mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-04 05:46:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adaab4ced8 | ||
|
|
5a2e149fa0 | ||
|
|
560c1f797c | ||
|
|
2d5be92f34 | ||
|
|
e9285fdac0 | ||
|
|
4f12c127af | ||
|
|
be7522382f | ||
|
|
987fb02be1 | ||
|
|
bbd8a34f44 | ||
|
|
720ebfd0ea | ||
|
|
159228972c | ||
|
|
2a65944eeb | ||
|
|
e7902c4565 | ||
|
|
f12588ef7c | ||
|
|
5fc197a40b | ||
|
|
64608f771e | ||
|
|
a9cebaf3d9 | ||
|
|
6c33dae498 | ||
|
|
e42d2dca04 | ||
|
|
35cd9cb6c5 | ||
|
|
2ce16325a1 | ||
|
|
275aeb7833 | ||
|
|
34737be5eb | ||
|
|
6fb5c873cc | ||
|
|
e0ffba5295 | ||
|
|
82f03e0769 | ||
|
|
b223cc4028 | ||
|
|
550a9abdbb | ||
|
|
a121d1b301 | ||
|
|
e794ff44a1 | ||
|
|
a22b6db130 | ||
|
|
1f7fc236db | ||
|
|
1edaea7905 | ||
|
|
e8cae710c7 | ||
|
|
489d92f9bb | ||
|
|
4d4c568afc | ||
|
|
e452c33a98 | ||
|
|
fdfd875580 | ||
|
|
a6e8ee20ae | ||
|
|
065dba2431 | ||
|
|
5f581c2f5e | ||
|
|
f7bfb68e7c | ||
|
|
2371e0678a | ||
|
|
97c78aaa3c | ||
|
|
9026c877ac | ||
|
|
238ade9ccb | ||
|
|
016bd1ed81 | ||
|
|
ed21d4cafb | ||
|
|
ecc0f39f7d | ||
|
|
26846fa91a | ||
|
|
fe3a0568f2 | ||
|
|
56252d33f1 | ||
|
|
fccfb2aaba | ||
|
|
7b55b69c12 | ||
|
|
23fad2a818 | ||
|
|
2dc23b601a | ||
|
|
504980d263 | ||
|
|
7cf7c9f082 | ||
|
|
dd8904de15 | ||
|
|
d1c43f8147 | ||
|
|
68506fd6af | ||
|
|
b795ca06d1 | ||
|
|
8695acb742 | ||
|
|
ed491f5e3a | ||
|
|
3a76c2a2de | ||
|
|
91b3b9e0e9 | ||
|
|
fb280650a5 | ||
|
|
e4baebf2f9 | ||
|
|
1a33da2537 | ||
|
|
3734ee58ea | ||
|
|
9554b36f80 | ||
|
|
5225c6ad07 | ||
|
|
c7be0e4a95 | ||
|
|
d4f060d509 | ||
|
|
9ba9ddee50 | ||
|
|
7bbdd5e877 | ||
|
|
6cc65ce84a | ||
|
|
6c711f8acc | ||
|
|
acefff80ee | ||
|
|
0e5b5d657f | ||
|
|
c2532322fd | ||
|
|
dc73aec334 | ||
|
|
aa909184fd | ||
|
|
205dd75e7e | ||
|
|
be9100dc5e | ||
|
|
b2e35cdb3e | ||
|
|
827e168bf2 | ||
|
|
a14d4e6f4e | ||
|
|
de61c0350e | ||
|
|
3e3c97076d | ||
|
|
f62c986016 | ||
|
|
04108e893d | ||
|
|
042394796a | ||
|
|
bfce57f686 | ||
|
|
5bb983ba5a | ||
|
|
debae02421 | ||
|
|
6fe93fdf33 | ||
|
|
3d225423ef | ||
|
|
49e1da8812 | ||
|
|
04addc4cba | ||
|
|
c5cf2c5442 | ||
|
|
ddb7e6b7f2 | ||
|
|
e5af34efca | ||
|
|
b3bdbbed98 | ||
|
|
656b066d3a | ||
|
|
4248fadc69 | ||
|
|
18281cbc49 | ||
|
|
1d67af328a | ||
|
|
72b4f6acef | ||
|
|
26273270ba | ||
|
|
db3824c3c7 | ||
|
|
d6de1d03d8 | ||
|
|
8e4b7c66b5 | ||
|
|
45c750069a | ||
|
|
a411a72024 | ||
|
|
219d163680 | ||
|
|
a4c9f42205 | ||
|
|
a8936c54d8 | ||
|
|
74875bb564 | ||
|
|
61d412d941 | ||
|
|
5bb1e47edf | ||
|
|
b023614b5f | ||
|
|
7245718003 | ||
|
|
ba1f98d930 | ||
|
|
ceeabffcb7 | ||
|
|
2a943493b3 | ||
|
|
332a2f41b6 | ||
|
|
ce2cfb7c86 | ||
|
|
41ef1abf0e | ||
|
|
fce27c3c55 | ||
|
|
6662966ab3 | ||
|
|
f458f00edc | ||
|
|
8f1fbe10e9 | ||
|
|
69efa78fbc | ||
|
|
64a07d8760 | ||
|
|
cb672d92d0 | ||
|
|
95bcbfdded | ||
|
|
ac269727bc | ||
|
|
b1a7d18c0a | ||
|
|
619b59357b | ||
|
|
86dcc3ccd9 | ||
|
|
6772360ae7 | ||
|
|
05cbbd79cb | ||
|
|
a80c03a2d2 | ||
|
|
6c3abe17ed | ||
|
|
7cd09261de | ||
|
|
6ef1bee8a0 | ||
|
|
66b8b04db3 | ||
|
|
3b06abb2bc | ||
|
|
138fafa204 | ||
|
|
9cbdbb8592 | ||
|
|
1c0ec93cbb | ||
|
|
c6fa2b5f80 | ||
|
|
dd55ad18e3 | ||
|
|
284da365a1 | ||
|
|
7a06c2e9c4 | ||
|
|
61449774a1 | ||
|
|
81a3baa41c | ||
|
|
4b7e58dd1c | ||
|
|
14ce42401b | ||
|
|
ecb8344287 | ||
|
|
9bc5549d76 | ||
|
|
8ac8894ccd | ||
|
|
efb9814f1b | ||
|
|
3582f587b3 | ||
|
|
4fbc9f6d53 | ||
|
|
e9ec1c45a0 | ||
|
|
b2b7cba352 | ||
|
|
393fbd0359 | ||
|
|
47ae42e3fe | ||
|
|
5db3ede09e | ||
|
|
eca420d789 | ||
|
|
5347373cde | ||
|
|
05596cb9bf | ||
|
|
3d1310295b | ||
|
|
35c780f81b | ||
|
|
b021920eaa | ||
|
|
2b27ac3aec | ||
|
|
c692c9125b | ||
|
|
7322fd7aae | ||
|
|
07a92cd70d |
@@ -7,6 +7,7 @@
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/androidTestResultsUserPreferences.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
/.idea/appInsightsSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
|
||||
+9
@@ -3,30 +3,39 @@
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
@@ -18,87 +18,88 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
|
||||
# Current Features
|
||||
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
|
||||
- [x] Home Feed
|
||||
- [x] Notifications Feed
|
||||
- [x] Global Feed
|
||||
- [x] Replies and mentions (NIP-10)
|
||||
- [x] Reactions (NIP-25)
|
||||
- [x] Reposts (NIP-18)
|
||||
- [x] Image/Video/Url/LnInvoice Previews
|
||||
- [x] View Threads
|
||||
- [x] Events / Relay Subscriptions (NIP-01)
|
||||
- [x] Follow List (NIP-02)
|
||||
- [ ] OpenTimestamps Attestations (NIP-03)
|
||||
- [x] Private Messages (NIP-04)
|
||||
- [x] User Profiles (edit/follow/unfollow - NIP-02)
|
||||
- [x] Public Chats (NIP-28)
|
||||
- [x] Bech Encoding support (NIP-19)
|
||||
- [x] Notification Dots
|
||||
- [x] Reporting and Hide User capability (NIP-56)
|
||||
- [x] Automatic Translations
|
||||
- [x] Relay Sets (home, dms, public chats, global)
|
||||
- [x] User/Note Tagging (NIP-08, NIP-10)
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Created_at Limits (NIP-22)
|
||||
- [x] DNS Address (NIP-05)
|
||||
- [ ] Mnemonic seed phrase (NIP-06)
|
||||
- [ ] WebBrowser Signer (NIP-07, Not applicable)
|
||||
- [x] Old-style mentions (NIP-08)
|
||||
- [x] Event Deletion (NIP-09)
|
||||
- [x] Nostr Address (NIP-05)
|
||||
- [x] Replies, mentions, Threads and Notifications (NIP-10)
|
||||
- [x] Relay Information Document (NIP-11)
|
||||
- [x] Generic Tag Queries (NIP-12)
|
||||
- [x] Proof of Work Display (NIP-13)
|
||||
- [ ] Proof of Work Calculations (NIP-13)
|
||||
- [x] Events with a Subject (NIP-14)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [x] Event Treatment (NIP-16)
|
||||
- [x] Image/Video/Url/LnInvoice Previews
|
||||
- [x] Reposts, Quotes, Generic Reposts (NIP-18)
|
||||
- [x] Bech Encoding support (NIP-19)
|
||||
- [x] Command Results (NIP-20)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Long-form Content (NIP-23)
|
||||
- [x] User Profile Fields / Relay list (NIP-24)
|
||||
- [x] Reactions (NIP-25)
|
||||
- [ ] Delegated Event Signing (NIP-26, Will not implement)
|
||||
- [x] Text Note References (NIP-27)
|
||||
- [x] Public Chats (NIP-28)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Event kind summaries (NIP-31)
|
||||
- [ ] Labeling (NIP-32)
|
||||
- [x] Parameterized Replaceable Events (NIP-33)
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [x] Internationalization
|
||||
- [x] Badges (NIP-58)
|
||||
- [x] Hashtag Following and Custom Hashtags
|
||||
- [x] Polls (NIP-69)
|
||||
- [x] Verifiable static content in URLs (NIP-94)
|
||||
- [x] Login with QR
|
||||
- [x] Sensitive Content (NIP-36)
|
||||
- [x] User Status Event (NIP-38)
|
||||
- [x] External Identities (NIP-39)
|
||||
- [x] Expiration Support (NIP-40)
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [ ] Event Counts (NIP-45, Will not implement)
|
||||
- [ ] Nostr Connect (NIP-46)
|
||||
- [x] Wallet Connect API (NIP-47)
|
||||
- [x] Accessible uploads
|
||||
- [ ] Proxy Tags (NIP-48, Not applicable)
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [x] Lists (NIP-51)
|
||||
- [ ] Calendar Events (NIP-52)
|
||||
- [x] Live Activities & Live Chats (NIP-53)
|
||||
- [x] Inline Metadata (NIP-55 - Draft)
|
||||
- [x] Reporting (NIP-56)
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (NIP-57)
|
||||
- [x] Private Zaps
|
||||
- [x] Zap Splits (NIP-57)
|
||||
- [x] Zapraiser (NIP-TBD)
|
||||
- [x] Badges (NIP-58)
|
||||
- [ ] Relay List Metadata (NIP-65)
|
||||
- [x] Polls (NIP-69)
|
||||
- [x] Moderated Communities (NIP-72)
|
||||
- [ ] Zap Goals (NIP-75)
|
||||
- [ ] Arbitrary Custom App Data (NIP-78)
|
||||
- [x] Highlights (NIP-84)
|
||||
- [x] Recommended Application Handlers (NIP-89)
|
||||
- [ ] Data Vending Machine (NIP-90)
|
||||
- [x] Verifiable file URLs (NIP-94)
|
||||
- [x] Binary Blobs (NIP-95)
|
||||
- [x] HTTP File Storage Integration (NIP-96 Draft)
|
||||
- [x] HTTP Auth (NIP-98)
|
||||
- [x] Classifieds (NIP-99)
|
||||
- [x] Private Messages and Small Groups (NIP-24/Draft)
|
||||
- [x] Gift Wraps & Seals (NIP-59/Draft)
|
||||
- [x] Versioned Encrypted Payloads (NIP-44/Draft)
|
||||
- [x] Audio Tracks (zapstr.live) (Kind:31337)
|
||||
- [x] Push Notifications (Google and Unified Push)
|
||||
- [x] In-Device Automatic Translations
|
||||
- [x] Hashtag Following and Custom Hashtags
|
||||
- [x] Login with QR
|
||||
- [x] Bounty support (nostrbounties.com)
|
||||
- [x] De-googled F-Droid flavor
|
||||
- [x] External Identity Support (NIP-39)
|
||||
- [x] Multiple Accounts
|
||||
- [x] Markdown Support
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Content stored in relays themselves (NIP-95)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Zap Forwarding (NIP-57)
|
||||
- [x] Text Note References (NIP-27)
|
||||
- [x] Audio Tracks (zapstr.live) (Kind:31337)
|
||||
- [x] Push Notifications (Zaps and Messages)
|
||||
- [x] Generic Tags (NIP-12)
|
||||
- [x] Sensitive Content (NIP-36)
|
||||
- [x] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [x] Recommended Application Handlers (NIP-89)
|
||||
- [x] Events with a Subject (NIP-14)
|
||||
- [x] Generic Reposts (kind:16)
|
||||
- [x] Live Activities & Live Chats (NIP-53)
|
||||
- [x] Relay Pages (NIP-11)
|
||||
- [x] HTTP Auth (NIP-98)
|
||||
- [x] Zapraiser (NIP-TBD)
|
||||
- [x] Moderated Communities (NIP-72)
|
||||
- [x] Emoji Packs (Kind:30030)
|
||||
- [x] Personal Emoji Lists (Kind:10030)
|
||||
- [x] Classifieds (Kind:30403)
|
||||
- [x] Private Messages and Small Groups (NIP-24)
|
||||
- [x] Gift Wraps & Seals (NIP-59)
|
||||
- [x] Versioned Encrypted Payloads (NIP-44)
|
||||
- [x] Expiration Support (NIP-40)
|
||||
- [x] Status Event (NIP-38)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Workspaces
|
||||
- [ ] Infinity Scroll
|
||||
- [ ] Relay List Metadata (NIP-65)
|
||||
- [ ] Signing Requests (NIP-46)
|
||||
- [ ] Delegated Event Signing (NIP-26)
|
||||
- [ ] Account Creation / Backup Guidance
|
||||
- [ ] Mnemonic seed phrase (NIP-06)
|
||||
- [ ] Message Sent feedback (NIP-20)
|
||||
- [ ] OpenTimestamps Attestations (NIP-03)
|
||||
|
||||
|
||||
# Development Overview
|
||||
|
||||
@@ -204,7 +205,7 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
|
||||
|
||||
# Screenshots
|
||||
|
||||
| Home Feed | Messages | Live Streams | Notifications |
|
||||
| FollowFeeds | ChatsGroup | LiveStreams | Notifications |
|
||||
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
|
||||
|  |  |  |  |
|
||||
|
||||
|
||||
+77
-76
@@ -13,83 +13,83 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 334
|
||||
versionName "0.81.3"
|
||||
versionCode 343
|
||||
versionName "0.83.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
resourceConfigurations += [
|
||||
'ar',
|
||||
'bn-rBD',
|
||||
'cs',
|
||||
'cy-rGB',
|
||||
'da-rDK',
|
||||
'de',
|
||||
'el-rGB',
|
||||
'en-rGB',
|
||||
'eo',
|
||||
'es',
|
||||
'es-rES',
|
||||
'es-rMX',
|
||||
'es-rUS',
|
||||
'et-rEE',
|
||||
'fa',
|
||||
'fi-rFI',
|
||||
'fo-rFO',
|
||||
'fr',
|
||||
'fr-rCA',
|
||||
'gu-rIN',
|
||||
'hi-rIN',
|
||||
'hr-rHR',
|
||||
'hu',
|
||||
'in',
|
||||
'in-rID',
|
||||
'it-rIT',
|
||||
'iw-rIL',
|
||||
'ja',
|
||||
'kk-rKZ',
|
||||
'ko-rKR',
|
||||
'ks-rIN',
|
||||
'ku-rTR',
|
||||
'lt-rLT',
|
||||
'ne-rNP',
|
||||
'night',
|
||||
'nl',
|
||||
'nl-rBE',
|
||||
'pcm-rNG',
|
||||
'pl-rPL',
|
||||
'pt-rBR',
|
||||
'pt-rPT',
|
||||
'ru',
|
||||
'ru-rUA',
|
||||
'sa-rIN',
|
||||
'sl-rSI',
|
||||
'so-rSO',
|
||||
'sr-rSP',
|
||||
'ss-rZA',
|
||||
'sv-rSE',
|
||||
'sw-rKE',
|
||||
'sw-rTZ',
|
||||
'ta',
|
||||
'th',
|
||||
'tr',
|
||||
'uk',
|
||||
'ur-rIN',
|
||||
'uz-rUZ',
|
||||
'vi-rVN',
|
||||
'zh',
|
||||
'zh-rCN',
|
||||
'zh-rHK',
|
||||
'zh-rSG',
|
||||
'zh-rTW'
|
||||
'ar',
|
||||
'bn-rBD',
|
||||
'cs',
|
||||
'cy-rGB',
|
||||
'da-rDK',
|
||||
'de',
|
||||
'el-rGB',
|
||||
'en-rGB',
|
||||
'eo',
|
||||
'es',
|
||||
'es-rES',
|
||||
'es-rMX',
|
||||
'es-rUS',
|
||||
'et-rEE',
|
||||
'fa',
|
||||
'fi-rFI',
|
||||
'fo-rFO',
|
||||
'fr',
|
||||
'fr-rCA',
|
||||
'gu-rIN',
|
||||
'hi-rIN',
|
||||
'hr-rHR',
|
||||
'hu',
|
||||
'in',
|
||||
'in-rID',
|
||||
'it-rIT',
|
||||
'iw-rIL',
|
||||
'ja',
|
||||
'kk-rKZ',
|
||||
'ko-rKR',
|
||||
'ks-rIN',
|
||||
'ku-rTR',
|
||||
'lt-rLT',
|
||||
'ne-rNP',
|
||||
'night',
|
||||
'nl',
|
||||
'nl-rBE',
|
||||
'pcm-rNG',
|
||||
'pl-rPL',
|
||||
'pt-rBR',
|
||||
'pt-rPT',
|
||||
'ru',
|
||||
'ru-rUA',
|
||||
'sa-rIN',
|
||||
'sl-rSI',
|
||||
'so-rSO',
|
||||
'sr-rSP',
|
||||
'ss-rZA',
|
||||
'sv-rSE',
|
||||
'sw-rKE',
|
||||
'sw-rTZ',
|
||||
'ta',
|
||||
'th',
|
||||
'tr',
|
||||
'uk',
|
||||
'ur-rIN',
|
||||
'uz-rUZ',
|
||||
'vi-rVN',
|
||||
'zh',
|
||||
'zh-rCN',
|
||||
'zh-rHK',
|
||||
'zh-rSG',
|
||||
'zh-rTW'
|
||||
]
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
//signingConfig signingConfigs.debug
|
||||
signingConfig signingConfigs.debug
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
|
||||
minifyEnabled true
|
||||
resValue "string", "app_name", "@string/app_name_release"
|
||||
@@ -134,6 +134,7 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
@@ -158,7 +159,7 @@ android {
|
||||
dependencies {
|
||||
implementation project(path: ':quartz')
|
||||
implementation "androidx.core:core-ktx:$core_ktx_version"
|
||||
implementation 'androidx.activity:activity-compose:1.8.1'
|
||||
implementation 'androidx.activity:activity-compose:1.8.2'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
|
||||
@@ -187,16 +188,16 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
||||
|
||||
// Zoomable images
|
||||
implementation 'net.engawapg.lib:zoomable:1.5.1'
|
||||
implementation 'net.engawapg.lib:zoomable:1.5.2'
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
// Websockets API
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.12'
|
||||
|
||||
// HTML Parsing for Link Preview
|
||||
implementation 'org.jsoup:jsoup:1.16.2'
|
||||
implementation 'org.jsoup:jsoup:1.17.1'
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
|
||||
@@ -233,9 +234,9 @@ dependencies {
|
||||
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
|
||||
|
||||
// Markdown (With fix for full-image bleeds)
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:a0954aba63')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material3:a0954aba63')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:a0954aba63')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:48702a8ced')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material3:48702a8ced')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:48702a8ced')
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
@@ -266,14 +267,14 @@ dependencies {
|
||||
implementation 'com.github.lincollincol:compose-audiowaveform:1.1.1'
|
||||
|
||||
// Video compression lib
|
||||
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.1'
|
||||
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.2'
|
||||
// Image compression lib
|
||||
implementation 'id.zelory:compressor:3.0.1'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'io.mockk:mockk:1.13.8'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
|
||||
androidTestImplementation "androidx.test.ext:junit-ktx:1.2.0-alpha01"
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha02'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha02'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
|
||||
|
||||
@@ -2,73 +2,116 @@ package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.FileServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.Nip96Retriever
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import junit.framework.TestCase.fail
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ImageUploadTesting {
|
||||
|
||||
val contentType = "image/gif"
|
||||
val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=="
|
||||
|
||||
private fun testBase(server: FileServer) {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val contentTypePng = "image/png"
|
||||
val imagePng = "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII="
|
||||
|
||||
private suspend fun testBase(server: Nip96MediaServers.ServerName) {
|
||||
val serverInfo = Nip96Retriever().loadInfo(
|
||||
server.baseUrl
|
||||
)
|
||||
|
||||
val bytes = Base64.getDecoder().decode(imagePng)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
var url: String? = null
|
||||
var error: String? = null
|
||||
val account = Account(KeyPair())
|
||||
|
||||
ImageUploader(Account(KeyPair())).uploadImage(
|
||||
val result = Nip96Uploader(account).uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
server,
|
||||
onSuccess = { newUrl, contentType ->
|
||||
println("Uploaded $contentType to $url")
|
||||
url = newUrl
|
||||
countDownLatch.countDown()
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
error = it.message
|
||||
countDownLatch.countDown()
|
||||
contentTypePng,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverInfo,
|
||||
onProgress = {
|
||||
}
|
||||
)
|
||||
|
||||
countDownLatch.await()
|
||||
val url = result.tags!!.first() { it[0] == "url" }.get(1)
|
||||
val size = result.tags!!.firstOrNull() { it[0] == "size" }?.get(1)?.ifBlank { null }
|
||||
val dim = result.tags!!.firstOrNull() { it[0] == "dim" }?.get(1)?.ifBlank { null }
|
||||
val hash = result.tags!!.firstOrNull() { it[0] == "x" }?.get(1)?.ifBlank { null }
|
||||
val contentType = result.tags!!.first() { it[0] == "m" }.get(1)
|
||||
val ox = result.tags!!.first() { it[0] == "ox" }.get(1)
|
||||
|
||||
Assert.assertNull(error)
|
||||
Assert.assertTrue(url?.startsWith("http") == true)
|
||||
Assert.assertTrue(url.startsWith("http"))
|
||||
|
||||
val imageData: ByteArray = ImageDownloader().waitAndGetImage(url) ?: run {
|
||||
fail("Should not be null")
|
||||
return
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
imageData,
|
||||
contentTypePng,
|
||||
null,
|
||||
onReady = {
|
||||
if (dim != null) {
|
||||
assertEquals(dim, it.dim)
|
||||
}
|
||||
if (size != null) {
|
||||
assertEquals(size, it.size.toString())
|
||||
}
|
||||
if (hash != null) {
|
||||
assertEquals(hash, it.hash)
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
fail("It should not fail")
|
||||
}
|
||||
)
|
||||
|
||||
// delay(1000)
|
||||
|
||||
// assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testImgurUpload() = runBlocking {
|
||||
testBase(ImgurServer())
|
||||
fun testNostrCheck() = runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrBuildUpload() = runBlocking {
|
||||
testBase(NostrBuildServer())
|
||||
fun testNostrage() = runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrImgUpload() = runBlocking {
|
||||
testBase(NostrImgServer())
|
||||
fun testSove() = runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrFilesDevUpload() = runBlocking {
|
||||
testBase(NostrFilesDevServer())
|
||||
fun testNostrBuild() = runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testSovbit() = runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testVoidCat() = runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -9,8 +10,13 @@ import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import junit.framework.TestCase
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ThreadAssemblerTest {
|
||||
val db = """
|
||||
[
|
||||
@@ -85,7 +91,7 @@ class ThreadAssemblerTest {
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun threadOrderTest() {
|
||||
fun threadOrderTest() = runBlocking {
|
||||
val eventArray = Event.mapper.readValue<ArrayList<Event>>(db) as List<Event> + Event.fromJson(header)
|
||||
|
||||
var counter = 0
|
||||
@@ -97,7 +103,12 @@ class ThreadAssemblerTest {
|
||||
|
||||
val naddr = ATag(30023, "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", "1680612926599", null)
|
||||
|
||||
val filter = ThreadFeedFilter(Account(KeyPair()), naddr.toTag())
|
||||
val account = Account(KeyPair())
|
||||
withContext(Dispatchers.Main) {
|
||||
val user = account.userProfile().live()
|
||||
}
|
||||
|
||||
val filter = ThreadFeedFilter(account, naddr.toTag())
|
||||
val calculatedFeed = filter.feed()
|
||||
|
||||
val expecteedOrder = listOf(
|
||||
|
||||
@@ -15,13 +15,13 @@ import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.Nip47URI
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.model.parseBooleanType
|
||||
import com.vitorpamplona.amethyst.model.parseConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.parseThemeType
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
@@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
@@ -57,9 +59,6 @@ private object PrefKeys {
|
||||
const val SAVED_ACCOUNTS = "all_saved_accounts"
|
||||
const val NOSTR_PRIVKEY = "nostr_privkey"
|
||||
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||
const val FOLLOWING_CHANNELS = "following_channels"
|
||||
const val FOLLOWING_COMMUNITIES = "following_communities"
|
||||
const val HIDDEN_USERS = "hidden_users"
|
||||
const val RELAYS = "relays"
|
||||
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
|
||||
const val LANGUAGE_PREFS = "languagePreferences"
|
||||
@@ -102,6 +101,7 @@ object LocalPreferences {
|
||||
|
||||
private var _currentAccount: String? = null
|
||||
private var _savedAccounts: List<AccountInfo>? = null
|
||||
private var _cachedAccounts: MutableMap<String, Account?> = mutableMapOf()
|
||||
|
||||
suspend fun currentAccount(): String? {
|
||||
if (_currentAccount == null) {
|
||||
@@ -264,7 +264,7 @@ object LocalPreferences {
|
||||
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices))
|
||||
putString(PrefKeys.REACTION_CHOICES, Event.mapper.writeValueAsString(account.reactionChoices))
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name)
|
||||
putString(PrefKeys.DEFAULT_FILE_SERVER, account.defaultFileServer.name)
|
||||
putString(PrefKeys.DEFAULT_FILE_SERVER, Event.mapper.writeValueAsString(account.defaultFileServer))
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value)
|
||||
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList.value)
|
||||
@@ -289,9 +289,9 @@ object LocalPreferences {
|
||||
}
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(): Account? {
|
||||
val acc = loadCurrentAccountFromEncryptedStorage(currentAccount())
|
||||
acc?.registerObservers()
|
||||
return acc
|
||||
return currentAccount()?.let {
|
||||
loadCurrentAccountFromEncryptedStorage(it)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun migrateOldSharedSettings(): Settings? {
|
||||
@@ -379,7 +379,23 @@ object LocalPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String?): Account? = withContext(Dispatchers.IO) {
|
||||
val mutex = Mutex()
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
if (_cachedAccounts.containsKey(npub)) {
|
||||
return@withContext _cachedAccounts.get(npub)
|
||||
}
|
||||
|
||||
val account = innerLoadCurrentAccountFromEncryptedStorage(npub)
|
||||
account?.registerObservers()
|
||||
|
||||
_cachedAccounts.put(npub, account)
|
||||
|
||||
return@withContext account
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? = withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
return@withContext with(encryptedPreferences(npub)) {
|
||||
@@ -387,9 +403,6 @@ object LocalPreferences {
|
||||
val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
|
||||
val privKey = if (loginWithExternalSigner) null else getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
|
||||
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
|
||||
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
|
||||
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
|
||||
val localRelays = getString(PrefKeys.RELAYS, "[]")?.let {
|
||||
println("LocalRelays: $it")
|
||||
Event.mapper.readValue<Set<RelaySetupInfo>?>(it)
|
||||
@@ -414,9 +427,15 @@ object LocalPreferences {
|
||||
LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName }
|
||||
} ?: LnZapEvent.ZapType.PUBLIC
|
||||
|
||||
val defaultFileServer = getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
|
||||
ServersAvailable.values().firstOrNull() { it.name == serverName }
|
||||
} ?: ServersAvailable.NOSTR_BUILD
|
||||
val defaultFileServer = try {
|
||||
getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
|
||||
Event.mapper.readValue<Nip96MediaServers.ServerName>(serverName)
|
||||
} ?: Nip96MediaServers.DEFAULT[0]
|
||||
} catch (e: Exception) {
|
||||
Log.w("LocalPreferences", "Failed to decode saved File Server", e)
|
||||
e.printStackTrace()
|
||||
Nip96MediaServers.DEFAULT[0]
|
||||
}
|
||||
|
||||
val zapPaymentRequestServer = try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
@@ -485,7 +504,7 @@ object LocalPreferences {
|
||||
NostrSignerInternal(keyPair)
|
||||
}
|
||||
|
||||
return@with Account(
|
||||
val account = Account(
|
||||
keyPair = keyPair,
|
||||
signer = signer,
|
||||
localRelays = localRelays,
|
||||
@@ -512,6 +531,16 @@ object LocalPreferences {
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute
|
||||
)
|
||||
|
||||
// Loads from DB
|
||||
account.userProfile()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// Loads Live Objects
|
||||
account.userProfile().live()
|
||||
}
|
||||
|
||||
return@with account
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
object OptOutFromFilters {
|
||||
var warnAboutPostsWithReports: Boolean = true
|
||||
var filterSpamFromStrangers: Boolean = true
|
||||
|
||||
fun start(warnAboutReports: Boolean, filterSpam: Boolean) {
|
||||
warnAboutPostsWithReports = warnAboutReports
|
||||
filterSpamFromStrangers = filterSpam
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,15 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -39,6 +45,9 @@ class ServiceManager {
|
||||
private var isStarted: Boolean = false // to not open amber in a loop trying to use auth relays and registering for notifications
|
||||
private var account: Account? = null
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var collectorJob: Job? = null
|
||||
|
||||
private fun start(account: Account) {
|
||||
this.account = account
|
||||
start()
|
||||
@@ -55,7 +64,7 @@ class ServiceManager {
|
||||
|
||||
// Resets Proxy Use
|
||||
HttpClient.start(account?.proxy)
|
||||
OptOutFromFilters.start(account?.warnAboutPostsWithReports ?: true, account?.filterSpamFromStrangers ?: true)
|
||||
LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true
|
||||
Coil.setImageLoader {
|
||||
Amethyst.instance.imageLoaderBuilder().components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
@@ -72,10 +81,30 @@ class ServiceManager {
|
||||
}
|
||||
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
|
||||
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
|
||||
Client.reconnect(relaySet)
|
||||
|
||||
collectorJob?.cancel()
|
||||
collectorJob = null
|
||||
collectorJob = scope.launch {
|
||||
myAccount.userProfile().flow().relays.stateFlow.collect {
|
||||
if (isStarted) {
|
||||
val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
|
||||
Client.reconnect(newRelaySet, onlyIfChanged = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// start services
|
||||
NostrAccountDataSource.account = myAccount
|
||||
NostrAccountDataSource.otherAccounts = LocalPreferences.allSavedAccounts().mapNotNull {
|
||||
try {
|
||||
it.npub.bechToBytes().toHexKey()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
@@ -102,6 +131,9 @@ class ServiceManager {
|
||||
private fun pause() {
|
||||
Log.d("ServiceManager", "Pausing Relay Services")
|
||||
|
||||
collectorJob?.cancel()
|
||||
collectorJob = null
|
||||
|
||||
NostrAccountDataSource.stopSync()
|
||||
NostrHomeDataSource.stopSync()
|
||||
NostrChannelDataSource.stopSync()
|
||||
@@ -120,7 +152,7 @@ class ServiceManager {
|
||||
NostrUserProfileDataSource.stopSync()
|
||||
NostrVideoDataSource.stopSync()
|
||||
|
||||
Client.disconnect()
|
||||
Client.reconnect(null)
|
||||
isStarted = false
|
||||
}
|
||||
|
||||
@@ -181,4 +213,9 @@ class ServiceManager {
|
||||
fun pauseForGood() {
|
||||
forceRestart(null, false, true)
|
||||
}
|
||||
|
||||
fun pauseForGoodAndClearAccount() {
|
||||
account = null
|
||||
forceRestart(null, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,15 +11,14 @@ import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.liveData
|
||||
import androidx.lifecycle.switchMap
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.OptOutFromFilters
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
@@ -31,6 +30,7 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.Contact
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.DeletionEvent
|
||||
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiUrl
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileServersEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.GeneralListEvent
|
||||
@@ -56,6 +57,7 @@ import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.NIP24Factory
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.Price
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
@@ -92,6 +94,7 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.math.BigDecimal
|
||||
import java.net.Proxy
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
val DefaultChannels = setOf(
|
||||
@@ -99,7 +102,7 @@ val DefaultChannels = setOf(
|
||||
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
|
||||
)
|
||||
|
||||
val DefaultReactions = listOf("\uD83D\uDE80", "\uD83E\uDEC2", "\uD83D\uDC40", "\uD83D\uDE02")
|
||||
val DefaultReactions = listOf("\uD83D\uDE80", "\uD83E\uDEC2", "\uD83D\uDC40", "\uD83D\uDE02", "\uD83C\uDF89", "\uD83E\uDD14", "\uD83D\uDE31")
|
||||
|
||||
val DefaultZapAmounts = listOf(500L, 1000L, 5000L)
|
||||
|
||||
@@ -128,7 +131,7 @@ class Account(
|
||||
var zapAmountChoices: List<Long> = DefaultZapAmounts,
|
||||
var reactionChoices: List<String> = DefaultReactions,
|
||||
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
|
||||
var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD,
|
||||
var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0],
|
||||
var defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
|
||||
var defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
var defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
@@ -210,6 +213,8 @@ class Account(
|
||||
}
|
||||
result?.let {
|
||||
emit(it)
|
||||
} ?: run {
|
||||
emit(LiveFollowLists())
|
||||
}
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
|
||||
@@ -241,6 +246,8 @@ class Account(
|
||||
}
|
||||
result?.let {
|
||||
emit(it)
|
||||
} ?: run {
|
||||
emit(LiveFollowLists())
|
||||
}
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
|
||||
@@ -272,6 +279,8 @@ class Account(
|
||||
}
|
||||
result?.let {
|
||||
emit(it)
|
||||
} ?: run {
|
||||
emit(LiveFollowLists())
|
||||
}
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
|
||||
@@ -303,6 +312,8 @@ class Account(
|
||||
}
|
||||
result?.let {
|
||||
emit(it)
|
||||
} ?: run {
|
||||
emit(LiveFollowLists())
|
||||
}
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
|
||||
@@ -338,18 +349,22 @@ class Account(
|
||||
) { localLive, blockList, muteList ->
|
||||
checkNotInMainThread()
|
||||
|
||||
val resultBlockList = withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
(blockList.note.event as? PeopleListEvent)?.publicAndPrivateUsersAndWords(signer) {
|
||||
continuation.resume(it)
|
||||
val resultBlockList = (blockList.note.event as? PeopleListEvent)?.let {
|
||||
withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
it.publicAndPrivateUsersAndWords(signer) {
|
||||
continuation.resume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: PeopleListEvent.UsersAndWords()
|
||||
|
||||
val resultMuteList = withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
(muteList.note.event as? MuteListEvent)?.publicAndPrivateUsersAndWords(signer) {
|
||||
continuation.resume(it)
|
||||
val resultMuteList = (muteList.note.event as? MuteListEvent)?.let {
|
||||
withTimeoutOrNull(1000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
it.publicAndPrivateUsersAndWords(signer) {
|
||||
continuation.resume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: PeopleListEvent.UsersAndWords()
|
||||
@@ -410,7 +425,7 @@ class Account(
|
||||
fun updateOptOutOptions(warnReports: Boolean, filterSpam: Boolean) {
|
||||
warnAboutPostsWithReports = warnReports
|
||||
filterSpamFromStrangers = filterSpam
|
||||
OptOutFromFilters.start(warnAboutPostsWithReports, filterSpamFromStrangers)
|
||||
LocalCache.antiSpam.active = filterSpamFromStrangers
|
||||
if (!filterSpamFromStrangers) {
|
||||
transientHiddenUsers = persistentSetOf()
|
||||
}
|
||||
@@ -436,7 +451,7 @@ class Account(
|
||||
val contactList = userProfile().latestContactList
|
||||
|
||||
if (contactList != null && contactList.tags.isNotEmpty()) {
|
||||
val event = ContactListEvent.updateRelayList(
|
||||
ContactListEvent.updateRelayList(
|
||||
earlierVersion = contactList,
|
||||
relayUse = relays,
|
||||
signer = signer
|
||||
@@ -445,7 +460,7 @@ class Account(
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
} else {
|
||||
val event = ContactListEvent.createFromScratch(
|
||||
ContactListEvent.createFromScratch(
|
||||
followUsers = listOf(),
|
||||
followTags = listOf(),
|
||||
followGeohashes = listOf(),
|
||||
@@ -461,10 +476,10 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendNewUserMetadata(toString: String, identities: List<IdentityClaim>) {
|
||||
suspend fun sendNewUserMetadata(toString: String, newName: String, identities: List<IdentityClaim>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
MetadataEvent.create(toString, identities, signer) {
|
||||
MetadataEvent.create(toString, newName, identities, signer) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
@@ -683,7 +698,9 @@ class Account(
|
||||
suspend fun delete(notes: List<Note>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex }
|
||||
val myNotes = notes.filter { it.author == userProfile() }.mapNotNull {
|
||||
it.event?.id()
|
||||
}
|
||||
|
||||
if (myNotes.isNotEmpty()) {
|
||||
DeletionEvent.create(myNotes, signer) {
|
||||
@@ -693,7 +710,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun createHTTPAuthorization(url: String, method: String, body: String? = null, onReady: (HTTPAuthorizationEvent) -> Unit) {
|
||||
fun createHTTPAuthorization(url: String, method: String, body: ByteArray? = null, onReady: (HTTPAuthorizationEvent) -> Unit) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
HTTPAuthorizationEvent.create(url, method, body, signer, onReady = onReady)
|
||||
@@ -949,7 +966,13 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun createNip95(byteArray: ByteArray, headerInfo: FileHeader, onReady: (Pair<FileStorageEvent, FileStorageHeaderEvent>) -> Unit) {
|
||||
fun createNip95(
|
||||
byteArray: ByteArray,
|
||||
headerInfo: FileHeader,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
onReady: (Pair<FileStorageEvent, FileStorageHeaderEvent>) -> Unit
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
FileStorageEvent.create(
|
||||
@@ -964,8 +987,8 @@ class Account(
|
||||
size = headerInfo.size.toString(),
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
alt = headerInfo.alt,
|
||||
sensitiveContent = headerInfo.sensitiveContent,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
signer = signer
|
||||
) { signedEvent ->
|
||||
onReady(
|
||||
@@ -975,7 +998,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null): Note? {
|
||||
fun consumeAndSendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null): Note? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
Client.send(data, relayList = relayList)
|
||||
@@ -987,7 +1010,19 @@ class Account(
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
}
|
||||
|
||||
private fun sendHeader(signedEvent: FileHeaderEvent, relayList: List<Relay>? = null, onReady: (Note) -> Unit) {
|
||||
fun consumeNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
|
||||
LocalCache.consume(data, null)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
}
|
||||
|
||||
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null) {
|
||||
Client.send(data, relayList = relayList)
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
}
|
||||
|
||||
fun sendHeader(signedEvent: FileHeaderEvent, relayList: List<Relay>? = null, onReady: (Note) -> Unit) {
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
|
||||
@@ -996,24 +1031,123 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendHeader(headerInfo: FileHeader, relayList: List<Relay>? = null, onReady: (Note) -> Unit) {
|
||||
fun createHeader(
|
||||
imageUrl: String,
|
||||
magnetUri: String?,
|
||||
headerInfo: FileHeader,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
originalHash: String? = null,
|
||||
onReady: (FileHeaderEvent) -> Unit
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
FileHeaderEvent.create(
|
||||
url = headerInfo.url,
|
||||
url = imageUrl,
|
||||
magnetUri = magnetUri,
|
||||
mimeType = headerInfo.mimeType,
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size.toString(),
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
alt = headerInfo.alt,
|
||||
sensitiveContent = headerInfo.sensitiveContent,
|
||||
alt = alt,
|
||||
originalHash = originalHash,
|
||||
sensitiveContent = sensitiveContent,
|
||||
signer = signer
|
||||
) { event ->
|
||||
onReady(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendHeader(
|
||||
imageUrl: String,
|
||||
magnetUri: String?,
|
||||
headerInfo: FileHeader,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
originalHash: String? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
onReady: (Note) -> Unit
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
FileHeaderEvent.create(
|
||||
url = imageUrl,
|
||||
magnetUri = magnetUri,
|
||||
mimeType = headerInfo.mimeType,
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size.toString(),
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
alt = alt,
|
||||
originalHash = originalHash,
|
||||
sensitiveContent = sensitiveContent,
|
||||
signer = signer
|
||||
) { event ->
|
||||
sendHeader(event, relayList = relayList, onReady)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendClassifieds(
|
||||
title: String,
|
||||
price: Price,
|
||||
condition: ClassifiedsEvent.CONDITION,
|
||||
location: String,
|
||||
category: String,
|
||||
message: String,
|
||||
replyTo: List<Note>?,
|
||||
mentions: List<User>?,
|
||||
directMentions: Set<HexKey>,
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<Event>? = null
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
val addresses = replyTo?.mapNotNull { it.address() }
|
||||
|
||||
ClassifiedsEvent.create(
|
||||
dTag = UUID.randomUUID().toString(),
|
||||
title = title,
|
||||
price = price,
|
||||
condition = condition,
|
||||
summary = message,
|
||||
image = null,
|
||||
location = location,
|
||||
category = category,
|
||||
message = message,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
addresses = addresses,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
directMentions = directMentions,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it, relayList = relayList)
|
||||
LocalCache.justConsume(it, null)
|
||||
|
||||
replyTo?.forEach {
|
||||
it.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
addresses?.forEach {
|
||||
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
|
||||
Client.send(it, relayList = relayList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPost(
|
||||
message: String,
|
||||
replyTo: List<Note>?,
|
||||
@@ -1026,7 +1160,8 @@ class Account(
|
||||
root: String?,
|
||||
directMentions: Set<HexKey>,
|
||||
relayList: List<Relay>? = null,
|
||||
geohash: String? = null
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<Event>? = null
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -1047,6 +1182,7 @@ class Account(
|
||||
root = root,
|
||||
directMentions = directMentions,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it, relayList = relayList)
|
||||
@@ -1084,7 +1220,8 @@ class Account(
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
geohash: String? = null
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<Event>? = null
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -1106,7 +1243,8 @@ class Account(
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments
|
||||
) {
|
||||
Client.send(it, relayList = relayList)
|
||||
LocalCache.justConsume(it, null)
|
||||
@@ -1125,7 +1263,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
|
||||
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List<Event>? = null) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = replyTo?.map { it.idHex }
|
||||
@@ -1140,6 +1278,7 @@ class Account(
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
@@ -1147,7 +1286,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
|
||||
fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: List<ZapSplitSetup>? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List<Event>? = null) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
@@ -1163,6 +1302,7 @@ class Account(
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
nip94attachments = nip94attachments,
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
@@ -1470,6 +1610,16 @@ class Account(
|
||||
return LocalCache.getOrCreateAddressableNote(aTag)
|
||||
}
|
||||
|
||||
fun getFileServersNote(): AddressableNote {
|
||||
val aTag = ATag(
|
||||
FileServersEvent.kind,
|
||||
userProfile().pubkeyHex,
|
||||
"",
|
||||
null
|
||||
)
|
||||
return LocalCache.getOrCreateAddressableNote(aTag)
|
||||
}
|
||||
|
||||
fun getBlockList(): PeopleListEvent? {
|
||||
return getBlockListNote().event as? PeopleListEvent
|
||||
}
|
||||
@@ -1478,6 +1628,10 @@ class Account(
|
||||
return getMuteListNote().event as? MuteListEvent
|
||||
}
|
||||
|
||||
fun getFileServersList(): FileServersEvent? {
|
||||
return getFileServersNote().event as? FileServersEvent
|
||||
}
|
||||
|
||||
fun hideWord(word: String) {
|
||||
val muteList = getMuteList()
|
||||
|
||||
@@ -1489,7 +1643,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
} else {
|
||||
MuteListEvent.createListWithWord(
|
||||
@@ -1498,7 +1652,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1514,7 +1668,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1528,7 +1682,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1544,7 +1698,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
} else {
|
||||
MuteListEvent.createListWithUser(
|
||||
@@ -1553,7 +1707,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1569,7 +1723,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1583,7 +1737,7 @@ class Account(
|
||||
signer = signer
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.consume(it)
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1598,7 +1752,7 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeDefaultFileServer(server: ServersAvailable) {
|
||||
fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) {
|
||||
defaultFileServer = server
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
@@ -1761,7 +1915,7 @@ class Account(
|
||||
?: Constants.defaultRelays.filter { defaultRelay -> defaultRelay.url == it.key }.firstOrNull()?.feedTypes
|
||||
?: FeedType.values().toSet()
|
||||
|
||||
Relay(it.key, it.value.read, it.value.write, localFeedTypes, proxy)
|
||||
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
|
||||
} ?: return null
|
||||
|
||||
// Ugly, but forces nostr.band as the only search-supporting relay today.
|
||||
@@ -1774,8 +1928,7 @@ class Account(
|
||||
it.url,
|
||||
it.read,
|
||||
it.write,
|
||||
it.feedTypes,
|
||||
proxy
|
||||
it.feedTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1785,7 +1938,7 @@ class Account(
|
||||
|
||||
fun convertLocalRelays(): Array<Relay> {
|
||||
return localRelays.map {
|
||||
Relay(it.url, it.read, it.write, it.feedTypes, proxy)
|
||||
Relay(it.url, it.read, it.write, it.feedTypes)
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
@@ -1799,15 +1952,6 @@ class Account(
|
||||
return (activeRelays() ?: convertLocalRelays()).filter { it.write }
|
||||
}
|
||||
|
||||
fun reconnectIfRelaysHaveChanged() {
|
||||
val newRelaySet = activeRelays() ?: convertLocalRelays()
|
||||
if (!Client.isSameRelaySetConfig(newRelaySet)) {
|
||||
Client.disconnect()
|
||||
Client.connect(newRelaySet)
|
||||
RelayPool.requestAndWatch()
|
||||
}
|
||||
}
|
||||
|
||||
fun isAllHidden(users: Set<HexKey>): Boolean {
|
||||
return users.all { isHidden(it) }
|
||||
}
|
||||
@@ -1886,7 +2030,7 @@ class Account(
|
||||
).toSet()
|
||||
}
|
||||
|
||||
suspend fun saveRelayList(value: List<RelaySetupInfo>) {
|
||||
fun saveRelayList(value: List<RelaySetupInfo>) {
|
||||
try {
|
||||
localRelays = value.toSet()
|
||||
return sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) })
|
||||
@@ -1932,13 +2076,6 @@ class Account(
|
||||
}
|
||||
|
||||
suspend fun registerObservers() = withContext(Dispatchers.Main) {
|
||||
// Observes relays to restart connections
|
||||
userProfile().live().relays.observeForever {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
reconnectIfRelaysHaveChanged()
|
||||
}
|
||||
}
|
||||
|
||||
// saves contact list for the next time.
|
||||
userProfile().live().follows.observeForever {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.OptOutFromFilters
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
@@ -19,10 +18,12 @@ class AntiSpamFilter {
|
||||
val recentMessages = LruCache<Int, String>(1000)
|
||||
val spamMessages = LruCache<Int, Spammer>(1000)
|
||||
|
||||
var active: Boolean = true
|
||||
|
||||
fun isSpam(event: Event, relay: Relay?): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (!OptOutFromFilters.filterSpamFromStrangers) return false
|
||||
if (!active) return false
|
||||
|
||||
val idHex = event.id
|
||||
|
||||
|
||||
@@ -20,11 +20,8 @@ class PublicChatChannel(idHex: String) : Channel(idHex) {
|
||||
var info = ChannelCreateEvent.ChannelData(null, null, null)
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.invalidateData()
|
||||
super.updateChannelInfo(creator, updatedAt)
|
||||
}
|
||||
|
||||
override fun toBestDisplayName(): String {
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.events.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileServersEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
@@ -276,7 +277,7 @@ object LocalCache {
|
||||
// avoids processing empty contact lists.
|
||||
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
|
||||
user.updateContactList(event)
|
||||
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
|
||||
// Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,16 +427,17 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: MuteListEvent) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: PeopleListEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: AdvertisedRelayListEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: EmojiPackSelectionEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: EmojiPackEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: ClassifiedsEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: PinListEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: RelaySetEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: AudioTrackEvent) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: MuteListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
fun consume(event: FileServersEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
fun consume(event: PeopleListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: AdvertisedRelayListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
fun consume(event: EmojiPackSelectionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: EmojiPackEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: ClassifiedsEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: PinListEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: RelaySetEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: AudioTrackEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
fun consume(event: StatusEvent, relay: Relay?) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
@@ -458,7 +460,7 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BadgeDefinitionEvent) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: BadgeDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
|
||||
fun consume(event: BadgeProfilesEvent) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
@@ -504,14 +506,14 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
private fun comsume(event: NNSEvent) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: AppDefinitionEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CalendarEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CalendarDateSlotEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CalendarTimeSlotEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CalendarRSVPEvent) { consumeBaseReplaceable(event) }
|
||||
private fun comsume(event: NNSEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
fun consume(event: AppDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: CalendarEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: CalendarDateSlotEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: CalendarTimeSlotEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
private fun consume(event: CalendarRSVPEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
|
||||
private fun consumeBaseReplaceable(event: BaseAddressableEvent) {
|
||||
private fun consumeBaseReplaceable(event: BaseAddressableEvent, relay: Relay?) {
|
||||
val version = getOrCreateNote(event.id)
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
@@ -521,6 +523,11 @@ object LocalCache {
|
||||
version.moveAllReferencesTo(note)
|
||||
}
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
@@ -531,7 +538,7 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: AppRecommendationEvent) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: AppRecommendationEvent, relay: Relay?) { consumeBaseReplaceable(event, relay) }
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun consume(event: RecommendRelayEvent) {
|
||||
@@ -776,7 +783,7 @@ object LocalCache {
|
||||
|
||||
fun consume(event: ChannelMetadataEvent) {
|
||||
val channelId = event.channel()
|
||||
// Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
|
||||
// Log.d("MT", "New PublicChatMetadata ${event.channelInfo()}")
|
||||
if (channelId.isNullOrBlank()) return
|
||||
|
||||
// new event
|
||||
@@ -784,10 +791,8 @@ object LocalCache {
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
if (event.createdAt > oldChannel.updatedMetadataAt) {
|
||||
if (oldChannel.creator == null || oldChannel.creator == author) {
|
||||
if (oldChannel is PublicChatChannel) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
}
|
||||
if (oldChannel is PublicChatChannel) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
}
|
||||
} else {
|
||||
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
@@ -1511,26 +1516,26 @@ object LocalCache {
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
is AdvertisedRelayListEvent -> consume(event)
|
||||
is AppDefinitionEvent -> consume(event)
|
||||
is AppRecommendationEvent -> consume(event)
|
||||
is AdvertisedRelayListEvent -> consume(event, relay)
|
||||
is AppDefinitionEvent -> consume(event, relay)
|
||||
is AppRecommendationEvent -> consume(event, relay)
|
||||
is AudioHeaderEvent -> consume(event, relay)
|
||||
is AudioTrackEvent -> consume(event)
|
||||
is AudioTrackEvent -> consume(event, relay)
|
||||
is BadgeAwardEvent -> consume(event)
|
||||
is BadgeDefinitionEvent -> consume(event)
|
||||
is BadgeDefinitionEvent -> consume(event, relay)
|
||||
is BadgeProfilesEvent -> consume(event)
|
||||
is BookmarkListEvent -> consume(event)
|
||||
is CalendarEvent -> consume(event)
|
||||
is CalendarDateSlotEvent -> consume(event)
|
||||
is CalendarTimeSlotEvent -> consume(event)
|
||||
is CalendarRSVPEvent -> consume(event)
|
||||
is CalendarEvent -> consume(event, relay)
|
||||
is CalendarDateSlotEvent -> consume(event, relay)
|
||||
is CalendarTimeSlotEvent -> consume(event, relay)
|
||||
is CalendarRSVPEvent -> consume(event, relay)
|
||||
is ChannelCreateEvent -> consume(event)
|
||||
is ChannelHideMessageEvent -> consume(event)
|
||||
is ChannelMessageEvent -> consume(event, relay)
|
||||
is ChannelMetadataEvent -> consume(event)
|
||||
is ChannelMuteUserEvent -> consume(event)
|
||||
is ChatMessageEvent -> consume(event, relay)
|
||||
is ClassifiedsEvent -> consume(event)
|
||||
is ClassifiedsEvent -> consume(event, relay)
|
||||
is CommunityDefinitionEvent -> consume(event, relay)
|
||||
is CommunityPostApprovalEvent -> {
|
||||
event.containedPost()?.let {
|
||||
@@ -1540,11 +1545,12 @@ object LocalCache {
|
||||
}
|
||||
is ContactListEvent -> consume(event)
|
||||
is DeletionEvent -> consume(event)
|
||||
is EmojiPackEvent -> consume(event)
|
||||
is EmojiPackSelectionEvent -> consume(event)
|
||||
is EmojiPackEvent -> consume(event, relay)
|
||||
is EmojiPackSelectionEvent -> consume(event, relay)
|
||||
is SealedGossipEvent -> consume(event, relay)
|
||||
|
||||
is FileHeaderEvent -> consume(event, relay)
|
||||
is FileServersEvent -> consume(event, relay)
|
||||
is FileStorageEvent -> consume(event, relay)
|
||||
is FileStorageHeaderEvent -> consume(event, relay)
|
||||
is GiftWrapEvent -> consume(event, relay)
|
||||
@@ -1563,15 +1569,15 @@ object LocalCache {
|
||||
is LnZapPaymentResponseEvent -> consume(event)
|
||||
is LongTextNoteEvent -> consume(event, relay)
|
||||
is MetadataEvent -> consume(event)
|
||||
is MuteListEvent -> consume(event)
|
||||
is NNSEvent -> comsume(event)
|
||||
is MuteListEvent -> consume(event, relay)
|
||||
is NNSEvent -> comsume(event, relay)
|
||||
is PrivateDmEvent -> consume(event, relay)
|
||||
is PinListEvent -> consume(event)
|
||||
is PeopleListEvent -> consume(event)
|
||||
is PinListEvent -> consume(event, relay)
|
||||
is PeopleListEvent -> consume(event, relay)
|
||||
is PollNoteEvent -> consume(event, relay)
|
||||
is ReactionEvent -> consume(event)
|
||||
is RecommendRelayEvent -> consume(event)
|
||||
is RelaySetEvent -> consume(event)
|
||||
is RelaySetEvent -> consume(event, relay)
|
||||
is ReportEvent -> consume(event, relay)
|
||||
is RepostEvent -> {
|
||||
event.containedPost()?.let {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
@@ -41,7 +42,6 @@ import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.WrappedEvent
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.math.BigDecimal
|
||||
@@ -93,7 +93,7 @@ open class Note(val idHex: String) {
|
||||
var zapPayments = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relays = listOf<String>()
|
||||
var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
|
||||
private set
|
||||
|
||||
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
|
||||
@@ -110,13 +110,13 @@ open class Note(val idHex: String) {
|
||||
host.id,
|
||||
host.pubKey,
|
||||
host.kind(),
|
||||
relays.firstOrNull()
|
||||
relays.firstOrNull()?.url
|
||||
)
|
||||
} else {
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
|
||||
}
|
||||
} else {
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ open class Note(val idHex: String) {
|
||||
zaps = mapOf<Note, Note?>()
|
||||
zapPayments = mapOf<Note, Note?>()
|
||||
zapsAmount = BigDecimal.ZERO
|
||||
relays = listOf<String>()
|
||||
relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
|
||||
lastReactionsDownloadTime = emptyMap()
|
||||
|
||||
liveSet?.innerReplies?.invalidateData()
|
||||
@@ -287,7 +287,7 @@ open class Note(val idHex: String) {
|
||||
val tags = note.event?.tags() ?: emptyArray()
|
||||
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
||||
|
||||
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
|
||||
if (reactions[reaction]?.contains(note) == true) {
|
||||
reactions[reaction]?.let {
|
||||
if (note in it) {
|
||||
val newList = it.minus(note)
|
||||
@@ -306,7 +306,7 @@ open class Note(val idHex: String) {
|
||||
fun removeReport(deleteNote: Note) {
|
||||
val author = deleteNote.author ?: return
|
||||
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
|
||||
if (reports[author]?.contains(deleteNote) == true) {
|
||||
reports[author]?.let {
|
||||
reports = reports + Pair(author, it.minus(deleteNote))
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
@@ -327,12 +327,11 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun removeZapPayment(note: Note) {
|
||||
if (zapPayments[note] != null) {
|
||||
if (zapPayments.containsKey(note)) {
|
||||
zapPayments = zapPayments.minus(note)
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
} else if (zapPayments.containsValue(note)) {
|
||||
val toRemove = zapPayments.filterValues { it == note }
|
||||
zapPayments = zapPayments.minus(toRemove.keys)
|
||||
zapPayments = zapPayments.filterValues { it != note }
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -346,10 +345,7 @@ open class Note(val idHex: String) {
|
||||
|
||||
@Synchronized
|
||||
private fun innerAddZap(zapRequest: Note, zap: Note?): Boolean {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
return true
|
||||
} else if (zaps[zapRequest] == null) {
|
||||
if (zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
return true
|
||||
}
|
||||
@@ -359,13 +355,8 @@ open class Note(val idHex: String) {
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
checkNotInMainThread()
|
||||
if (zapRequest !in zaps.keys) {
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
updateZapTotal()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
} else if (zaps[zapRequest] == null) {
|
||||
|
||||
if (zaps[zapRequest] == null) {
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
updateZapTotal()
|
||||
@@ -376,10 +367,7 @@ open class Note(val idHex: String) {
|
||||
|
||||
@Synchronized
|
||||
private fun innerAddZapPayment(zapPaymentRequest: Note, zapPayment: Note?): Boolean {
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
return true
|
||||
} else if (zapPayments[zapPaymentRequest] == null) {
|
||||
if (zapPayments[zapPaymentRequest] == null) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
return true
|
||||
}
|
||||
@@ -389,12 +377,7 @@ open class Note(val idHex: String) {
|
||||
|
||||
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
|
||||
checkNotInMainThread()
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
|
||||
if (inserted) {
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
} else if (zapPayments[zapPaymentRequest] == null) {
|
||||
if (zapPayments[zapPaymentRequest] == null) {
|
||||
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
|
||||
if (inserted) {
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
@@ -406,11 +389,12 @@ open class Note(val idHex: String) {
|
||||
val tags = note.event?.tags() ?: emptyArray()
|
||||
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
||||
|
||||
if (reaction !in reactions.keys) {
|
||||
val listOfAuthors = reactions[reaction]
|
||||
if (listOfAuthors == null) {
|
||||
reactions = reactions + Pair(reaction, listOf(note))
|
||||
liveSet?.innerReactions?.invalidateData()
|
||||
} else if (reactions[reaction]?.contains(note) == false) {
|
||||
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
|
||||
} else if (!listOfAuthors.contains(note)) {
|
||||
reactions = reactions + Pair(reaction, listOfAuthors + note)
|
||||
liveSet?.innerReactions?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -418,25 +402,27 @@ open class Note(val idHex: String) {
|
||||
fun addReport(note: Note) {
|
||||
val author = note.author ?: return
|
||||
|
||||
if (author !in reports.keys) {
|
||||
val reportsByAuthor = reports[author]
|
||||
|
||||
if (reportsByAuthor == null) {
|
||||
reports = reports + Pair(author, listOf(note))
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
} else if (reports[author]?.contains(note) == false) {
|
||||
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
|
||||
} else if (!reportsByAuthor.contains(note)) {
|
||||
reports = reports + Pair(author, reportsByAuthor + note)
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addRelaySync(url: String) {
|
||||
if (url !in relays) {
|
||||
relays = relays + url
|
||||
fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) {
|
||||
if (briefInfo !in relays) {
|
||||
relays = relays + briefInfo
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelay(relay: Relay) {
|
||||
if (relay.url !in relays) {
|
||||
addRelaySync(relay.url)
|
||||
if (relay.brief !in relays) {
|
||||
addRelaySync(relay.brief)
|
||||
liveSet?.innerRelays?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -523,17 +509,17 @@ open class Note(val idHex: String) {
|
||||
return reports[user]?.isNotEmpty() ?: false
|
||||
}
|
||||
|
||||
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
|
||||
return reports.keys.filter { it.pubkeyHex in users }
|
||||
}
|
||||
|
||||
fun countReportAuthorsBy(users: Set<HexKey>): Int {
|
||||
return reports.keys.count { it.pubkeyHex in users }
|
||||
return reports.count { it.key.pubkeyHex in users }
|
||||
}
|
||||
|
||||
fun reportsBy(users: Set<HexKey>): List<Note> {
|
||||
return reportAuthorsBy(users).mapNotNull {
|
||||
reports[it]
|
||||
return reports.mapNotNull {
|
||||
if (it.key.pubkeyHex in users) {
|
||||
it.value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
@@ -541,8 +527,8 @@ open class Note(val idHex: String) {
|
||||
var sumOfAmounts = BigDecimal.ZERO
|
||||
|
||||
// Regular Zap Receipts
|
||||
zaps.values.forEach {
|
||||
val noteEvent = it?.event
|
||||
zaps.forEach {
|
||||
val noteEvent = it?.value?.event
|
||||
if (noteEvent is LnZapEvent) {
|
||||
sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO
|
||||
}
|
||||
@@ -647,8 +633,8 @@ open class Note(val idHex: String) {
|
||||
val dayAgo = TimeUtils.oneDayAgo()
|
||||
return reports.isNotEmpty() ||
|
||||
(
|
||||
author?.reports?.values?.any {
|
||||
it.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
|
||||
author?.reports?.any {
|
||||
it.value.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
|
||||
} ?: false
|
||||
)
|
||||
}
|
||||
@@ -863,21 +849,17 @@ class NoteLiveSet(u: Note) {
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val reactionCount = innerReactions.map {
|
||||
it.note.reactions.values.sumOf { it.size }
|
||||
var total = 0
|
||||
it.note.reactions.forEach { total += it.value.size }
|
||||
total
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val boostCount = innerBoosts.map {
|
||||
it.note.boosts.size
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val boostList = innerBoosts.map {
|
||||
it.note.boosts.toImmutableList()
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val relayInfo = innerRelays.map {
|
||||
it.note.relays.map {
|
||||
RelayBriefInfo(it)
|
||||
}.toImmutableList()
|
||||
it.note.relays
|
||||
}
|
||||
|
||||
val content = innerMetadata.map {
|
||||
@@ -897,8 +879,7 @@ class NoteLiveSet(u: Note) {
|
||||
hasReactions.hasObservers() ||
|
||||
replyCount.hasObservers() ||
|
||||
reactionCount.hasObservers() ||
|
||||
boostCount.hasObservers() ||
|
||||
boostList.hasObservers()
|
||||
boostCount.hasObservers()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
@@ -986,9 +967,22 @@ class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveDat
|
||||
@Immutable
|
||||
class NoteState(val note: Note)
|
||||
|
||||
@Immutable
|
||||
data class RelayBriefInfo(
|
||||
val url: String,
|
||||
val displayUrl: String = url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
|
||||
val favIcon: String = "https://$displayUrl/favicon.ico".intern()
|
||||
)
|
||||
object RelayBriefInfoCache {
|
||||
val cache = LruCache<String, RelayBriefInfo?>(50)
|
||||
|
||||
@Immutable
|
||||
data class RelayBriefInfo(
|
||||
val url: String,
|
||||
val displayUrl: String = url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
|
||||
val favIcon: String = "https://$displayUrl/favicon.ico".intern()
|
||||
)
|
||||
|
||||
fun get(url: String): RelayBriefInfo {
|
||||
val info = cache[url]
|
||||
if (info != null) return info
|
||||
|
||||
val newInfo = RelayBriefInfo(url)
|
||||
cache.put(url, newInfo)
|
||||
return newInfo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,5 @@ data class RelaySetupInfo(
|
||||
val feedTypes: Set<FeedType>,
|
||||
val paidRelay: Boolean = false
|
||||
) {
|
||||
val briefInfo: RelayBriefInfo = RelayBriefInfo(url)
|
||||
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
enum class ServersAvailable {
|
||||
// IMGUR,
|
||||
NOSTR_BUILD,
|
||||
NOSTRIMG,
|
||||
NOSTRFILES_DEV,
|
||||
NOSTRCHECK_ME,
|
||||
|
||||
// IMGUR_NIP_94,
|
||||
NOSTRIMG_NIP_94,
|
||||
NOSTR_BUILD_NIP_94,
|
||||
NOSTRFILES_DEV_NIP_94,
|
||||
NOSTRCHECK_ME_NIP_94,
|
||||
NIP95
|
||||
}
|
||||
@@ -34,7 +34,7 @@ object UrlCachedPreviewer {
|
||||
}
|
||||
}
|
||||
|
||||
val state = if (urlInfo.allFetchComplete() && urlInfo.url == url) {
|
||||
val state = if (urlInfo.fetchComplete() && urlInfo.url == url) {
|
||||
UrlPreviewState.Loaded(urlInfo)
|
||||
} else {
|
||||
UrlPreviewState.Empty
|
||||
|
||||
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.events.UserMetadata
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Stable
|
||||
@@ -123,16 +124,18 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
liveSet?.innerRelays?.invalidateData()
|
||||
flowSet?.relays?.invalidateData()
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
val author = note.author ?: return
|
||||
|
||||
if (author !in reports.keys) {
|
||||
val reportsBy = reports[author]
|
||||
if (reportsBy == null) {
|
||||
reports = reports + Pair(author, setOf(note))
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
} else if (reports[author]?.contains(note) == false) {
|
||||
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
|
||||
} else if (!reportsBy.contains(note)) {
|
||||
reports = reports + Pair(author, reportsBy + note)
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -140,7 +143,7 @@ class User(val pubkeyHex: String) {
|
||||
fun removeReport(deleteNote: Note) {
|
||||
val author = deleteNote.author ?: return
|
||||
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
|
||||
if (reports[author]?.contains(deleteNote) == true) {
|
||||
reports[author]?.let {
|
||||
reports = reports + Pair(author, it.minus(deleteNote))
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
@@ -149,48 +152,49 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
|
||||
if (zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZap(zapRequestOrZapEvent: Note) {
|
||||
if (zapRequestOrZapEvent in zaps.keys) {
|
||||
if (zaps.containsKey(zapRequestOrZapEvent)) {
|
||||
zaps = zaps.minus(zapRequestOrZapEvent)
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
} else if (zapRequestOrZapEvent in zaps.values) {
|
||||
} else if (zaps.containsValue(zapRequestOrZapEvent)) {
|
||||
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun zappedAmount(): BigDecimal {
|
||||
return zaps.mapNotNull { it.value?.event }
|
||||
.filterIsInstance<LnZapEvent>()
|
||||
.mapNotNull {
|
||||
it.amount
|
||||
}.sumOf { it }
|
||||
var amount = BigDecimal.ZERO
|
||||
zaps.forEach {
|
||||
val itemValue = (it.value?.event as? LnZapEvent)?.amount
|
||||
if (itemValue != null) {
|
||||
amount += itemValue
|
||||
}
|
||||
}
|
||||
|
||||
return amount
|
||||
}
|
||||
|
||||
fun reportsBy(user: User): Set<Note> {
|
||||
return reports[user] ?: emptySet()
|
||||
}
|
||||
|
||||
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
|
||||
return reports.keys.filter { it.pubkeyHex in users }
|
||||
}
|
||||
|
||||
fun countReportAuthorsBy(users: Set<HexKey>): Int {
|
||||
return reports.keys.count { it.pubkeyHex in users }
|
||||
return reports.count { it.key.pubkeyHex in users }
|
||||
}
|
||||
|
||||
fun reportsBy(users: Set<HexKey>): List<Note> {
|
||||
return reportAuthorsBy(users).mapNotNull {
|
||||
reports[it]
|
||||
return reports.mapNotNull {
|
||||
if (it.key.pubkeyHex in users) {
|
||||
it.value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
@@ -312,7 +316,7 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
suspend fun transientFollowerCount(): Int {
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun cachedFollowingKeySet(): Set<HexKey> {
|
||||
@@ -336,7 +340,7 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
suspend fun cachedFollowerCount(): Int {
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
|
||||
@@ -356,6 +360,7 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
var liveSet: UserLiveSet? = null
|
||||
var flowSet: UserFlowSet? = null
|
||||
|
||||
fun live(): UserLiveSet {
|
||||
if (liveSet == null) {
|
||||
@@ -383,6 +388,47 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun createOrDestroyFlowSync(create: Boolean) {
|
||||
if (create) {
|
||||
if (flowSet == null) {
|
||||
flowSet = UserFlowSet(this)
|
||||
}
|
||||
} else {
|
||||
if (flowSet != null && flowSet?.isInUse() == false) {
|
||||
flowSet?.destroy()
|
||||
flowSet = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun flow(): UserFlowSet {
|
||||
if (flowSet == null) {
|
||||
createOrDestroyFlowSync(true)
|
||||
}
|
||||
return flowSet!!
|
||||
}
|
||||
|
||||
fun clearFlow() {
|
||||
if (flowSet != null && flowSet?.isInUse() == false) {
|
||||
createOrDestroyFlowSync(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class UserFlowSet(u: User) {
|
||||
// Observers line up here.
|
||||
val relays = UserBundledRefresherFlow(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return relays.stateFlow.subscriptionCount.value > 0
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
relays.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -491,6 +537,27 @@ class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserSta
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class UserBundledRefresherFlow(val user: User) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
val stateFlow = MutableStateFlow(UserState(user))
|
||||
|
||||
fun destroy() {
|
||||
bundler.cancel()
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
|
||||
stateFlow.emit(UserState(user))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
|
||||
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison
|
||||
import com.vitorpamplona.amethyst.ui.components.tagIndex
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtensions
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
@@ -61,6 +62,31 @@ val noProtocolUrlValidator = try {
|
||||
val HTTPRegex = "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
class RichTextParser() {
|
||||
fun parseMediaUrl(fullUrl: String): ZoomableUrlContent? {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
val frags = Nip44UrlParser().parse(fullUrl)
|
||||
ZoomableUrlImage(
|
||||
url = fullUrl,
|
||||
description = frags["alt"],
|
||||
hash = frags["x"],
|
||||
blurhash = frags["blurhash"],
|
||||
dim = frags["dim"]
|
||||
)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
val frags = Nip44UrlParser().parse(fullUrl)
|
||||
ZoomableUrlVideo(
|
||||
url = fullUrl,
|
||||
description = frags["alt"],
|
||||
hash = frags["x"],
|
||||
blurhash = frags["blurhash"],
|
||||
dim = frags["dim"]
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun parseText(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>
|
||||
@@ -85,14 +111,7 @@ class RichTextParser() {
|
||||
}
|
||||
|
||||
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableUrlImage(fullUrl)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableUrlVideo(fullUrl)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
parseMediaUrl(fullUrl)
|
||||
}.associateBy { it.url }
|
||||
val imageList = imagesForPager.values.toList()
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ data class CashuToken(
|
||||
val token: String,
|
||||
val mint: String,
|
||||
val totalAmount: Long,
|
||||
val fees: Int,
|
||||
val redeemInvoiceAmount: Long,
|
||||
val proofs: JsonNode
|
||||
)
|
||||
|
||||
@@ -38,10 +36,8 @@ class CashuProcessor {
|
||||
for (proof in proofs) {
|
||||
totalAmount += proof.get("amount").asLong()
|
||||
}
|
||||
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
|
||||
val redeemInvoiceAmount = totalAmount - fees
|
||||
|
||||
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, fees, redeemInvoiceAmount, proofs))
|
||||
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
|
||||
} catch (e: Exception) {
|
||||
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
|
||||
}
|
||||
@@ -53,10 +49,29 @@ class CashuProcessor {
|
||||
runCatching {
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lnaddress = lud16,
|
||||
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
|
||||
message = "Redeem Cashu",
|
||||
onSuccess = { invoice ->
|
||||
meltInvoice(token, invoice, onSuccess, onError, context)
|
||||
milliSats = token.totalAmount * 1000, // Make invoice and leave room for fees
|
||||
message = "Calculate Fees for Cashu",
|
||||
onSuccess = { baseInvoice ->
|
||||
feeCalculator(
|
||||
token.mint,
|
||||
baseInvoice,
|
||||
onSuccess = { fees ->
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lnaddress = lud16,
|
||||
milliSats = (token.totalAmount - fees) * 1000, // Make invoice and leave room for fees
|
||||
message = "Redeem Cashu",
|
||||
onSuccess = { invoice ->
|
||||
meltInvoice(token, invoice, fees, onSuccess, onError, context)
|
||||
},
|
||||
onProgress = {
|
||||
},
|
||||
onError = onError,
|
||||
context = context
|
||||
)
|
||||
},
|
||||
onError = onError,
|
||||
context
|
||||
)
|
||||
},
|
||||
onProgress = {
|
||||
},
|
||||
@@ -66,7 +81,59 @@ class CashuProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) {
|
||||
fun feeCalculator(
|
||||
mintAddress: String,
|
||||
invoice: String,
|
||||
onSuccess: (Int) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
|
||||
|
||||
val factory = Event.mapper.nodeFactory
|
||||
|
||||
val jsonObject = factory.objectNode()
|
||||
jsonObject.put("pr", invoice)
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = jsonObject.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
val body = it.body.string()
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
|
||||
val feeCost = tree?.get("fee")?.asInt()
|
||||
|
||||
if (feeCost != null) {
|
||||
onSuccess(
|
||||
feeCost
|
||||
)
|
||||
} else {
|
||||
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
|
||||
onError(
|
||||
context.getString(R.string.cashu_failed_redemption),
|
||||
if (msg != null) {
|
||||
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg)
|
||||
} else {
|
||||
context.getString(R.string.cashu_failed_redemption_explainer_error_msg)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError(context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
|
||||
}
|
||||
}
|
||||
|
||||
private fun meltInvoice(token: CashuToken, invoice: String, fees: Int, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) {
|
||||
try {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val url = token.mint + "/melt" // Melt cashu tokens at Mint
|
||||
@@ -92,8 +159,8 @@ class CashuProcessor {
|
||||
|
||||
if (successful) {
|
||||
onSuccess(
|
||||
context.getString(R.string.cashu_sucessful_redemption),
|
||||
context.getString(R.string.cashu_sucessful_redemption_explainer, token.totalAmount.toString(), token.fees.toString())
|
||||
context.getString(R.string.cashu_successful_redemption),
|
||||
context.getString(R.string.cashu_successful_redemption_explainer, token.totalAmount.toString(), fees.toString())
|
||||
)
|
||||
} else {
|
||||
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
|
||||
@@ -108,7 +175,7 @@ class CashuProcessor {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError(context.getString(R.string.cashu_sucessful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
|
||||
onError(context.getString(R.string.cashu_successful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,47 +2,54 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.media.MediaDataSource
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMetadataRetriever.BitmapParams
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import java.io.IOException
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FileHeader(
|
||||
val url: String,
|
||||
val mimeType: String?,
|
||||
val hash: String,
|
||||
val size: Int,
|
||||
val dim: String?,
|
||||
val blurHash: String?,
|
||||
val alt: String? = null,
|
||||
val sensitiveContent: Boolean = false
|
||||
val blurHash: String?
|
||||
) {
|
||||
companion object {
|
||||
suspend fun prepare(fileUrl: String, mimeType: String?, alt: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
suspend fun prepare(
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
dimPrecomputed: String?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: (String?) -> Unit
|
||||
) {
|
||||
try {
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
|
||||
|
||||
if (imageData != null) {
|
||||
prepare(imageData, fileUrl, mimeType, alt, sensitiveContent, onReady, onError)
|
||||
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
|
||||
} else {
|
||||
onError()
|
||||
onError(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
onError()
|
||||
onError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare(
|
||||
data: ByteArray,
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
dimPrecomputed: String?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: () -> Unit
|
||||
onError: (String?) -> Unit
|
||||
) {
|
||||
try {
|
||||
val hash = CryptoUtils.sha256(data).toHexKey()
|
||||
@@ -75,15 +82,125 @@ class FileHeader(
|
||||
} else {
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
|
||||
}
|
||||
} else if (mimeType?.startsWith("video/") == true) {
|
||||
val mediaMetadataRetriever = MediaMetadataRetriever()
|
||||
mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data))
|
||||
|
||||
val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed
|
||||
|
||||
val blurhash = mediaMetadataRetriever.getThumbnail()?.let { thumbnail ->
|
||||
val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat()
|
||||
|
||||
val intArray = IntArray(thumbnail.width * thumbnail.height)
|
||||
thumbnail.getPixels(
|
||||
intArray,
|
||||
0,
|
||||
thumbnail.width,
|
||||
0,
|
||||
0,
|
||||
thumbnail.width,
|
||||
thumbnail.height
|
||||
)
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 9, (9 * (1 / aspectRatio)).roundToInt())
|
||||
} else if (aspectRatio < 1) {
|
||||
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, (9 * aspectRatio).roundToInt(), 9)
|
||||
} else {
|
||||
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4)
|
||||
}
|
||||
}
|
||||
|
||||
if (newDim != "0x0") {
|
||||
Pair(blurhash, newDim)
|
||||
} else {
|
||||
Pair(blurhash, null)
|
||||
}
|
||||
} else {
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, alt, sensitiveContent))
|
||||
onReady(FileHeader(mimeType, hash, size, dim, blurHash))
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError()
|
||||
onError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaMetadataRetriever.getThumbnail(): Bitmap? {
|
||||
val raw: ByteArray? = getEmbeddedPicture()
|
||||
if (raw != null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw))
|
||||
}
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val params = BitmapParams()
|
||||
params.preferredConfig = Bitmap.Config.ARGB_8888
|
||||
|
||||
// Fall back to middle of video
|
||||
// Note: METADATA_KEY_DURATION unit is in ms, not us.
|
||||
val thumbnailTimeUs: Long = (extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0) * 1000 / 2
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
getFrameAtTime(thumbnailTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, params)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaMetadataRetriever.prepareDimFromVideo(): String? {
|
||||
val width = prepareVideoWidth() ?: return null
|
||||
val height = prepareVideoHeight() ?: return null
|
||||
|
||||
return "${width}x$height"
|
||||
}
|
||||
|
||||
fun MediaMetadataRetriever.prepareVideoWidth(): Int? {
|
||||
val widthData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
return if (widthData.isNullOrEmpty()) {
|
||||
null
|
||||
} else {
|
||||
widthData.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaMetadataRetriever.prepareVideoHeight(): Int? {
|
||||
val heightData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
return if (heightData.isNullOrEmpty()) {
|
||||
null
|
||||
} else {
|
||||
heightData.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
class ByteArrayMediaDataSource(var imageData: ByteArray) : MediaDataSource() {
|
||||
override fun getSize(): Long {
|
||||
return imageData.size.toLong()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
|
||||
if (position >= imageData.size) {
|
||||
return -1
|
||||
}
|
||||
val newSize = if (position + size > imageData.size) {
|
||||
size - ((position.toInt() + size) - imageData.size)
|
||||
} else {
|
||||
size
|
||||
}
|
||||
|
||||
imageData.copyInto(buffer, offset, position.toInt(), position.toInt() + newSize)
|
||||
|
||||
return newSize
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
@@ -14,6 +19,8 @@ object HttpClient {
|
||||
var proxyChangeListeners = ArrayList<() -> Unit>()
|
||||
var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
|
||||
|
||||
var defaultHttpClient: OkHttpClient? = null
|
||||
|
||||
// fires off every time value of the property changes
|
||||
private var internalProxy: Proxy? by Delegates.observable(null) { _, oldValue, newValue ->
|
||||
if (oldValue != newValue) {
|
||||
@@ -24,13 +31,17 @@ object HttpClient {
|
||||
}
|
||||
|
||||
fun start(proxy: Proxy?) {
|
||||
this.internalProxy = proxy
|
||||
if (internalProxy != proxy) {
|
||||
this.internalProxy = proxy
|
||||
this.defaultHttpClient = getHttpClient()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeTimeouts(timeout: Duration) {
|
||||
Log.d("HttpClient", "Changing timeout to: $timeout")
|
||||
if (this.defaultTimeout.seconds != timeout.seconds) {
|
||||
this.defaultTimeout = timeout
|
||||
this.defaultHttpClient = getHttpClient()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +53,36 @@ object HttpClient {
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
.addInterceptor(DefaultContentTypeInterceptor())
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
class DefaultContentTypeInterceptor : Interceptor {
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest: Request = chain.request()
|
||||
val requestWithUserAgent: Request = originalRequest
|
||||
.newBuilder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.build()
|
||||
return chain.proceed(requestWithUserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
fun getHttpClientForRelays(): OkHttpClient {
|
||||
if (this.defaultHttpClient == null) {
|
||||
this.defaultHttpClient = getHttpClient(defaultTimeout)
|
||||
}
|
||||
return defaultHttpClient!!
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
return getHttpClient(defaultTimeout)
|
||||
if (this.defaultHttpClient == null) {
|
||||
this.defaultHttpClient = getHttpClient(defaultTimeout)
|
||||
}
|
||||
return defaultHttpClient!!
|
||||
}
|
||||
|
||||
fun getProxy(): Proxy? {
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class Nip05Verifier() {
|
||||
class Nip05NostrAddressVerifier() {
|
||||
fun assembleUrl(nip05address: String): String? {
|
||||
val parts = nip05address.trim().split("@")
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@Immutable
|
||||
class NIP30Parser {
|
||||
class Nip30CustomEmoji {
|
||||
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
fun buildArray(input: String): List<String> {
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
|
||||
class Nip44UrlParser {
|
||||
fun parse(url: String): Map<String, String> {
|
||||
return try {
|
||||
fragments(URI(url))
|
||||
} catch (e: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fragments(uri: URI): Map<String, String> {
|
||||
if (uri.rawFragment == null) return emptyMap()
|
||||
return uri.rawFragment.split('&').associate { keyValuePair ->
|
||||
val parts = keyValuePair.split('=')
|
||||
val name = parts.firstOrNull() ?: ""
|
||||
val value = parts.getOrNull(1)?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
|
||||
Pair(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
|
||||
// Rename to the corect nip number when ready.
|
||||
object Nip47 {
|
||||
object Nip47WalletConnectParser {
|
||||
fun parse(uri: String): Nip47URI {
|
||||
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import okhttp3.Request
|
||||
|
||||
object Nip96MediaServers {
|
||||
val DEFAULT =
|
||||
listOf(
|
||||
ServerName("Nostr.Build", "https://nostr.build"),
|
||||
ServerName("NostrCheck.me", "https://nostrcheck.me"),
|
||||
ServerName("Nostrage", "https://nostrage.com"),
|
||||
ServerName("Sove", "https://sove.rent"),
|
||||
ServerName("Sovbit", "https://files.sovbit.host"),
|
||||
ServerName("Void.cat", "https://void.cat")
|
||||
)
|
||||
|
||||
data class ServerName(val name: String, val baseUrl: String)
|
||||
|
||||
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
|
||||
|
||||
suspend fun load(url: String): Nip96Retriever.ServerInfo {
|
||||
val cached = cache[url]
|
||||
if (cached != null) return cached
|
||||
|
||||
val fetched = Nip96Retriever().loadInfo(url)
|
||||
cache[url] = fetched
|
||||
return fetched
|
||||
}
|
||||
}
|
||||
|
||||
class Nip96Retriever {
|
||||
data class ServerInfo(
|
||||
@JsonProperty("api_url") val apiUrl: String,
|
||||
@JsonProperty("download_url") val downloadUrl: String? = null,
|
||||
@JsonProperty("delegated_to_url") val delegatedToUrl: String? = null,
|
||||
@JsonProperty("supported_nips") val supportedNips: ArrayList<Int> = arrayListOf(),
|
||||
@JsonProperty("tos_url") val tosUrl: String? = null,
|
||||
@JsonProperty("content_types") val contentTypes: ArrayList<MimeType> = arrayListOf(),
|
||||
@JsonProperty("plans") val plans: Map<PlanName, Plan> = mapOf()
|
||||
)
|
||||
|
||||
data class Plan(
|
||||
@JsonProperty("name") val name: String? = null,
|
||||
@JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null,
|
||||
@JsonProperty("url") val url: String? = null,
|
||||
@JsonProperty("max_byte_size") val maxByteSize: Long? = null,
|
||||
@JsonProperty("file_expiration") val fileExpiration: ArrayList<Int> = arrayListOf(),
|
||||
@JsonProperty("media_transformations") val mediaTransformations: Map<MimeType, Array<String>> = emptyMap()
|
||||
)
|
||||
|
||||
fun parse(body: String): ServerInfo {
|
||||
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
return mapper.readValue(body, ServerInfo::class.java)
|
||||
}
|
||||
|
||||
suspend fun loadInfo(
|
||||
baseUrl: String
|
||||
): ServerInfo {
|
||||
checkNotInMainThread()
|
||||
|
||||
val request: Request = Request
|
||||
.Builder()
|
||||
.header("Accept", "application/nostr+json")
|
||||
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
|
||||
.build()
|
||||
|
||||
HttpClient.getHttpClient()
|
||||
.newCall(request)
|
||||
.execute().use { response ->
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
val body = it.body.string()
|
||||
try {
|
||||
if (it.isSuccessful) {
|
||||
return parse(body)
|
||||
} else {
|
||||
throw RuntimeException("Resulting Message from $baseUrl is an error: ${response.code} ${response.message}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias PlanName = String
|
||||
typealias MimeType = String
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.net.toFile
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
|
||||
fun randomChars() = List(16) { charPool.random() }.joinToString("")
|
||||
|
||||
class Nip96Uploader(val account: Account?) {
|
||||
|
||||
suspend fun uploadImage(
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
size: Long?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: Nip96MediaServers.ServerName,
|
||||
contentResolver: ContentResolver,
|
||||
onProgress: (percentage: Float) -> Unit
|
||||
): PartialEvent {
|
||||
val serverInfo = Nip96Retriever().loadInfo(
|
||||
server.baseUrl
|
||||
)
|
||||
|
||||
return uploadImage(uri, contentType, size, alt, sensitiveContent, serverInfo, contentResolver, onProgress)
|
||||
}
|
||||
|
||||
suspend fun uploadImage(
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
size: Long?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
contentResolver: ContentResolver,
|
||||
onProgress: (percentage: Float) -> Unit
|
||||
): PartialEvent {
|
||||
checkNotInMainThread()
|
||||
|
||||
val myContentType = contentType ?: contentResolver.getType(uri)
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
val length = size ?: contentResolver.query(uri, null, null, null, null)?.use {
|
||||
it.moveToFirst()
|
||||
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
|
||||
it.getLong(sizeIndex)
|
||||
} ?: kotlin.runCatching {
|
||||
uri.toFile().length()
|
||||
}.getOrNull() ?: 0
|
||||
|
||||
checkNotNull(imageInputStream) {
|
||||
"Can't open the image input stream"
|
||||
}
|
||||
|
||||
return uploadImage(imageInputStream, length, myContentType, alt, sensitiveContent, server, onProgress)
|
||||
}
|
||||
|
||||
suspend fun uploadImage(
|
||||
inputStream: InputStream,
|
||||
length: Long,
|
||||
contentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
onProgress: (percentage: Float) -> Unit
|
||||
): PartialEvent {
|
||||
checkNotInMainThread()
|
||||
|
||||
val fileName = randomChars()
|
||||
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
val requestBody: RequestBody
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("expiration", "")
|
||||
.addFormDataPart("size", length.toString())
|
||||
.also { body ->
|
||||
alt?.let { body.addFormDataPart("alt", it) }
|
||||
sensitiveContent?.let { body.addFormDataPart("content-warning", it) }
|
||||
contentType?.let { body.addFormDataPart("content_type", it) }
|
||||
}
|
||||
.addFormDataPart(
|
||||
"file",
|
||||
"$fileName.$extension",
|
||||
|
||||
object : RequestBody() {
|
||||
override fun contentType() = contentType?.toMediaType()
|
||||
|
||||
override fun contentLength() = length
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
inputStream.source().use(sink::writeAll)
|
||||
}
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
nip98Header(server.apiUrl)?.let {
|
||||
requestBuilder.addHeader("Authorization", it)
|
||||
}
|
||||
|
||||
requestBuilder
|
||||
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(server.apiUrl)
|
||||
.post(requestBody)
|
||||
|
||||
val request = requestBuilder.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
response.body.use { body ->
|
||||
val str = body.string()
|
||||
val result = parseResults(str)
|
||||
|
||||
if (!result.processingUrl.isNullOrBlank()) {
|
||||
return waitProcessing(result, server, onProgress)
|
||||
} else if (result.status == "success" && result.nip94Event != null) {
|
||||
return result.nip94Event
|
||||
} else {
|
||||
throw RuntimeException("Failed to upload with message: ${result.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw RuntimeException("Error Uploading image: ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(
|
||||
hash: String,
|
||||
contentType: String?,
|
||||
server: Nip96Retriever.ServerInfo
|
||||
): Boolean {
|
||||
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
nip98Header(server.apiUrl)?.let {
|
||||
requestBuilder.addHeader("Authorization", it)
|
||||
}
|
||||
|
||||
println(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
|
||||
|
||||
val request = requestBuilder
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
|
||||
.delete()
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
response.body.use { body ->
|
||||
val str = body.string()
|
||||
val result = parseDeleteResults(str)
|
||||
return result.status == "success"
|
||||
}
|
||||
} else {
|
||||
throw RuntimeException("Error Uploading image: ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun waitProcessing(
|
||||
result: Nip96Result,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
onProgress: (percentage: Float) -> Unit
|
||||
): PartialEvent {
|
||||
val client = HttpClient.getHttpClient()
|
||||
var currentResult = result
|
||||
|
||||
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
|
||||
onProgress((currentResult.percentage ?: 100) / 100f)
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(result.processingUrl)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
it.body.use {
|
||||
currentResult = parseResults(it.string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delay(500)
|
||||
}
|
||||
onProgress((currentResult.percentage ?: 100) / 100f)
|
||||
|
||||
val nip94 = currentResult.nip94Event
|
||||
|
||||
if (nip94 != null) {
|
||||
return nip94
|
||||
} else {
|
||||
throw RuntimeException("Error waiting for processing. Final result is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun nip98Header(url: String): String? {
|
||||
return withTimeoutOrNull(5000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
nip98Header(url, "POST") { authorizationToken ->
|
||||
continuation.resume(authorizationToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun nip98Header(url: String, method: String, file: ByteArray? = null, onReady: (String?) -> Unit) {
|
||||
val myAccount = account
|
||||
|
||||
if (myAccount == null) {
|
||||
onReady(null)
|
||||
return
|
||||
}
|
||||
|
||||
myAccount.createHTTPAuthorization(url, method, file) {
|
||||
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
|
||||
onReady("Nostr $encodedNIP98Event")
|
||||
}
|
||||
}
|
||||
|
||||
data class DeleteResult(
|
||||
val status: String?,
|
||||
val message: String?
|
||||
)
|
||||
|
||||
private fun parseDeleteResults(body: String): DeleteResult {
|
||||
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
return mapper.readValue(body, DeleteResult::class.java)
|
||||
}
|
||||
|
||||
data class Nip96Result(
|
||||
val status: String? = null,
|
||||
val message: String? = null,
|
||||
@JsonProperty("processing_url")
|
||||
val processingUrl: String? = null,
|
||||
val percentage: Int? = null,
|
||||
@JsonProperty("nip94_event")
|
||||
val nip94Event: PartialEvent? = null
|
||||
)
|
||||
|
||||
class PartialEvent(
|
||||
val tags: Array<Array<String>>? = null,
|
||||
val content: String? = null
|
||||
)
|
||||
|
||||
private fun parseResults(body: String): Nip96Result {
|
||||
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
return mapper.readValue(body, Nip96Result::class.java)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||
@@ -38,6 +39,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
// TODO: Migrate this to a property of AccountVi
|
||||
object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
lateinit var account: Account
|
||||
var otherAccounts = listOf<HexKey>()
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
val hasLoadedTheBasics = mutableMapOf<User, Boolean>()
|
||||
@@ -75,6 +77,18 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createOtherAccountsBaseFilter(): TypedFilter? {
|
||||
if (otherAccounts.isEmpty()) return null
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind, ContactListEvent.kind, AdvertisedRelayListEvent.kind, MuteListEvent.kind, PeopleListEvent.kind),
|
||||
authors = otherAccounts.filter { it != account.userProfile().pubkeyHex },
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountAcceptedAwardsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
@@ -218,7 +232,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
override fun updateChannelFilters() {
|
||||
return if (hasLoadedTheBasics[account.userProfile()] != null) {
|
||||
// gets everthing about the user logged in
|
||||
accountChannel.typedFilters = listOf(
|
||||
accountChannel.typedFilters = listOfNotNull(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createAccountRelayListFilter(),
|
||||
@@ -227,7 +241,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter(),
|
||||
createAccountBookmarkListFilter(),
|
||||
createAccountLastPostsListFilter()
|
||||
createAccountLastPostsListFilter(),
|
||||
createOtherAccountsBaseFilter()
|
||||
).ifEmpty { null }
|
||||
} else {
|
||||
// just the basics.
|
||||
|
||||
@@ -44,9 +44,11 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
)
|
||||
)
|
||||
|
||||
fun createMyChannelsFilter(): TypedFilter {
|
||||
fun createMyChannelsFilter(): TypedFilter? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
if (followingEvents.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
@@ -60,6 +62,8 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter>? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
if (followingEvents.isEmpty()) return null
|
||||
|
||||
return followingEvents.map {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
@@ -75,6 +79,8 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
fun createLastMessageOfEachChannelFilter(): List<TypedFilter>? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
if (followingEvents.isEmpty()) return null
|
||||
|
||||
return followingEvents.map {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
@@ -93,7 +99,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val list = listOf(
|
||||
val list = listOfNotNull(
|
||||
createMessagesToMeFilter(),
|
||||
createMessagesFromMeFilter(),
|
||||
createMyChannelsFilter()
|
||||
|
||||
@@ -35,8 +35,8 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
private val clientListener = object : Client.Listener() {
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||
if (subscriptionId in subscriptions.keys) {
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) {
|
||||
if (subscriptions.containsKey(subscriptionId)) {
|
||||
val key = "$debugName $subscriptionId ${event.kind}"
|
||||
val keyValue = eventCounter.get(key)
|
||||
if (keyValue != null) {
|
||||
@@ -46,6 +46,9 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
consume(event, relay)
|
||||
if (afterEOSE) {
|
||||
markAsEOSE(subscriptionId, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,12 +64,8 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
// Relay.Type.EOSE -> "sent all events it had stored."
|
||||
// }}")
|
||||
|
||||
if (type == Relay.StateType.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
|
||||
// updates a per subscripton since date
|
||||
subscriptions[subscriptionId]?.updateEOSE(
|
||||
TimeUtils.oneMinuteAgo(), // in case people's clock is slighly off.
|
||||
relay.url
|
||||
)
|
||||
if (type == Relay.StateType.EOSE && subscriptionId != null && subscriptions.containsKey(subscriptionId)) {
|
||||
markAsEOSE(subscriptionId, relay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +177,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
subscriptions.values.forEach { updatedSubscription ->
|
||||
val updatedSubscriptionNewFilters = updatedSubscription.typedFilters
|
||||
|
||||
if (updatedSubscription.id in currentFilters.keys) {
|
||||
if (currentFilters.containsKey(updatedSubscription.id)) {
|
||||
if (updatedSubscriptionNewFilters == null) {
|
||||
// was active and is not active anymore, just close.
|
||||
Client.close(updatedSubscription.id)
|
||||
@@ -221,6 +220,13 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
|
||||
}
|
||||
|
||||
open fun markAsEOSE(subscriptionId: String, relay: Relay) {
|
||||
subscriptions[subscriptionId]?.updateEOSE(
|
||||
TimeUtils.oneMinuteAgo(), // in case people's clock is slighly off.
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
open fun auth(relay: Relay, challenge: String) = Unit
|
||||
open fun notify(relay: Relay, description: String) = Unit
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
@@ -42,6 +43,54 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
fun createMarketplaceFilter(): List<TypedFilter> {
|
||||
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
|
||||
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
|
||||
val geohashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
|
||||
|
||||
return listOfNotNull(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(ClassifiedsEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
),
|
||||
hashToLoad?.let {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ClassifiedsEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to it.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
},
|
||||
geohashToLoad?.let {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ClassifiedsEvent.kind),
|
||||
tags = mapOf(
|
||||
"g" to it.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun createLiveStreamFilter(): List<TypedFilter> {
|
||||
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
|
||||
|
||||
@@ -238,16 +287,19 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
discoveryFeedChannel.typedFilters = createLiveStreamFilter().plus(createPublicChatFilter()).plus(
|
||||
listOfNotNull(
|
||||
createLiveStreamTagsFilter(),
|
||||
createLiveStreamGeohashesFilter(),
|
||||
createCommunitiesFilter(),
|
||||
createPublicChatsTagsFilter(),
|
||||
createCommunitiesTagsFilter(),
|
||||
createCommunitiesGeohashesFilter(),
|
||||
createPublicChatsGeohashesFilter()
|
||||
)
|
||||
).ifEmpty { null }
|
||||
discoveryFeedChannel.typedFilters = createLiveStreamFilter()
|
||||
.plus(createPublicChatFilter())
|
||||
.plus(createMarketplaceFilter())
|
||||
.plus(
|
||||
listOfNotNull(
|
||||
createLiveStreamTagsFilter(),
|
||||
createLiveStreamGeohashesFilter(),
|
||||
createCommunitiesFilter(),
|
||||
createCommunitiesTagsFilter(),
|
||||
createCommunitiesGeohashesFilter(),
|
||||
createPublicChatsTagsFilter(),
|
||||
createPublicChatsGeohashesFilter()
|
||||
)
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
|
||||
override fun start() {
|
||||
job?.cancel()
|
||||
job = account.scope.launch(Dispatchers.IO) {
|
||||
job = scope.launch(Dispatchers.IO) {
|
||||
// creates cache on main
|
||||
withContext(Dispatchers.Main) {
|
||||
account.userProfile().live()
|
||||
|
||||
@@ -14,7 +14,6 @@ import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.round
|
||||
@@ -142,8 +141,17 @@ class ZapPaymentHandler(val account: Account) {
|
||||
} else {
|
||||
launch(Dispatchers.IO) {
|
||||
// Awaits for the event to come back to LocalCache.
|
||||
delay(5000)
|
||||
onProgress(1f)
|
||||
var count = 0
|
||||
while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) {
|
||||
count++
|
||||
Thread.sleep(5000)
|
||||
}
|
||||
if (invoicesToPayOnIntent.isNotEmpty()) {
|
||||
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
|
||||
onProgress(1f)
|
||||
} else {
|
||||
onProgress(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
||||
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
|
||||
|
||||
+10
-2
@@ -4,11 +4,14 @@ import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
|
||||
object PlaybackClientController {
|
||||
val cache = LruCache<Int, SessionToken>(1)
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun prepareController(
|
||||
controllerID: String,
|
||||
@@ -25,9 +28,14 @@ object PlaybackClientController {
|
||||
bundle.putString("uri", videoUri)
|
||||
bundle.putString("callbackUri", callbackUri)
|
||||
|
||||
val sessionTokenLocal = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||
var session = cache.get(context.hashCode())
|
||||
if (session == null) {
|
||||
session = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||
cache.put(context.hashCode(), session)
|
||||
}
|
||||
|
||||
val controllerFuture = MediaController
|
||||
.Builder(context, sessionTokenLocal)
|
||||
.Builder(context, session)
|
||||
.setConnectionHints(bundle)
|
||||
.buildAsync()
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
|
||||
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
|
||||
|
||||
suspend fun fetchUrlPreview(timeOut: Int = 30000) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
fetch(timeOut)
|
||||
@@ -17,26 +13,7 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
|
||||
}
|
||||
|
||||
private suspend fun fetch(timeOut: Int = 30000) {
|
||||
lateinit var urlInfoItem: UrlInfoItem
|
||||
if (checkIsImageUrl()) {
|
||||
urlInfoItem = UrlInfoItem(url = url, image = url)
|
||||
} else {
|
||||
val document = getDocument(url, timeOut)
|
||||
urlInfoItem = parseHtml(url, document)
|
||||
}
|
||||
callback?.onComplete(urlInfoItem)
|
||||
}
|
||||
|
||||
private fun checkIsImageUrl(): Boolean {
|
||||
val uri = Uri.parse(url)
|
||||
var isImage = false
|
||||
for (imageExtension in imageExtensionArray) {
|
||||
if (uri.path != null && uri.path!!.toLowerCase(Locale.getDefault()).endsWith(imageExtension)) {
|
||||
isImage = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return isImage
|
||||
callback?.onComplete(getDocument(url, timeOut))
|
||||
}
|
||||
|
||||
fun cleanUp() {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import okhttp3.MediaType
|
||||
import java.net.URL
|
||||
|
||||
@Immutable
|
||||
data class UrlInfoItem(
|
||||
class UrlInfoItem(
|
||||
val url: String = "",
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val image: String = ""
|
||||
val image: String = "",
|
||||
val mimeType: MediaType
|
||||
) {
|
||||
val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull()
|
||||
val imageUrlFullPath =
|
||||
@@ -18,6 +20,10 @@ data class UrlInfoItem(
|
||||
image
|
||||
}
|
||||
|
||||
fun fetchComplete(): Boolean {
|
||||
return url.isNotEmpty() && image.isNotEmpty()
|
||||
}
|
||||
|
||||
fun allFetchComplete(): Boolean {
|
||||
return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.service.previews
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
@@ -46,21 +48,31 @@ private val META_X_IMAGE = arrayOf(
|
||||
|
||||
private const val CONTENT = "content"
|
||||
|
||||
suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
|
||||
suspend fun getDocument(url: String, timeOut: Int = 30000): UrlInfoItem =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder().url(url).get().build()
|
||||
val html = HttpClient.getHttpClient().newCall(request).execute().use {
|
||||
HttpClient.getHttpClient().newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
it.body.string()
|
||||
val mimeType = it.headers.get("Content-Type")?.toMediaType()
|
||||
?: throw IllegalArgumentException("Website returned unknown mimetype: ${it.headers.get("Content-Type")}")
|
||||
|
||||
if (mimeType.type == "text" && mimeType.subtype == "html") {
|
||||
val document = Jsoup.parse(it.body.string())
|
||||
parseHtml(url, document, mimeType)
|
||||
} else if (mimeType.type == "image") {
|
||||
UrlInfoItem(url, image = url, mimeType = mimeType)
|
||||
} else if (mimeType.type == "video") {
|
||||
UrlInfoItem(url, image = url, mimeType = mimeType)
|
||||
} else {
|
||||
throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType")
|
||||
}
|
||||
} else {
|
||||
throw IllegalArgumentException("Website returned: " + it.code)
|
||||
}
|
||||
}
|
||||
|
||||
Jsoup.parse(html)
|
||||
}
|
||||
|
||||
suspend fun parseHtml(url: String, document: Document): UrlInfoItem =
|
||||
suspend fun parseHtml(url: String, document: Document, type: MediaType): UrlInfoItem =
|
||||
withContext(Dispatchers.IO) {
|
||||
val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
|
||||
|
||||
@@ -106,8 +118,8 @@ suspend fun parseHtml(url: String, document: Document): UrlInfoItem =
|
||||
}
|
||||
|
||||
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
|
||||
return@withContext UrlInfoItem(url, title, description, image)
|
||||
return@withContext UrlInfoItem(url, title, description, image, type)
|
||||
}
|
||||
}
|
||||
return@withContext UrlInfoItem(url, title, description, image)
|
||||
return@withContext UrlInfoItem(url, title, description, image, type)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
@@ -18,21 +18,47 @@ import java.util.UUID
|
||||
*/
|
||||
object Client : RelayPool.Listener {
|
||||
private var listeners = setOf<Listener>()
|
||||
private var relays = Constants.convertDefaultRelays()
|
||||
private var relays = emptyArray<Relay>()
|
||||
private var subscriptions = mapOf<String, List<TypedFilter>>()
|
||||
|
||||
@Synchronized
|
||||
fun connect(relays: Array<Relay>) {
|
||||
fun reconnect(relays: Array<Relay>?, onlyIfChanged: Boolean = false) {
|
||||
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays")
|
||||
checkNotInMainThread()
|
||||
|
||||
RelayPool.register(this)
|
||||
RelayPool.unloadRelays()
|
||||
RelayPool.loadRelays(relays.toList())
|
||||
this.relays = relays
|
||||
if (onlyIfChanged) {
|
||||
if (!isSameRelaySetConfig(relays)) {
|
||||
if (this.relays.isNotEmpty()) {
|
||||
RelayPool.disconnect()
|
||||
RelayPool.unregister(this)
|
||||
RelayPool.unloadRelays()
|
||||
}
|
||||
|
||||
if (relays != null) {
|
||||
RelayPool.register(this)
|
||||
RelayPool.loadRelays(relays.toList())
|
||||
RelayPool.requestAndWatch()
|
||||
this.relays = relays
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.relays.isNotEmpty()) {
|
||||
RelayPool.disconnect()
|
||||
RelayPool.unregister(this)
|
||||
RelayPool.unloadRelays()
|
||||
}
|
||||
|
||||
if (relays != null) {
|
||||
RelayPool.register(this)
|
||||
RelayPool.loadRelays(relays.toList())
|
||||
RelayPool.requestAndWatch()
|
||||
this.relays = relays
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>): Boolean {
|
||||
if (relays.size != newRelayConfig.size) return false
|
||||
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>?): Boolean {
|
||||
if (relays.size != newRelayConfig?.size) return false
|
||||
|
||||
relays.forEach { oldRelayInfo ->
|
||||
val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false
|
||||
@@ -99,7 +125,7 @@ object Client : RelayPool.Listener {
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet())
|
||||
RelayPool.addRelay(relay)
|
||||
|
||||
relay.connectAndRun {
|
||||
@@ -126,18 +152,12 @@ object Client : RelayPool.Listener {
|
||||
subscriptions = subscriptions.minus(subscriptionId)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
RelayPool.unregister(this)
|
||||
RelayPool.disconnect()
|
||||
RelayPool.unloadRelays()
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) {
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
|
||||
listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,8 +213,8 @@ object Client : RelayPool.Listener {
|
||||
listeners = listeners.minus(listener)
|
||||
}
|
||||
|
||||
fun allSubscriptions(): List<String> {
|
||||
return subscriptions.keys.toList()
|
||||
fun allSubscriptions(): Set<String> {
|
||||
return subscriptions.keys
|
||||
}
|
||||
|
||||
fun getSubscriptionFilters(subId: String): List<TypedFilter> {
|
||||
@@ -205,7 +225,7 @@ object Client : RelayPool.Listener {
|
||||
/**
|
||||
* A new message was received
|
||||
*/
|
||||
open fun onEvent(event: Event, subscriptionId: String, relay: Relay) = Unit
|
||||
open fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean) = Unit
|
||||
|
||||
/**
|
||||
* A new or repeat message was received
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
|
||||
object Constants {
|
||||
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
|
||||
@@ -11,7 +10,7 @@ object Constants {
|
||||
|
||||
fun convertDefaultRelays(): Array<Relay> {
|
||||
return defaultRelays.map {
|
||||
Relay(it.url, it.read, it.write, it.feedTypes, HttpClient.getProxy())
|
||||
Relay(it.url, it.read, it.write, it.feedTypes)
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
@@ -51,13 +50,13 @@ object Constants {
|
||||
|
||||
// Supporting NIP-50
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://filter.nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
)
|
||||
|
||||
val forcedRelayForSearch = arrayOf(
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://filter.nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
)
|
||||
val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url }
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
@@ -9,13 +11,11 @@ import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.events.bytesUsedInMemory
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import java.lang.StringBuilder
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
enum class FeedType {
|
||||
@@ -25,25 +25,19 @@ enum class FeedType {
|
||||
val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
|
||||
|
||||
class Relay(
|
||||
var url: String,
|
||||
var read: Boolean = true,
|
||||
var write: Boolean = true,
|
||||
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
|
||||
proxy: Proxy?
|
||||
val url: String,
|
||||
val read: Boolean = true,
|
||||
val write: Boolean = true,
|
||||
val activeTypes: Set<FeedType> = FeedType.values().toSet()
|
||||
) {
|
||||
val brief = RelayBriefInfoCache.get(url)
|
||||
|
||||
companion object {
|
||||
// waits 3 minutes to reconnect once things fail
|
||||
const val RECONNECTING_IN_SECONDS = 60 * 3
|
||||
}
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.readTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
|
||||
.connectTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
|
||||
.writeTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
private val httpClient = HttpClient.getHttpClientForRelays()
|
||||
|
||||
private var listeners = setOf<Listener>()
|
||||
private var socket: WebSocket? = null
|
||||
@@ -87,7 +81,7 @@ class Relay(
|
||||
private var connectingBlock = AtomicBoolean()
|
||||
|
||||
fun connectAndRun(onConnected: (Relay) -> Unit) {
|
||||
Log.d("Client", "Relay.connect $url")
|
||||
Log.d("Relay", "Relay.connect $url")
|
||||
// BRB is crashing OkHttp Deflater object :(
|
||||
if (url.contains("brb.io")) return
|
||||
|
||||
@@ -120,6 +114,7 @@ class Relay(
|
||||
inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
checkNotInMainThread()
|
||||
Log.d("Relay", "Connect onOpen $url $socket")
|
||||
|
||||
markConnectionAsReady(
|
||||
pingInMs = response.receivedResponseAtMillis - response.sentRequestAtMillis,
|
||||
@@ -150,6 +145,8 @@ class Relay(
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
checkNotInMainThread()
|
||||
|
||||
Log.w("Relay", "Relay onClosing $url: $reason")
|
||||
|
||||
listeners.forEach {
|
||||
it.onRelayStateChange(
|
||||
this@Relay,
|
||||
@@ -164,6 +161,8 @@ class Relay(
|
||||
|
||||
markConnectionAsClosed()
|
||||
|
||||
Log.w("Relay", "Relay onClosed $url: $reason")
|
||||
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.DISCONNECT, null) }
|
||||
}
|
||||
|
||||
@@ -172,7 +171,7 @@ class Relay(
|
||||
|
||||
errorCounter++
|
||||
|
||||
socket?.close(1000, "Normal close")
|
||||
socket?.cancel() // 1000, "Normal close"
|
||||
// Failures disconnect the relay.
|
||||
markConnectionAsClosed()
|
||||
|
||||
@@ -185,7 +184,7 @@ class Relay(
|
||||
}
|
||||
|
||||
fun markConnectionAsReady(pingInMs: Long, usingCompression: Boolean) {
|
||||
this.afterEOSEPerSubscription.clear()
|
||||
this.resetEOSEStatuses()
|
||||
this.isReady = true
|
||||
this.pingInMs = pingInMs
|
||||
this.usingCompression = usingCompression
|
||||
@@ -195,7 +194,7 @@ class Relay(
|
||||
this.socket = null
|
||||
this.isReady = false
|
||||
this.usingCompression = false
|
||||
this.afterEOSEPerSubscription.clear()
|
||||
this.resetEOSEStatuses()
|
||||
this.closingTimeInSeconds = TimeUtils.now()
|
||||
}
|
||||
|
||||
@@ -209,10 +208,7 @@ class Relay(
|
||||
|
||||
// Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}")
|
||||
listeners.forEach {
|
||||
it.onEvent(this@Relay, subscriptionId, event)
|
||||
if (afterEOSEPerSubscription[subscriptionId] == true) {
|
||||
it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId)
|
||||
}
|
||||
it.onEvent(this@Relay, subscriptionId, event, afterEOSEPerSubscription[subscriptionId] == true)
|
||||
}
|
||||
}
|
||||
"EOSE" -> listeners.forEach {
|
||||
@@ -267,7 +263,7 @@ class Relay(
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
Log.d("Client", "Relay.disconnect $url")
|
||||
Log.d("Relay", "Relay.disconnect $url")
|
||||
checkNotInMainThread()
|
||||
|
||||
closingTimeInSeconds = TimeUtils.now()
|
||||
@@ -275,7 +271,11 @@ class Relay(
|
||||
socket = null
|
||||
isReady = false
|
||||
usingCompression = false
|
||||
afterEOSEPerSubscription.clear()
|
||||
resetEOSEStatuses()
|
||||
}
|
||||
|
||||
fun resetEOSEStatuses() {
|
||||
afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size)
|
||||
}
|
||||
|
||||
fun sendFilter(requestId: String) {
|
||||
@@ -284,16 +284,22 @@ class Relay(
|
||||
if (read) {
|
||||
if (isConnected()) {
|
||||
if (isReady) {
|
||||
val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() }
|
||||
val filters = Client.getSubscriptionFilters(requestId).filter { filter ->
|
||||
activeTypes.any { it in filter.types }
|
||||
}
|
||||
if (filters.isNotEmpty()) {
|
||||
val request =
|
||||
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]"""
|
||||
val request = filters.joinToStringLimited(
|
||||
separator = ",",
|
||||
limit = 10,
|
||||
prefix = """["REQ","$requestId",""",
|
||||
postfix = "]"
|
||||
) { it.filter.toJson(url) }
|
||||
|
||||
// Log.d("Relay", "onFilterSent $url $requestId $request")
|
||||
|
||||
socket?.send(request)
|
||||
eventUploadCounterInBytes += request.bytesUsedInMemory()
|
||||
afterEOSEPerSubscription.clear()
|
||||
resetEOSEStatuses()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -306,6 +312,33 @@ class Relay(
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Iterable<T>.joinToStringLimited(
|
||||
separator: CharSequence = ", ",
|
||||
prefix: CharSequence = "",
|
||||
postfix: CharSequence = "",
|
||||
limit: Int = -1,
|
||||
transform: ((T) -> CharSequence)? = null
|
||||
): String {
|
||||
val buffer = StringBuilder()
|
||||
buffer.append(prefix)
|
||||
var count = 0
|
||||
for (element in this) {
|
||||
if (limit < 0 || count <= limit) {
|
||||
if (++count > 1) buffer.append(separator)
|
||||
when {
|
||||
transform != null -> buffer.append(transform(element))
|
||||
element is CharSequence? -> buffer.append(element)
|
||||
element is Char -> buffer.append(element)
|
||||
else -> buffer.append(element.toString())
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
buffer.append(postfix)
|
||||
return buffer.toString()
|
||||
}
|
||||
|
||||
fun sendFilterOnlyIfDisconnected() {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -364,7 +397,7 @@ class Relay(
|
||||
checkNotInMainThread()
|
||||
|
||||
val msg = """["CLOSE","$subscriptionId"]"""
|
||||
Log.d("Relay", "Close Subscription $url $msg")
|
||||
// Log.d("Relay", "Close Subscription $url $msg")
|
||||
socket?.send(msg)
|
||||
}
|
||||
|
||||
@@ -393,7 +426,7 @@ class Relay(
|
||||
/**
|
||||
* A new message was received
|
||||
*/
|
||||
fun onEvent(relay: Relay, subscriptionId: String, event: Event)
|
||||
fun onEvent(relay: Relay, subscriptionId: String, event: Event, afterEOSE: Boolean)
|
||||
|
||||
fun onError(relay: Relay, subscriptionId: String, error: Error)
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ object RelayPool : Relay.Listener {
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onEvent(event: Event, subscriptionId: String, relay: Relay)
|
||||
fun onEvent(event: Event, subscriptionId: String, relay: Relay, afterEOSE: Boolean)
|
||||
|
||||
fun onError(error: Error, subscriptionId: String, relay: Relay)
|
||||
|
||||
@@ -116,8 +116,8 @@ object RelayPool : Relay.Listener {
|
||||
fun onNotify(relay: Relay, description: String)
|
||||
}
|
||||
|
||||
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
|
||||
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
|
||||
override fun onEvent(relay: Relay, subscriptionId: String, event: Event, afterEOSE: Boolean) {
|
||||
listeners.forEach { it.onEvent(event, subscriptionId, relay, afterEOSE) }
|
||||
}
|
||||
|
||||
override fun onError(relay: Relay, subscriptionId: String, error: Error) {
|
||||
|
||||
@@ -32,7 +32,7 @@ import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
|
||||
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.debugState
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47WalletConnectParser
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
@@ -183,7 +183,7 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun onDestroy() {
|
||||
Log.d("Lifetime Event", "MainActivity.onDestroy")
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
keepPlayingMutex?.stop()
|
||||
keepPlayingMutex?.release()
|
||||
keepPlayingMutex = null
|
||||
@@ -261,9 +261,7 @@ class MainActivity : AppCompatActivity() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
Log.d("ServiceManager NetworkCallback", "onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}")
|
||||
if (updateNetworkCapabilities(networkCapabilities) && shouldPauseService) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
serviceManager.forceRestart()
|
||||
}
|
||||
serviceManager.forceRestart()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,7 +319,7 @@ fun uriToRoute(uri: String?): String? {
|
||||
}
|
||||
} ?: try {
|
||||
uri?.let {
|
||||
Nip47.parse(it)
|
||||
Nip47WalletConnectParser.parse(it)
|
||||
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
|
||||
Route.Home.base + "?nip47=" + encodedUri
|
||||
}
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.net.toFile
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.Response
|
||||
import okio.BufferedSink
|
||||
import okio.IOException
|
||||
import okio.source
|
||||
import java.io.InputStream
|
||||
import java.time.Duration
|
||||
import java.util.Base64
|
||||
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
|
||||
fun randomChars() = List(16) { charPool.random() }.joinToString("")
|
||||
class ImageUploader(val account: Account?) {
|
||||
fun uploadImage(
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
size: Long?,
|
||||
server: ServersAvailable,
|
||||
contentResolver: ContentResolver,
|
||||
onSuccess: (String, String?) -> Unit,
|
||||
onError: (Throwable) -> Unit
|
||||
) {
|
||||
val myContentType = contentType ?: contentResolver.getType(uri)
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
val length = size ?: contentResolver.query(uri, null, null, null, null)?.use {
|
||||
it.moveToFirst()
|
||||
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
|
||||
it.getLong(sizeIndex)
|
||||
} ?: kotlin.runCatching {
|
||||
uri.toFile().length()
|
||||
}.getOrNull() ?: 0
|
||||
|
||||
checkNotNull(imageInputStream) {
|
||||
"Can't open the image input stream"
|
||||
}
|
||||
val myServer = when (server) {
|
||||
// ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
|
||||
// ImgurServer()
|
||||
// }
|
||||
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
|
||||
NostrImgServer()
|
||||
}
|
||||
ServersAvailable.NOSTR_BUILD, ServersAvailable.NOSTR_BUILD_NIP_94 -> {
|
||||
NostrBuildServer()
|
||||
}
|
||||
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
|
||||
NostrFilesDevServer()
|
||||
}
|
||||
ServersAvailable.NOSTRCHECK_ME, ServersAvailable.NOSTRCHECK_ME_NIP_94 -> {
|
||||
NostrCheckMeServer()
|
||||
}
|
||||
else -> {
|
||||
NostrBuildServer()
|
||||
}
|
||||
}
|
||||
|
||||
uploadImage(imageInputStream, length, myContentType, myServer, onSuccess, onError)
|
||||
}
|
||||
|
||||
fun uploadImage(
|
||||
inputStream: InputStream,
|
||||
length: Long,
|
||||
contentType: String?,
|
||||
server: FileServer,
|
||||
onSuccess: (String, String?) -> Unit,
|
||||
onError: (Throwable) -> Unit
|
||||
) {
|
||||
val fileName = randomChars()
|
||||
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClient.getHttpClient(Duration.ofSeconds(30L))
|
||||
val requestBody: RequestBody
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
server.inputParameterName(contentType),
|
||||
"$fileName.$extension",
|
||||
|
||||
object : RequestBody() {
|
||||
override fun contentType() = contentType?.toMediaType()
|
||||
|
||||
override fun contentLength() = length
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
inputStream.source().use(sink::writeAll)
|
||||
}
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
server.authorizationToken(account, requestBody.toString()) { authorizationToken ->
|
||||
if (authorizationToken != null) {
|
||||
requestBuilder.addHeader("Authorization", authorizationToken)
|
||||
}
|
||||
|
||||
requestBuilder
|
||||
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(server.postUrl(contentType))
|
||||
.post(requestBody)
|
||||
val request = requestBuilder.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
try {
|
||||
check(response.isSuccessful)
|
||||
response.body.use { body ->
|
||||
val url = server.parseUrlFromSuccess(body.string(), authorizationToken)
|
||||
checkNotNull(url) {
|
||||
"There must be an uploaded image URL in the response"
|
||||
}
|
||||
|
||||
onSuccess(url, contentType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun NIP98Header(url: String, method: String, body: String, onReady: (String?) -> Unit) {
|
||||
val myAccount = account
|
||||
|
||||
if (myAccount == null) {
|
||||
onReady(null)
|
||||
return
|
||||
}
|
||||
|
||||
myAccount.createHTTPAuthorization(url, method, body) {
|
||||
val noteJson = it.toJson()
|
||||
val encodedNIP98Event: String = Base64.getEncoder().encodeToString(noteJson.toByteArray())
|
||||
onReady("Nostr $encodedNIP98Event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class FileServer {
|
||||
abstract fun postUrl(contentType: String?): String
|
||||
abstract fun parseUrlFromSuccess(body: String, authorizationToken: String?): String?
|
||||
abstract fun inputParameterName(contentType: String?): String
|
||||
|
||||
open fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
|
||||
onReady(null)
|
||||
}
|
||||
}
|
||||
|
||||
class NostrImgServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
|
||||
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
}
|
||||
|
||||
class ImgurServer : FileServer() {
|
||||
override fun postUrl(contentType: String?): String {
|
||||
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
|
||||
}
|
||||
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
|
||||
override fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
|
||||
onReady("Client-ID e6aea87296f3f96")
|
||||
}
|
||||
}
|
||||
|
||||
class NostrBuildServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostr.build/api/v2/upload/files"
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val data = tree?.get("data")
|
||||
val data0 = data?.get(0)
|
||||
val url = data0?.get("url")
|
||||
return url.toString().replace("\"", "")
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "file"
|
||||
}
|
||||
|
||||
override fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
|
||||
ImageUploader(account).NIP98Header("https://nostr.build/api/v2/upload/files", "POST", info, onReady)
|
||||
}
|
||||
}
|
||||
|
||||
class NostrFilesDevServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrfiles.dev/upload_image"
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
return tree?.get("url")?.asText()
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
class NostrCheckMeServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrcheck.me/api/v1/media"
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
checkNotInMainThread()
|
||||
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("url")?.asText()
|
||||
val id = tree?.get("id")?.asText()
|
||||
var isCompleted = false
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
val requrl =
|
||||
"https://nostrcheck.me/api/v1/media?id=$id" // + "&apikey=26d075787d261660682fb9d20dbffa538c708b1eda921d0efa2be95fbef4910a"
|
||||
|
||||
val requestBuilder = Request.Builder().url(requrl)
|
||||
|
||||
if (authorizationToken != null) {
|
||||
requestBuilder.addHeader("Authorization", authorizationToken)
|
||||
}
|
||||
|
||||
val request = requestBuilder.get().build()
|
||||
|
||||
while (!isCompleted) {
|
||||
client.newCall(request).execute().use {
|
||||
val tree = jacksonObjectMapper().readTree(it.body.string())
|
||||
isCompleted = tree?.get("status")?.asText() == "completed"
|
||||
try {
|
||||
Thread.sleep(500)
|
||||
} catch (e: InterruptedException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "mediafile"
|
||||
}
|
||||
|
||||
override fun authorizationToken(account: Account?, body: String, onReady: (String?) -> Unit) {
|
||||
ImageUploader(account).NIP98Header("https://nostrcheck.me/api/v1/media", "POST", body, onReady)
|
||||
}
|
||||
}
|
||||
@@ -9,25 +9,29 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ServerOption(
|
||||
val server: Nip96MediaServers.ServerName,
|
||||
val isNip95: Boolean
|
||||
)
|
||||
|
||||
@Stable
|
||||
open class NewMediaModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
val imageUploadingError = MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
var mediaType by mutableStateOf<String?>(null)
|
||||
|
||||
var selectedServer by mutableStateOf<ServersAvailable?>(null)
|
||||
var selectedServer by mutableStateOf<ServerOption?>(null)
|
||||
var alt by mutableStateOf("")
|
||||
var sensitiveContent by mutableStateOf(false)
|
||||
|
||||
@@ -38,25 +42,14 @@ open class NewMediaModel : ViewModel() {
|
||||
var uploadingDescription = mutableStateOf<String?>(null)
|
||||
|
||||
var onceUploaded: () -> Unit = {}
|
||||
var onError: (String) -> Unit = {}
|
||||
|
||||
open fun load(account: Account, uri: Uri, contentType: String?) {
|
||||
open fun load(account: Account, uri: Uri, contentType: String?, onError: (String) -> Unit) {
|
||||
this.account = account
|
||||
this.galleryUri = uri
|
||||
this.mediaType = contentType
|
||||
this.selectedServer = defaultServer()
|
||||
|
||||
// if (selectedServer == ServersAvailable.IMGUR) {
|
||||
// selectedServer = ServersAvailable.IMGUR_NIP_94
|
||||
// } else
|
||||
if (selectedServer == ServersAvailable.NOSTRIMG) {
|
||||
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
|
||||
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTRFILES_DEV) {
|
||||
selectedServer = ServersAvailable.NOSTRFILES_DEV_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTRCHECK_ME) {
|
||||
selectedServer = ServersAvailable.NOSTRCHECK_ME_NIP_94
|
||||
}
|
||||
this.selectedServer = ServerOption(defaultServer(), false)
|
||||
this.onError = onError
|
||||
}
|
||||
|
||||
fun upload(context: Context, relayList: List<Relay>? = null) {
|
||||
@@ -76,16 +69,15 @@ open class NewMediaModel : ViewModel() {
|
||||
contentType,
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
|
||||
if (selectedServer == ServersAvailable.NIP95) {
|
||||
if (serverToUse.isNip95) {
|
||||
uploadingPercentage.value = 0.2f
|
||||
uploadingDescription.value = "Loading"
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList)
|
||||
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList, context)
|
||||
}
|
||||
?: run {
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
onError(context.getString(R.string.could_not_open_the_compressed_file))
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
@@ -95,30 +87,34 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingPercentage.value = 0.2f
|
||||
uploadingDescription.value = "Uploading"
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ImageUploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
createNIP94Record(
|
||||
imageUrl,
|
||||
mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
relayList = relayList
|
||||
)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
try {
|
||||
val result = Nip96Uploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = serverToUse.server,
|
||||
contentResolver = contentResolver,
|
||||
onProgress = { percent: Float ->
|
||||
uploadingPercentage.value = 0.2f + (0.2f * percent)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
relayList = relayList,
|
||||
context
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
onError(context.getString(R.string.failed_to_upload_media, e.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -126,9 +122,7 @@ open class NewMediaModel : ViewModel() {
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
onError(context.getString(R.string.error_when_compressing_media, it))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -142,85 +136,115 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingPercentage.value = 0.0f
|
||||
|
||||
alt = ""
|
||||
selectedServer = account?.defaultFileServer
|
||||
selectedServer = ServerOption(defaultServer(), false)
|
||||
}
|
||||
|
||||
fun canPost(): Boolean {
|
||||
return !isUploadingImage && galleryUri != null && selectedServer != null
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
|
||||
suspend fun createNIP94Record(
|
||||
uploadingResult: Nip96Uploader.PartialEvent,
|
||||
localContentType: String?,
|
||||
alt: String,
|
||||
sensitiveContent: Boolean,
|
||||
relayList: List<Relay>? = null,
|
||||
context: Context
|
||||
) {
|
||||
uploadingPercentage.value = 0.40f
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
uploadingDescription.value = "Server Processing"
|
||||
// Images don't seem to be ready immediately after upload
|
||||
uploadingDescription.value = "Server Processing"
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
|
||||
val imageUrl = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
val remoteMimeType = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null }
|
||||
val originalHash = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null }
|
||||
val dim = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
|
||||
val magnet = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "magnet" }?.get(1)?.ifBlank { null }
|
||||
|
||||
uploadingDescription.value = "Downloading"
|
||||
uploadingPercentage.value = 0.60f
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
onError(context.getString(R.string.server_did_not_provide_a_url_after_uploading))
|
||||
return
|
||||
}
|
||||
|
||||
if (imageData != null) {
|
||||
uploadingPercentage.value = 0.80f
|
||||
uploadingDescription.value = "Hashing"
|
||||
uploadingDescription.value = "Downloading"
|
||||
uploadingPercentage.value = 0.60f
|
||||
|
||||
FileHeader.prepare(
|
||||
imageData,
|
||||
imageUrl,
|
||||
mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
onReady = {
|
||||
uploadingPercentage.value = 0.90f
|
||||
uploadingDescription.value = "Sending"
|
||||
account?.sendHeader(it, relayList) {
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
|
||||
|
||||
if (imageData != null) {
|
||||
uploadingPercentage.value = 0.80f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
FileHeader.prepare(
|
||||
data = imageData,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
onReady = {
|
||||
uploadingPercentage.value = 0.90f
|
||||
uploadingDescription.value = "Sending"
|
||||
account?.sendHeader(imageUrl, magnet, it, alt, sensitiveContent, originalHash, relayList) {
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
onceUploaded()
|
||||
cancel()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
},
|
||||
onError = {
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
onError(context.getString(R.string.could_not_prepare_local_file_to_upload, it))
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
onError(context.getString(R.string.could_not_download_from_the_server))
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
|
||||
fun createNIP95Record(
|
||||
bytes: ByteArray,
|
||||
mimeType: String?,
|
||||
alt: String,
|
||||
sensitiveContent: Boolean,
|
||||
relayList: List<Relay>? = null,
|
||||
context: Context
|
||||
) {
|
||||
if (bytes.size > 80000) {
|
||||
viewModelScope.launch {
|
||||
onError("Media is too big for NIP-95")
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
uploadingPercentage.value = 0.30f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
"",
|
||||
mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
null,
|
||||
onReady = {
|
||||
uploadingDescription.value = "Signing"
|
||||
uploadingPercentage.value = 0.40f
|
||||
account?.createNip95(bytes, headerInfo = it) { nip95 ->
|
||||
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
|
||||
uploadingDescription.value = "Sending"
|
||||
uploadingPercentage.value = 0.60f
|
||||
account?.sendNip95(nip95.first, nip95.second, relayList)
|
||||
account?.consumeAndSendNip95(nip95.first, nip95.second, relayList)
|
||||
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
@@ -233,9 +257,7 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingPercentage.value = 0.00f
|
||||
isUploadingImage = false
|
||||
cancel()
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
onError(context.getString(R.string.could_not_prepare_local_file_to_upload, it))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -243,7 +265,7 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
fun isImage() = mediaType?.startsWith("image")
|
||||
fun isVideo() = mediaType?.startsWith("video")
|
||||
fun defaultServer() = account?.defaultFileServer
|
||||
fun defaultServer() = account?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0]
|
||||
fun onceUploaded(onceUploaded: () -> Unit) {
|
||||
this.onceUploaded = onceUploaded
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -46,7 +45,7 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
@@ -55,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
@@ -67,14 +65,8 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
|
||||
LaunchedEffect(uri) {
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
postViewModel.load(account, uri, mediaType)
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
postViewModel.load(account, uri, mediaType) {
|
||||
accountViewModel.toast(context.getString(R.string.failed_to_upload_media_no_details), it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +138,11 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
onPost = {
|
||||
onClose()
|
||||
postViewModel.upload(context, relayList)
|
||||
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
|
||||
postViewModel.selectedServer?.let {
|
||||
if (!it.isNip95) {
|
||||
account.changeDefaultFileServer(it.server)
|
||||
}
|
||||
}
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
@@ -170,26 +166,19 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
}
|
||||
}
|
||||
|
||||
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
|
||||
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
// selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTRCHECK_ME_NIP_94
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewModel) {
|
||||
val fileServers = listOf(
|
||||
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRCHECK_ME_NIP_94, stringResource(id = R.string.upload_server_nostrcheckme_nip94), stringResource(id = R.string.upload_server_nostrcheckme_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
val fileServers = Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + listOf(
|
||||
ServerOption(
|
||||
Nip96MediaServers.ServerName(
|
||||
"NIP95",
|
||||
stringResource(id = R.string.upload_server_relays_nip95)
|
||||
),
|
||||
true
|
||||
)
|
||||
)
|
||||
|
||||
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
|
||||
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() }
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
|
||||
Row(
|
||||
@@ -218,7 +207,6 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
postViewModel.imageUploadingError.emit("Unable to load thumbnail, but the video can be uploaded")
|
||||
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
|
||||
}
|
||||
}
|
||||
@@ -252,10 +240,12 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
|
||||
) {
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.file_server),
|
||||
placeholder = fileServers.firstOrNull { it.first == accountViewModel.account.defaultFileServer }?.second ?: fileServers[0].second,
|
||||
placeholder = fileServers.firstOrNull {
|
||||
it.server == accountViewModel.account.defaultFileServer
|
||||
}?.server?.name ?: fileServers[0].server.name,
|
||||
options = fileServerOptions,
|
||||
onSelect = {
|
||||
postViewModel.selectedServer = fileServers[it].first
|
||||
postViewModel.selectedServer = fileServers[it]
|
||||
},
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
@@ -263,44 +253,40 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
|
||||
)
|
||||
}
|
||||
|
||||
if (isNIP94Server(postViewModel.selectedServer) ||
|
||||
postViewModel.selectedServer == ServersAvailable.NIP95
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
SettingSwitchItem(
|
||||
checked = postViewModel.sensitiveContent,
|
||||
onCheckedChange = { postViewModel.sensitiveContent = it },
|
||||
title = R.string.add_sensitive_content_label,
|
||||
description = R.string.add_sensitive_content_description
|
||||
)
|
||||
}
|
||||
SettingSwitchItem(
|
||||
checked = postViewModel.sensitiveContent,
|
||||
onCheckedChange = { postViewModel.sensitiveContent = it },
|
||||
title = R.string.add_sensitive_content_label,
|
||||
description = R.string.add_sensitive_content_description
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = postViewModel.alt,
|
||||
onValueChange = { postViewModel.alt = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.content_description_example),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = postViewModel.alt,
|
||||
onValueChange = { postViewModel.alt = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.content_description_example),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.CurrencyBitcoin
|
||||
import androidx.compose.material.icons.filled.LocationOff
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.Sell
|
||||
import androidx.compose.material.icons.filled.ShowChart
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
@@ -93,6 +94,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
@@ -110,8 +112,8 @@ import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
|
||||
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
|
||||
@@ -124,6 +126,7 @@ import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtensions
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.CancelIcon
|
||||
@@ -152,6 +155,7 @@ import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -345,6 +349,15 @@ fun NewPostView(
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.wantsProduct) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
) {
|
||||
SellProduct(postViewModel = postViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
MessageField(postViewModel)
|
||||
|
||||
if (postViewModel.wantsPoll) {
|
||||
@@ -392,8 +405,10 @@ fun NewPostView(
|
||||
url,
|
||||
accountViewModel.account.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent ->
|
||||
postViewModel.upload(url, alt, sensitiveContent, server, context, relayList)
|
||||
accountViewModel.account.changeDefaultFileServer(server)
|
||||
postViewModel.upload(url, alt, sensitiveContent, false, server, context)
|
||||
if (!server.isNip95) {
|
||||
accountViewModel.account.changeDefaultFileServer(server.server)
|
||||
}
|
||||
},
|
||||
onCancel = {
|
||||
postViewModel.contentToAddUrl = null
|
||||
@@ -448,8 +463,7 @@ fun NewPostView(
|
||||
if (myUrlPreview != null) {
|
||||
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
|
||||
if (isValidURL(myUrlPreview)) {
|
||||
val removedParamsFromUrl =
|
||||
myUrlPreview.split("?")[0].lowercase()
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(myUrlPreview)
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
AsyncImage(
|
||||
model = myUrlPreview,
|
||||
@@ -530,6 +544,16 @@ fun NewPostView(
|
||||
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
|
||||
AddPollButton(postViewModel.wantsPoll) {
|
||||
postViewModel.wantsPoll = !postViewModel.wantsPoll
|
||||
if (postViewModel.wantsPoll) {
|
||||
postViewModel.wantsProduct = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AddClassifiedsButton(postViewModel) {
|
||||
postViewModel.wantsProduct = !postViewModel.wantsProduct
|
||||
if (postViewModel.wantsProduct) {
|
||||
postViewModel.wantsPoll = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,7 +659,11 @@ private fun MessageField(
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.what_s_on_your_mind),
|
||||
text = if (postViewModel.wantsProduct) {
|
||||
stringResource(R.string.description)
|
||||
} else {
|
||||
stringResource(R.string.what_s_on_your_mind)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
@@ -776,6 +804,227 @@ fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SellProduct(postViewModel: NewPostViewModel) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.classifieds_title),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
|
||||
MyTextField(
|
||||
value = postViewModel.title,
|
||||
onValueChange = {
|
||||
postViewModel.title = it
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.classifieds_title_placeholder),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
visualTransformation = UrlUserTagTransformation(
|
||||
MaterialTheme.colorScheme.primary
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.classifieds_price),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
|
||||
MyTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.price,
|
||||
onValueChange = {
|
||||
runCatching {
|
||||
if (it.text.isEmpty()) {
|
||||
postViewModel.price = TextFieldValue("")
|
||||
} else if (it.text.toLongOrNull() != null) {
|
||||
postViewModel.price = it
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "1000",
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.classifieds_condition),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
|
||||
val conditionTypes = listOf(
|
||||
Triple(ClassifiedsEvent.CONDITION.NEW, stringResource(id = R.string.classifieds_condition_new), stringResource(id = R.string.classifieds_condition_new_explainer)),
|
||||
Triple(ClassifiedsEvent.CONDITION.USED_LIKE_NEW, stringResource(id = R.string.classifieds_condition_like_new), stringResource(id = R.string.classifieds_condition_like_new_explainer)),
|
||||
Triple(ClassifiedsEvent.CONDITION.USED_GOOD, stringResource(id = R.string.classifieds_condition_good), stringResource(id = R.string.classifieds_condition_good_explainer)),
|
||||
Triple(ClassifiedsEvent.CONDITION.USED_FAIR, stringResource(id = R.string.classifieds_condition_fair), stringResource(id = R.string.classifieds_condition_fair_explainer))
|
||||
)
|
||||
|
||||
val conditionOptions = remember { conditionTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
|
||||
|
||||
TextSpinner(
|
||||
placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second,
|
||||
options = conditionOptions,
|
||||
onSelect = {
|
||||
postViewModel.condition = conditionTypes[it].first
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 5.dp, bottom = 1.dp)
|
||||
) { currentOption, modifier ->
|
||||
MyTextField(
|
||||
value = TextFieldValue(currentOption),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = modifier,
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.classifieds_category),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
|
||||
val categoryList = listOf(
|
||||
R.string.classifieds_category_clothing,
|
||||
R.string.classifieds_category_accessories,
|
||||
R.string.classifieds_category_electronics,
|
||||
R.string.classifieds_category_furniture,
|
||||
R.string.classifieds_category_collectibles,
|
||||
R.string.classifieds_category_books,
|
||||
R.string.classifieds_category_pets,
|
||||
R.string.classifieds_category_sports,
|
||||
R.string.classifieds_category_fitness,
|
||||
R.string.classifieds_category_art,
|
||||
R.string.classifieds_category_crafts,
|
||||
R.string.classifieds_category_home,
|
||||
R.string.classifieds_category_office,
|
||||
R.string.classifieds_category_food,
|
||||
R.string.classifieds_category_misc,
|
||||
R.string.classifieds_category_other
|
||||
)
|
||||
|
||||
val categoryTypes = categoryList.map {
|
||||
Triple(it, stringResource(id = it), null)
|
||||
}
|
||||
|
||||
val categoryOptions = remember { categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList() }
|
||||
TextSpinner(
|
||||
placeholder = categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second ?: "",
|
||||
options = categoryOptions,
|
||||
onSelect = {
|
||||
postViewModel.category = TextFieldValue(categoryTypes[it].second)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 5.dp, bottom = 1.dp)
|
||||
) { currentOption, modifier ->
|
||||
MyTextField(
|
||||
value = TextFieldValue(currentOption),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = modifier,
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.classifieds_location),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
|
||||
MyTextField(
|
||||
value = postViewModel.locationText,
|
||||
onValueChange = {
|
||||
postViewModel.locationText = it
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.classifieds_location_placeholder),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
visualTransformation = UrlUserTagTransformation(
|
||||
MaterialTheme.colorScheme.primary
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FowardZapTo(postViewModel: NewPostViewModel, accountViewModel: AccountViewModel) {
|
||||
Column(
|
||||
@@ -862,11 +1111,11 @@ fun FowardZapTo(postViewModel: NewPostViewModel, accountViewModel: AccountViewMo
|
||||
onValueChange = {
|
||||
postViewModel.updateZapForwardTo(it)
|
||||
},
|
||||
label = { Text(text = stringResource(R.string.zap_split_serarch_and_add_user)) },
|
||||
label = { Text(text = stringResource(R.string.zap_split_search_and_add_user)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.zap_split_serarch_and_add_user_placeholder),
|
||||
text = stringResource(R.string.zap_split_search_and_add_user_placeholder),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
@@ -1213,6 +1462,36 @@ private fun ForwardZapTo(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddClassifiedsButton(
|
||||
postViewModel: NewPostViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onClick()
|
||||
}
|
||||
) {
|
||||
if (!postViewModel.wantsProduct) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Sell,
|
||||
contentDescription = stringResource(R.string.classifieds),
|
||||
modifier = Modifier
|
||||
.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Sell,
|
||||
contentDescription = stringResource(id = R.string.classifieds),
|
||||
modifier = Modifier
|
||||
.size(20.dp),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkAsSensitive(
|
||||
postViewModel: NewPostViewModel,
|
||||
@@ -1320,8 +1599,8 @@ fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
|
||||
@Composable
|
||||
fun ImageVideoDescription(
|
||||
uri: Uri,
|
||||
defaultServer: ServersAvailable,
|
||||
onAdd: (String, ServersAvailable, Boolean) -> Unit,
|
||||
defaultServer: Nip96MediaServers.ServerName,
|
||||
onAdd: (String, ServerOption, Boolean) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
@@ -1332,23 +1611,19 @@ fun ImageVideoDescription(
|
||||
val isImage = mediaType.startsWith("image")
|
||||
val isVideo = mediaType.startsWith("video")
|
||||
|
||||
val fileServers = listOf(
|
||||
// Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)),
|
||||
Triple(ServersAvailable.NOSTRCHECK_ME, stringResource(id = R.string.upload_server_nostrcheckme), stringResource(id = R.string.upload_server_nostrcheckme_explainer)),
|
||||
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRCHECK_ME_NIP_94, stringResource(id = R.string.upload_server_nostrcheckme_nip94), stringResource(id = R.string.upload_server_nostrcheckme_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
val fileServers = Nip96MediaServers.DEFAULT.map { ServerOption(it, false) } + listOf(
|
||||
ServerOption(
|
||||
Nip96MediaServers.ServerName(
|
||||
"NIP95",
|
||||
stringResource(id = R.string.upload_server_relays_nip95)
|
||||
),
|
||||
true
|
||||
)
|
||||
)
|
||||
|
||||
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
|
||||
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.server.name, it.server.baseUrl) }.toImmutableList() }
|
||||
|
||||
var selectedServer by remember { mutableStateOf(defaultServer) }
|
||||
var selectedServer by remember { mutableStateOf(ServerOption(defaultServer, false)) }
|
||||
var message by remember { mutableStateOf("") }
|
||||
var sensitiveContent by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -1458,10 +1733,12 @@ fun ImageVideoDescription(
|
||||
) {
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.file_server),
|
||||
placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second,
|
||||
placeholder = fileServers.firstOrNull {
|
||||
it.server == accountViewModel.account.defaultFileServer
|
||||
}?.server?.name ?: fileServers[0].server.name,
|
||||
options = fileServerOptions,
|
||||
onSelect = {
|
||||
selectedServer = fileServers[it].first
|
||||
selectedServer = fileServers[it]
|
||||
},
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
@@ -1469,45 +1746,41 @@ fun ImageVideoDescription(
|
||||
)
|
||||
}
|
||||
|
||||
if (isNIP94Server(selectedServer) ||
|
||||
selectedServer == ServersAvailable.NIP95
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
SettingSwitchItem(
|
||||
checked = sensitiveContent,
|
||||
onCheckedChange = { sensitiveContent = it },
|
||||
title = R.string.add_sensitive_content_label,
|
||||
description = R.string.add_sensitive_content_description
|
||||
)
|
||||
}
|
||||
SettingSwitchItem(
|
||||
checked = sensitiveContent,
|
||||
onCheckedChange = { sensitiveContent = it },
|
||||
title = R.string.add_sensitive_content_label,
|
||||
description = R.string.add_sensitive_content_description
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.content_description_example),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.content_description_example),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
|
||||
@@ -18,10 +18,10 @@ import com.fonfon.kgeohash.toGeoHash
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.LocationUtil
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
@@ -33,16 +33,24 @@ import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.Price
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.events.findURLs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URLEncoder
|
||||
|
||||
enum class UserSuggestionAnchor {
|
||||
MAIN_MESSAGE,
|
||||
@@ -61,6 +69,9 @@ open class NewPostViewModel() : ViewModel() {
|
||||
var mentions by mutableStateOf<List<User>?>(null)
|
||||
var replyTos by mutableStateOf<List<Note>?>(null)
|
||||
|
||||
var nip94attachments by mutableStateOf<List<FileHeaderEvent>>(emptyList())
|
||||
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
var urlPreview by mutableStateOf<String?>(null)
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
@@ -94,6 +105,14 @@ open class NewPostViewModel() : ViewModel() {
|
||||
var isValidConsensusThreshold = mutableStateOf(true)
|
||||
var isValidClosedAt = mutableStateOf(true)
|
||||
|
||||
// Classifieds
|
||||
var wantsProduct by mutableStateOf(false)
|
||||
var title by mutableStateOf(TextFieldValue(""))
|
||||
var price by mutableStateOf(TextFieldValue(""))
|
||||
var locationText by mutableStateOf(TextFieldValue(""))
|
||||
var category by mutableStateOf(TextFieldValue(""))
|
||||
var condition by mutableStateOf<ClassifiedsEvent.CONDITION>(ClassifiedsEvent.CONDITION.USED_LIKE_NEW)
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
var wantsInvoice by mutableStateOf(false)
|
||||
@@ -188,7 +207,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
val dmUsers = toUsersTagger.mentions
|
||||
|
||||
val zapReceiver = if (wantsForwardZapTo) {
|
||||
forwardZapTo?.items?.map {
|
||||
forwardZapTo.items.map {
|
||||
ZapSplitSetup(
|
||||
lnAddressOrPubKeyHex = it.key.pubkeyHex,
|
||||
relay = it.key.relaysBeingUsed.keys.firstOrNull(),
|
||||
@@ -209,11 +228,25 @@ open class NewPostViewModel() : ViewModel() {
|
||||
|
||||
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
|
||||
|
||||
nip95attachments.forEach {
|
||||
if (replyTos?.contains(LocalCache.getNoteIfExists(it.second.id)) == true) {
|
||||
account?.sendNip95(it.first, it.second, relayList)
|
||||
}
|
||||
}
|
||||
|
||||
val urls = findURLs(tagger.message)
|
||||
val usedAttachments = nip94attachments.filter {
|
||||
it.urls().intersect(urls).isNotEmpty()
|
||||
}
|
||||
usedAttachments.forEach {
|
||||
account?.sendHeader(it, relayList, { })
|
||||
}
|
||||
|
||||
if (originalNote?.channelHex() != null) {
|
||||
if (originalNote is AddressableEvent && originalNote?.address() != null) {
|
||||
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
|
||||
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash, nip94attachments = usedAttachments)
|
||||
} else {
|
||||
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
|
||||
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash, nip94attachments = usedAttachments)
|
||||
}
|
||||
} else if (originalNote?.event is PrivateDmEvent) {
|
||||
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
|
||||
@@ -271,7 +304,26 @@ open class NewPostViewModel() : ViewModel() {
|
||||
wantsToMarkAsSensitive,
|
||||
localZapRaiserAmount,
|
||||
relayList,
|
||||
geoHash
|
||||
geoHash,
|
||||
nip94attachments = usedAttachments
|
||||
)
|
||||
} else if (wantsProduct) {
|
||||
account?.sendClassifieds(
|
||||
title = title.text,
|
||||
price = Price(price.text, "SATS", null),
|
||||
condition = condition,
|
||||
message = tagger.message,
|
||||
replyTo = tagger.replyTos,
|
||||
mentions = tagger.mentions,
|
||||
location = locationText.text,
|
||||
category = category.text,
|
||||
directMentions = tagger.directMentions,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments
|
||||
)
|
||||
} else {
|
||||
// adds markers
|
||||
@@ -295,7 +347,8 @@ open class NewPostViewModel() : ViewModel() {
|
||||
root = rootId,
|
||||
directMentions = tagger.directMentions,
|
||||
relayList = relayList,
|
||||
geohash = geoHash
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -303,7 +356,14 @@ open class NewPostViewModel() : ViewModel() {
|
||||
cancel()
|
||||
}
|
||||
|
||||
fun upload(galleryUri: Uri, alt: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context, relayList: List<Relay>? = null) {
|
||||
fun upload(
|
||||
galleryUri: Uri,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
isPrivate: Boolean = false,
|
||||
server: ServerOption,
|
||||
context: Context
|
||||
) {
|
||||
isUploadingImage = true
|
||||
contentToAddUrl = null
|
||||
|
||||
@@ -316,34 +376,50 @@ open class NewPostViewModel() : ViewModel() {
|
||||
contentType,
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
if (server == ServersAvailable.NIP95) {
|
||||
if (server.isNip95) {
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList)
|
||||
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ImageUploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
if (isNIP94Server(server)) {
|
||||
createNIP94Record(imageUrl, mimeType, alt, sensitiveContent)
|
||||
} else {
|
||||
isUploadingImage = false
|
||||
message = TextFieldValue(message.text + "\n" + imageUrl)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
try {
|
||||
val result = Nip96Uploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = alt,
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server.server,
|
||||
contentResolver = contentResolver,
|
||||
onProgress = { }
|
||||
)
|
||||
|
||||
if (!isPrivate) {
|
||||
createNIP94Record(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent
|
||||
)
|
||||
} else {
|
||||
noNIP94(
|
||||
uploadingResult = result,
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent
|
||||
)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"ImageUploader",
|
||||
"Failed to upload ${e.message}",
|
||||
e
|
||||
)
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -381,6 +457,10 @@ open class NewPostViewModel() : ViewModel() {
|
||||
wantsZapraiser = false
|
||||
zapRaiserAmount = null
|
||||
|
||||
wantsProduct = false
|
||||
condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW
|
||||
price = TextFieldValue("")
|
||||
|
||||
wantsForwardZapTo = false
|
||||
wantsToMarkAsSensitive = false
|
||||
wantsToAddGeoHash = false
|
||||
@@ -489,7 +569,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
|
||||
)
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
|
||||
forwardZapTo?.addItem(item)
|
||||
forwardZapTo.addItem(item)
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
/*
|
||||
val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
@@ -527,6 +607,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
(!wantsZapraiser || zapRaiserAmount != null) &&
|
||||
(!wantsDirectMessage || !toUsers.text.isNullOrBlank()) &&
|
||||
(!wantsPoll || (pollOptions.values.all { it.isNotEmpty() } && isValidvalueMinimum.value && isValidvalueMaximum.value)) &&
|
||||
(!wantsProduct || (!title.text.isNullOrBlank() && !price.text.isNullOrBlank() && !category.text.isNullOrBlank())) &&
|
||||
contentToAddUrl == null
|
||||
}
|
||||
|
||||
@@ -543,44 +624,139 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
FileHeader.prepare(
|
||||
imageUrl,
|
||||
mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
onReady = {
|
||||
account?.sendHeader(it, relayList = relayList) { note ->
|
||||
isUploadingImage = false
|
||||
suspend fun createNIP94Record(
|
||||
uploadingResult: Nip96Uploader.PartialEvent,
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean
|
||||
) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
val imageUrl = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
val remoteMimeType = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null }
|
||||
val originalHash = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null }
|
||||
val dim = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
|
||||
val magnet = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "magnet" }?.get(1)?.ifBlank { null }
|
||||
|
||||
message = TextFieldValue(message.text + "\nnostr:" + note.toNEvent())
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
FileHeader.prepare(
|
||||
fileUrl = imageUrl,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
onReady = { header: FileHeader ->
|
||||
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
nip94attachments = nip94attachments + event
|
||||
val contentWarning = if (sensitiveContent) "" else null
|
||||
message = TextFieldValue(message.text + "\n" + addInlineMetadataAsNIP54(imageUrl, header.dim, header.mimeType, alt, header.blurHash, header.hash, contentWarning))
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun noNIP94(
|
||||
uploadingResult: Nip96Uploader.PartialEvent,
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean
|
||||
) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
val imageUrl = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
val remoteMimeType = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null }
|
||||
val dim = uploadingResult.tags?.firstOrNull() { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
|
||||
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Server failed to return a url")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
fileUrl = imageUrl,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
onReady = { header: FileHeader ->
|
||||
isUploadingImage = false
|
||||
val contentWarning = if (sensitiveContent) "" else null
|
||||
message = TextFieldValue(message.text + "\n" + addInlineMetadataAsNIP54(imageUrl, header.dim, header.mimeType, alt, header.blurHash, header.hash, contentWarning))
|
||||
urlPreview = findUrlInMessage()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun addInlineMetadataAsNIP54(
|
||||
imageUrl: String,
|
||||
dim: String?,
|
||||
m: String?,
|
||||
alt: String?,
|
||||
blurHash: String?,
|
||||
x: String?,
|
||||
sensitiveContent: String?
|
||||
): String {
|
||||
val extension = listOfNotNull(
|
||||
m?.ifBlank { null }?.let { "m=${URLEncoder.encode(it, "utf-8")}" },
|
||||
dim?.ifBlank { null }?.let { "dim=${URLEncoder.encode(it, "utf-8")}" },
|
||||
alt?.ifBlank { null }?.let { "alt=${URLEncoder.encode(it, "utf-8")}" },
|
||||
blurHash?.ifBlank { null }?.let { "blurhash=${URLEncoder.encode(it, "utf-8")}" },
|
||||
x?.ifBlank { null }?.let { "x=${URLEncoder.encode(it, "utf-8")}" },
|
||||
sensitiveContent?.ifBlank { null }?.let { "content-warning=${URLEncoder.encode(it, "utf-8")}" }
|
||||
).joinToString("&")
|
||||
|
||||
return if (imageUrl.contains("#")) {
|
||||
"$imageUrl&$extension"
|
||||
} else {
|
||||
"$imageUrl#$extension"
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
|
||||
fun createNIP95Record(
|
||||
bytes: ByteArray,
|
||||
mimeType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean
|
||||
) {
|
||||
if (bytes.size > 80000) {
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Media is too big for NIP-95")
|
||||
isUploadingImage = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
"",
|
||||
mimeType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
null,
|
||||
onReady = {
|
||||
account?.createNip95(bytes, headerInfo = it) { nip95 ->
|
||||
val note = nip95.let { it1 -> account?.sendNip95(it1.first, it1.second, relayList = relayList) }
|
||||
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
@@ -605,6 +781,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
contentToAddUrl = uri
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun startLocation(context: Context) {
|
||||
locUtil = LocationUtil(context)
|
||||
locUtil?.let {
|
||||
|
||||
@@ -57,7 +57,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
@@ -74,11 +74,11 @@ import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.WarningColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.warningColor
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -329,7 +329,7 @@ fun ServerConfig(
|
||||
accountViewModel.retrieveRelayDocument(
|
||||
item.url,
|
||||
onInfo = {
|
||||
relayInfo = RelayInfoDialog(RelayBriefInfo(item.url), it)
|
||||
relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it)
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage ->
|
||||
val msg = when (errorCode) {
|
||||
@@ -369,7 +369,12 @@ fun ServerConfigClickableLine(
|
||||
modifier = Modifier.padding(vertical = 5.dp)
|
||||
) {
|
||||
Column(Modifier.clickable(onClick = onClick)) {
|
||||
RenderRelayIcon(iconUrl = item.briefInfo.favIcon, loadProfilePicture, Size55dp)
|
||||
RenderRelayIcon(
|
||||
item.briefInfo.displayUrl,
|
||||
item.briefInfo.favIcon,
|
||||
loadProfilePicture,
|
||||
MaterialTheme.colorScheme.largeRelayIconModifier
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = HalfHorzPadding)
|
||||
|
||||
@@ -32,10 +32,9 @@ class NewRelayListViewModel : ViewModel() {
|
||||
relays.let {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.saveRelayList(it.value)
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
clear()
|
||||
}
|
||||
|
||||
fun loadRelayDocuments() {
|
||||
@@ -120,7 +119,7 @@ class NewRelayListViewModel : ViewModel() {
|
||||
|
||||
fun deleteAll() {
|
||||
_relays.update { relays ->
|
||||
relays.dropWhile { relays.isNotEmpty() }
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-12
@@ -10,6 +10,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.quartz.events.GitHubIdentity
|
||||
import com.vitorpamplona.quartz.events.MastodonIdentity
|
||||
@@ -123,7 +124,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
ObjectMapper().writeValue(writer, currentJson)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.sendNewUserMetadata(writer.buffer.toString(), newClaims)
|
||||
account.sendNewUserMetadata(writer.buffer.toString(), displayName.value.trim(), newClaims)
|
||||
}
|
||||
clear()
|
||||
}
|
||||
@@ -184,23 +185,35 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ImageUploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
server = account.defaultFileServer,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
try {
|
||||
val result = Nip96Uploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
server = account.defaultFileServer,
|
||||
contentResolver = contentResolver,
|
||||
onProgress = { }
|
||||
)
|
||||
|
||||
val url = result.tags?.firstOrNull() { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
|
||||
if (url != null) {
|
||||
onUploading(false)
|
||||
onUploaded(imageUrl)
|
||||
},
|
||||
onError = {
|
||||
onUploaded(url)
|
||||
} else {
|
||||
onUploading(false)
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
onUploading(false)
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -26,7 +27,7 @@ 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.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
@@ -36,14 +37,14 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RelayInformationDialog(
|
||||
onClose: () -> Unit,
|
||||
relayBriefInfo: RelayBriefInfo,
|
||||
relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo,
|
||||
relayInfo: RelayInformation,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
@@ -81,9 +82,10 @@ fun RelayInformationDialog(
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = StdPadding.fillMaxWidth()) {
|
||||
Column() {
|
||||
RenderRelayIcon(
|
||||
relayBriefInfo.displayUrl,
|
||||
relayBriefInfo.favIcon,
|
||||
automaticallyShowProfilePicture,
|
||||
Size55dp
|
||||
MaterialTheme.colorScheme.largeRelayIconModifier
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
@@ -37,12 +37,12 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
data class RelayList(
|
||||
val relay: Relay,
|
||||
val relayInfo: RelayBriefInfo,
|
||||
val relayInfo: RelayBriefInfoCache.RelayBriefInfo,
|
||||
val isSelected: Boolean
|
||||
)
|
||||
|
||||
data class RelayInfoDialog(
|
||||
val relayBriefInfo: RelayBriefInfo,
|
||||
val relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo,
|
||||
val relayInfo: RelayInformation
|
||||
)
|
||||
|
||||
@@ -61,7 +61,7 @@ fun RelaySelectionDialog(
|
||||
accountViewModel.account.activeWriteRelays().map {
|
||||
RelayList(
|
||||
relay = it,
|
||||
relayInfo = RelayBriefInfo(it.url),
|
||||
relayInfo = RelayBriefInfoCache.RelayBriefInfo(it.url),
|
||||
isSelected = preSelectedList.any { relay -> it.url == relay.url }
|
||||
)
|
||||
}
|
||||
@@ -167,7 +167,12 @@ fun RelaySelectionDialog(
|
||||
accountViewModel.retrieveRelayDocument(
|
||||
item.relay.url,
|
||||
onInfo = {
|
||||
relayInfo = RelayInfoDialog(RelayBriefInfo(item.relay.url), it)
|
||||
relayInfo = RelayInfoDialog(
|
||||
RelayBriefInfoCache.RelayBriefInfo(
|
||||
item.relay.url
|
||||
),
|
||||
it
|
||||
)
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage ->
|
||||
val msg = when (errorCode) {
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -21,7 +26,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -34,21 +38,39 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.fasterxml.jackson.databind.node.TextNode
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.service.CashuProcessor
|
||||
import com.vitorpamplona.amethyst.service.CashuToken
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.note.CashuIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.CopyIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
|
||||
var cachuData by remember { mutableStateOf<GenericLoadable<CashuToken>>(GenericLoadable.Loading<CashuToken>()) }
|
||||
var cachuData by remember {
|
||||
mutableStateOf<GenericLoadable<CashuToken>>(GenericLoadable.Loading())
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = cashutoken) {
|
||||
launch(Dispatchers.IO) {
|
||||
@@ -59,7 +81,7 @@ fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = cachuData) {
|
||||
Crossfade(targetState = cachuData, label = "CashuPreview(") {
|
||||
when (it) {
|
||||
is GenericLoadable.Loaded<CashuToken> -> CashuPreview(it.loaded, accountViewModel)
|
||||
is GenericLoadable.Error<CashuToken> -> Text(
|
||||
@@ -73,26 +95,58 @@ fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
|
||||
|
||||
@Composable
|
||||
fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) {
|
||||
val lud16 = remember(accountViewModel) {
|
||||
accountViewModel.account.userProfile().info?.lud16
|
||||
}
|
||||
CashuPreviewNew(token, accountViewModel::meltCashu, accountViewModel::toast)
|
||||
}
|
||||
|
||||
val useWebService = false
|
||||
@Composable
|
||||
@Preview()
|
||||
fun CashuPreviewPreview() {
|
||||
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
|
||||
|
||||
sharedPreferencesViewModel.init()
|
||||
sharedPreferencesViewModel.updateTheme(ThemeType.DARK)
|
||||
|
||||
AmethystTheme(sharedPrefsViewModel = sharedPreferencesViewModel) {
|
||||
Column() {
|
||||
CashuPreview(
|
||||
token = CashuToken("token", "mint", 32400, TextNode("")),
|
||||
melt = { token, context, onDone ->
|
||||
},
|
||||
toast = { title, message ->
|
||||
}
|
||||
)
|
||||
|
||||
CashuPreviewNew(
|
||||
token = CashuToken("token", "mint", 32400, TextNode("")),
|
||||
melt = { token, context, onDone ->
|
||||
},
|
||||
toast = { title, message ->
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CashuPreview(
|
||||
token: CashuToken,
|
||||
melt: (CashuToken, Context, (String, String) -> Unit) -> Unit,
|
||||
toast: (String, String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 30.dp, end = 30.dp)
|
||||
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(30.dp)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -103,7 +157,7 @@ fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.cashu),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
modifier = Size20Modifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
@@ -117,80 +171,195 @@ fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) {
|
||||
|
||||
Divider()
|
||||
|
||||
token.totalAmount.let {
|
||||
Text(
|
||||
text = "${token.totalAmount} ${stringResource(id = R.string.sats)}",
|
||||
fontSize = 25.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
var isRedeeming by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
isRedeeming = true
|
||||
melt(token, context) { title, message ->
|
||||
toast(title, message)
|
||||
isRedeeming = false
|
||||
}
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
if (isRedeeming) {
|
||||
LoadingAnimation()
|
||||
} else {
|
||||
ZapIcon(Size20Modifier, tint = Color.White)
|
||||
}
|
||||
Spacer(DoubleHorzSpacer)
|
||||
|
||||
Text(
|
||||
stringResource(id = R.string.cashu_redeem_to_zap),
|
||||
color = Color.White,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
Button(
|
||||
onClick = {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}"))
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
|
||||
startActivity(context, intent, null)
|
||||
} catch (e: Exception) {
|
||||
toast("Cashu", context.getString(R.string.cashu_no_wallet_found))
|
||||
}
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
CashuIcon(Size20Modifier)
|
||||
Spacer(DoubleHorzSpacer)
|
||||
Text(stringResource(id = R.string.cashu_redeem_to_cashu), color = Color.White, fontSize = 16.sp)
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
Button(
|
||||
onClick = {
|
||||
// Copying the token to clipboard
|
||||
clipboardManager.setText(AnnotatedString(token.token))
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
CopyIcon(Size20Modifier, Color.White)
|
||||
Spacer(DoubleHorzSpacer)
|
||||
Text(stringResource(id = R.string.cashu_copy_token), color = Color.White, fontSize = 16.sp)
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CashuPreviewNew(
|
||||
token: CashuToken,
|
||||
melt: (CashuToken, Context, (String, String) -> Unit) -> Unit,
|
||||
toast: (String, String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp)
|
||||
.clip(shape = QuoteBorder)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.cashu),
|
||||
null,
|
||||
modifier = Modifier.size(13.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "$it ${stringResource(id = R.string.sats)}",
|
||||
fontSize = 25.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp)
|
||||
text = stringResource(R.string.cashu),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(start = 5.dp, bottom = 1.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
Text(
|
||||
text = "${token.totalAmount} ${stringResource(id = R.string.sats)}",
|
||||
fontSize = 20.sp
|
||||
)
|
||||
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
Button(
|
||||
Row(modifier = Modifier.padding(top = 5.dp)) {
|
||||
var isRedeeming by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp).padding(horizontal = 2.dp),
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
if (lud16 != null) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
CashuProcessor().melt(
|
||||
token,
|
||||
lud16,
|
||||
onSuccess = { title, message ->
|
||||
accountViewModel.toast(title, message)
|
||||
},
|
||||
onError = { title, message ->
|
||||
accountViewModel.toast(title, message)
|
||||
},
|
||||
context
|
||||
)
|
||||
}
|
||||
} else {
|
||||
accountViewModel.toast(
|
||||
context.getString(R.string.no_lightning_address_set),
|
||||
context.getString(R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats, accountViewModel.account.userProfile().toBestDisplayName())
|
||||
)
|
||||
isRedeeming = true
|
||||
melt(token, context) { title, message ->
|
||||
toast(title, message)
|
||||
isRedeeming = false
|
||||
}
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
shape = SmallishBorder
|
||||
) {
|
||||
if (isRedeeming) {
|
||||
LoadingAnimation()
|
||||
} else {
|
||||
ZapIcon(Size20Modifier, tint = MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
Spacer(StdHorzSpacer)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.cashu_redeem),
|
||||
color = Color.White,
|
||||
fontSize = 18.sp
|
||||
"Redeem",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp).padding(horizontal = 1.dp),
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
if (useWebService) {
|
||||
// In case we want to use the cashu.me webservice
|
||||
val url = "https://redeem.cashu.me?token=$token&lightning=$lud16&autopay=false"
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}"))
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
|
||||
startActivity(context, intent, null)
|
||||
} else {
|
||||
// Copying the token to clipboard for now
|
||||
clipboardManager.setText(AnnotatedString(token.token))
|
||||
} catch (e: Exception) {
|
||||
toast("Cashu", context.getString(R.string.cashu_no_wallet_found))
|
||||
}
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
shape = SmallishBorder,
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text("⎘", color = Color.White, fontSize = 18.sp)
|
||||
OpenInNewIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
// Copying the token to clipboard
|
||||
clipboardManager.setText(AnnotatedString(token.token))
|
||||
},
|
||||
shape = SmallishBorder,
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
CopyIcon(Size18Modifier, tint = MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NIP30Parser
|
||||
import com.vitorpamplona.amethyst.service.Nip30CustomEmoji
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadChannel
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -550,7 +550,7 @@ fun CreateClickableTextWithEmoji(
|
||||
}
|
||||
|
||||
suspend fun assembleAnnotatedList(text: String, emojis: Map<String, String>): ImmutableList<Renderable> {
|
||||
return NIP30Parser().buildArray(text).map {
|
||||
return Nip30CustomEmoji().buildArray(text).map {
|
||||
val url = emojis[it]
|
||||
if (url != null) {
|
||||
ImageUrlType(url)
|
||||
|
||||
@@ -12,17 +12,18 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
fun ClickableUrl(urlText: String, url: String) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
val doubleCheckedUrl = remember(url) {
|
||||
if (url.contains("://")) url else "https://$url"
|
||||
}
|
||||
|
||||
val text = remember(urlText) {
|
||||
AnnotatedString(urlText)
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = text,
|
||||
onClick = { runCatching { uri.openUri(doubleCheckedUrl) } },
|
||||
onClick = {
|
||||
runCatching {
|
||||
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
|
||||
uri.openUri(doubleCheckedUrl)
|
||||
}
|
||||
},
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
}
|
||||
|
||||
+12
-9
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -78,15 +79,17 @@ fun ExpandableRichTextViewer(
|
||||
}
|
||||
|
||||
Box {
|
||||
RichTextViewer(
|
||||
text,
|
||||
canPreview,
|
||||
modifier.align(Alignment.TopStart),
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
Crossfade(text, label = "ExpandableRichTextViewer") {
|
||||
RichTextViewer(
|
||||
it,
|
||||
canPreview,
|
||||
modifier.align(Alignment.TopStart),
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
|
||||
if (content.length > whereToCut && !showFullText) {
|
||||
Row(
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Divider
|
||||
@@ -36,6 +35,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.payViaIntent
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -119,7 +119,7 @@ fun InvoicePreview(lnInvoice: String, amount: String?) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.lightning),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
modifier = Size20Modifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -37,6 +36,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
@@ -94,7 +94,7 @@ fun InvoiceRequest(
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.lightning),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
modifier = Size20Modifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -10,6 +11,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
fun LoadUrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) {
|
||||
@@ -42,7 +45,17 @@ fun LoadUrlPreview(url: String, urlText: String, accountViewModel: AccountViewMo
|
||||
) { state ->
|
||||
when (state) {
|
||||
is UrlPreviewState.Loaded -> {
|
||||
UrlPreviewCard(url, state.previewInfo)
|
||||
if (state.previewInfo.mimeType.type == "image") {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(ZoomableUrlImage(url), persistentListOf(), roundedCorner = true, accountViewModel)
|
||||
}
|
||||
} else if (state.previewInfo.mimeType.type == "video") {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(ZoomableUrlVideo(url), persistentListOf(), roundedCorner = true, accountViewModel)
|
||||
}
|
||||
} else {
|
||||
UrlPreviewCard(url, state.previewInfo)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
@@ -93,7 +93,7 @@ class MarkdownParser {
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(word)
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
returnContent += " "
|
||||
} else {
|
||||
|
||||
@@ -69,11 +69,16 @@ class MediaCompressor {
|
||||
}
|
||||
}
|
||||
)
|
||||
} else if (contentType?.startsWith("image", true) == true && !contentType.contains("gif")) {
|
||||
val compressedImageFile = Compressor.compress(applicationContext, from(uri, contentType, applicationContext)) {
|
||||
default(width = 640, format = Bitmap.CompressFormat.JPEG)
|
||||
} else if (contentType?.startsWith("image", true) == true && !contentType.contains("gif") && !contentType.contains("svg")) {
|
||||
try {
|
||||
val compressedImageFile = Compressor.compress(applicationContext, from(uri, contentType, applicationContext)) {
|
||||
default(width = 640, format = Bitmap.CompressFormat.JPEG)
|
||||
}
|
||||
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onReady(uri, contentType, null)
|
||||
}
|
||||
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
|
||||
} else {
|
||||
onReady(uri, contentType, null)
|
||||
}
|
||||
|
||||
@@ -30,17 +30,18 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFontFamilyResolver
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.em
|
||||
@@ -64,6 +65,7 @@ import com.vitorpamplona.amethyst.service.InvoiceSegment
|
||||
import com.vitorpamplona.amethyst.service.LinkSegment
|
||||
import com.vitorpamplona.amethyst.service.PhoneSegment
|
||||
import com.vitorpamplona.amethyst.service.RegularTextSegment
|
||||
import com.vitorpamplona.amethyst.service.RichTextParser
|
||||
import com.vitorpamplona.amethyst.service.RichTextViewerState
|
||||
import com.vitorpamplona.amethyst.service.SchemelessUrlSegment
|
||||
import com.vitorpamplona.amethyst.service.Segment
|
||||
@@ -95,6 +97,22 @@ val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "
|
||||
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
|
||||
val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
fun removeQueryParamsForExtensionComparison(fullUrl: String): String {
|
||||
return if (fullUrl.contains("?")) {
|
||||
fullUrl.split("?")[0].lowercase()
|
||||
} else if (fullUrl.contains("#")) {
|
||||
fullUrl.split("#")[0].lowercase()
|
||||
} else {
|
||||
fullUrl.lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
fun isImageOrVideoUrl(url: String): Boolean {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
|
||||
|
||||
return imageExtensions.any { removedParamsFromUrl.endsWith(it) } || videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
}
|
||||
|
||||
fun isValidURL(url: String?): Boolean {
|
||||
return try {
|
||||
URL(url).toURI()
|
||||
@@ -160,59 +178,59 @@ private fun RenderRegular(
|
||||
)
|
||||
}
|
||||
|
||||
MeasureSpaceWidth() { spaceWidth ->
|
||||
Column() {
|
||||
if (canPreview) {
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
state.paragraphs.forEach { paragraph ->
|
||||
val direction = if (paragraph.isRTL) {
|
||||
LayoutDirection.Rtl
|
||||
} else {
|
||||
LayoutDirection.Ltr
|
||||
}
|
||||
val spaceWidth = measureSpaceWidth(textStyle)
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides direction) {
|
||||
FlowRow(
|
||||
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start),
|
||||
horizontalArrangement = Arrangement.spacedBy(spaceWidth)
|
||||
) {
|
||||
paragraph.words.forEach { word ->
|
||||
RenderWordWithPreview(
|
||||
word,
|
||||
state,
|
||||
backgroundColor,
|
||||
textStyle,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
Column() {
|
||||
if (canPreview) {
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
state.paragraphs.forEach { paragraph ->
|
||||
val direction = if (paragraph.isRTL) {
|
||||
LayoutDirection.Rtl
|
||||
} else {
|
||||
LayoutDirection.Ltr
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides direction) {
|
||||
FlowRow(
|
||||
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start),
|
||||
horizontalArrangement = Arrangement.spacedBy(spaceWidth)
|
||||
) {
|
||||
paragraph.words.forEach { word ->
|
||||
RenderWordWithPreview(
|
||||
word,
|
||||
state,
|
||||
backgroundColor,
|
||||
textStyle,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
state.paragraphs.forEach { paragraph ->
|
||||
val direction = if (paragraph.isRTL) {
|
||||
LayoutDirection.Rtl
|
||||
} else {
|
||||
LayoutDirection.Ltr
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
state.paragraphs.forEach { paragraph ->
|
||||
val direction = if (paragraph.isRTL) {
|
||||
LayoutDirection.Rtl
|
||||
} else {
|
||||
LayoutDirection.Ltr
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides direction) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
|
||||
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start)
|
||||
) {
|
||||
paragraph.words.forEach { word ->
|
||||
RenderWordWithoutPreview(
|
||||
word,
|
||||
state,
|
||||
backgroundColor,
|
||||
textStyle,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
CompositionLocalProvider(LocalLayoutDirection provides direction) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
|
||||
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start)
|
||||
) {
|
||||
paragraph.words.forEach { word ->
|
||||
RenderWordWithoutPreview(
|
||||
word,
|
||||
state,
|
||||
backgroundColor,
|
||||
textStyle,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,17 +240,15 @@ private fun RenderRegular(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MeasureSpaceWidth(
|
||||
content: @Composable (measuredWidth: Dp) -> Unit
|
||||
) {
|
||||
SubcomposeLayout { constraints ->
|
||||
val measuredWidth = subcompose("viewToMeasure", { Text(" ") })[0].measure(Constraints()).width.toDp()
|
||||
fun measureSpaceWidth(textStyle: TextStyle): Dp {
|
||||
val fontFamilyResolver = LocalFontFamilyResolver.current
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
|
||||
val contentPlaceable = subcompose("content") {
|
||||
content(measuredWidth)
|
||||
}[0].measure(constraints)
|
||||
layout(contentPlaceable.width, contentPlaceable.height) {
|
||||
contentPlaceable.place(0, 0)
|
||||
return remember(fontFamilyResolver, density, layoutDirection, textStyle) {
|
||||
val widthPx = TextMeasurer(fontFamilyResolver, density, layoutDirection, 1).measure(" ", textStyle).size.width
|
||||
with(density) {
|
||||
widthPx.toDp()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,6 +350,13 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
|
||||
)
|
||||
}
|
||||
|
||||
val markdownParseOptions = MarkdownParseOptions(
|
||||
autolink = true,
|
||||
isImage = { url ->
|
||||
isImageOrVideoUrl(url)
|
||||
}
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<String>?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val uri = LocalUriHandler.current
|
||||
@@ -350,14 +373,22 @@ private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<
|
||||
}
|
||||
|
||||
ProvideTextStyle(MarkdownTextStyle) {
|
||||
Material3RichText(
|
||||
style = MaterialTheme.colorScheme.markdownStyle
|
||||
) {
|
||||
Material3RichText(style = MaterialTheme.colorScheme.markdownStyle) {
|
||||
RefreshableContent(content, tags, accountViewModel) {
|
||||
Markdown(
|
||||
content = it,
|
||||
markdownParseOptions = MarkdownParseOptions.Default,
|
||||
onLinkClicked = onClick
|
||||
markdownParseOptions = markdownParseOptions,
|
||||
onLinkClicked = onClick,
|
||||
onMediaCompose = { title, destination ->
|
||||
ZoomableContentView(
|
||||
content = remember(destination) {
|
||||
RichTextParser().parseMediaUrl(destination)
|
||||
?: ZoomableUrlImage(url = destination)
|
||||
},
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -557,7 +588,7 @@ private fun RenderHashtag(
|
||||
) {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val background = MaterialTheme.colorScheme.onBackground
|
||||
val hashtagIcon = remember(segment.hashtag) {
|
||||
val hashtagIcon: HashtagIcon? = remember(segment.hashtag) {
|
||||
checkForHashtagWithIcon(segment.hashtag, primary)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.ImageSource
|
||||
@@ -14,308 +11,48 @@ import coil.fetch.SourceResult
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.Robohash
|
||||
import okio.Buffer
|
||||
|
||||
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 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 {
|
||||
checkNotInMainThread()
|
||||
|
||||
val hash = CryptoUtils.sha256(msg.toByteArray())
|
||||
val hashHex = hash.toHexKey()
|
||||
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>"""
|
||||
}
|
||||
import java.nio.charset.Charset
|
||||
|
||||
@Stable
|
||||
class HashImageFetcher(
|
||||
private val context: Context,
|
||||
private val isLightTheme: Boolean,
|
||||
private val data: Uri
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): SourceResult {
|
||||
checkNotInMainThread()
|
||||
val source = try {
|
||||
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
|
||||
val buffer = Buffer()
|
||||
buffer.writeString(Robohash.assemble(data.toString(), isLightTheme), Charset.defaultCharset())
|
||||
buffer
|
||||
} finally {
|
||||
}
|
||||
|
||||
return SourceResult(
|
||||
source = ImageSource(source, context),
|
||||
mimeType = null,
|
||||
mimeType = "image/svg+xml",
|
||||
dataSource = DataSource.MEMORY
|
||||
)
|
||||
}
|
||||
|
||||
object Factory : Fetcher.Factory<Uri> {
|
||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return HashImageFetcher(options.context, data)
|
||||
return HashImageFetcher(options.context, options.parameters.value("isLightTheme") ?: true, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
object Robohash {
|
||||
fun imageRequest(context: Context, message: String): ImageRequest {
|
||||
|
||||
object RobohashImageRequest {
|
||||
fun build(context: Context, message: String, isLightTheme: Boolean): ImageRequest {
|
||||
return ImageRequest
|
||||
.Builder(context)
|
||||
.data("robohash:$message")
|
||||
.data(message)
|
||||
.fetcherFactory(HashImageFetcher.Factory)
|
||||
.setParameter("isLightTheme", isLightTheme)
|
||||
.addHeader("Cache-Control", "max-age=31536000")
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
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"/>"""
|
||||
)
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -29,6 +30,7 @@ import coil.fetch.Fetcher
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.theme.isLight
|
||||
import java.util.Base64
|
||||
|
||||
@Composable
|
||||
@@ -45,9 +47,10 @@ fun RobohashAsyncImage(
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isLightTheme = MaterialTheme.colorScheme.isLight
|
||||
|
||||
val imageRequest = remember(robot) {
|
||||
Robohash.imageRequest(context, robot)
|
||||
RobohashImageRequest.build(context, robot, isLightTheme)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
@@ -78,8 +81,9 @@ fun RobohashFallbackAsyncImage(
|
||||
loadProfilePicture: Boolean
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isLightTheme = MaterialTheme.colorScheme.isLight
|
||||
val painter = rememberAsyncImagePainter(
|
||||
model = Robohash.imageRequest(context, robot)
|
||||
model = RobohashImageRequest.build(context, robot, isLightTheme)
|
||||
)
|
||||
|
||||
if (model != null && loadProfilePicture) {
|
||||
@@ -90,12 +94,12 @@ fun RobohashFallbackAsyncImage(
|
||||
}
|
||||
|
||||
if (isBase64) {
|
||||
val painter = rememberAsyncImagePainter(
|
||||
val base64Painter = rememberAsyncImagePainter(
|
||||
model = Base64Requester.imageRequest(context, model)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painter,
|
||||
painter = base64Painter,
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
@@ -129,33 +133,6 @@ fun RobohashFallbackAsyncImage(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RobohashAsyncImageProxy(
|
||||
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,
|
||||
loadProfilePicture: Boolean
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = robot,
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality,
|
||||
loadProfilePicture = loadProfilePicture
|
||||
)
|
||||
}
|
||||
|
||||
object Base64Requester {
|
||||
fun imageRequest(context: Context, message: String): ImageRequest {
|
||||
return ImageRequest
|
||||
|
||||
@@ -25,6 +25,7 @@ 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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
@@ -43,6 +44,30 @@ fun TextSpinner(
|
||||
options: ImmutableList<TitleExplainer>,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TextSpinner(
|
||||
placeholder,
|
||||
options,
|
||||
onSelect,
|
||||
modifier
|
||||
) { currentOption, modifier ->
|
||||
OutlinedTextField(
|
||||
value = currentOption,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { label?.let { Text(it) } },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextSpinner(
|
||||
placeholder: String,
|
||||
options: ImmutableList<TitleExplainer>,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
mainElement: @Composable (currentOption: String, modifier: Modifier) -> Unit
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
@@ -50,16 +75,16 @@ fun TextSpinner(
|
||||
var currentText by remember { mutableStateOf(placeholder) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = currentText,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { label?.let { Text(it) } },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
mainElement(
|
||||
currentText,
|
||||
remember {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
}
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -17,10 +17,12 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -35,6 +37,7 @@ import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -44,6 +47,8 @@ import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
@@ -67,6 +72,7 @@ import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.linc.audiowaveform.infiniteLinearGradient
|
||||
import com.vitorpamplona.amethyst.service.playback.PlaybackClientController
|
||||
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.MuteIcon
|
||||
@@ -76,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size0dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size75dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -160,6 +167,8 @@ fun VideoView(
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
@@ -168,22 +177,86 @@ fun VideoView(
|
||||
) {
|
||||
val defaultToStart by remember(videoUri) { mutableStateOf(DefaultMutedSetting.value) }
|
||||
|
||||
VideoViewInner(
|
||||
videoUri = videoUri,
|
||||
defaultToStart = defaultToStart,
|
||||
title = title,
|
||||
thumb = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
waveform = waveform,
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
alwaysShowVideo = alwaysShowVideo,
|
||||
accountViewModel = accountViewModel,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog
|
||||
)
|
||||
val automaticallyStartPlayback = remember {
|
||||
mutableStateOf<Boolean>(
|
||||
if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value
|
||||
)
|
||||
}
|
||||
|
||||
if (blurhash == null) {
|
||||
val ratio = aspectRatio(dimensions)
|
||||
val modifier = if (ratio != null && roundedCorner && automaticallyStartPlayback.value) {
|
||||
Modifier.aspectRatio(ratio)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(modifier) {
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
|
||||
} else {
|
||||
VideoViewInner(
|
||||
videoUri = videoUri,
|
||||
defaultToStart = defaultToStart,
|
||||
title = title,
|
||||
thumb = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
waveform = waveform,
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val ratio = aspectRatio(dimensions)
|
||||
val modifier = if (ratio != null && roundedCorner) {
|
||||
Modifier.aspectRatio(ratio)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
DisplayBlurHash(
|
||||
blurhash,
|
||||
null,
|
||||
ContentScale.Crop,
|
||||
MaterialTheme.colorScheme.imageModifier
|
||||
)
|
||||
IconButton(
|
||||
modifier = Modifier.size(Size75dp),
|
||||
onClick = { automaticallyStartPlayback.value = true }
|
||||
) {
|
||||
DownloadForOfflineIcon(Size75dp, Color.White)
|
||||
}
|
||||
} else {
|
||||
VideoViewInner(
|
||||
videoUri = videoUri,
|
||||
defaultToStart = defaultToStart,
|
||||
title = title,
|
||||
thumb = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
waveform = waveform,
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -198,48 +271,15 @@ fun VideoViewInner(
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
alwaysShowVideo: Boolean = false,
|
||||
accountViewModel: AccountViewModel,
|
||||
automaticallyStartPlayback: State<Boolean>,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
val automaticallyStartPlayback = remember {
|
||||
mutableStateOf<Boolean>(
|
||||
if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value
|
||||
)
|
||||
}
|
||||
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
|
||||
} else {
|
||||
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
|
||||
val mediaItem = remember(videoUri) {
|
||||
mutableStateOf(
|
||||
MediaItem.Builder()
|
||||
.setMediaId(videoUri)
|
||||
.setUri(videoUri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setArtist(authorName?.ifBlank { null })
|
||||
.setTitle(title?.ifBlank { null } ?: videoUri)
|
||||
.setArtworkUri(
|
||||
try {
|
||||
if (artworkUri != null) {
|
||||
Uri.parse(artworkUri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
|
||||
GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem ->
|
||||
GetVideoController(
|
||||
mediaItem = mediaItem,
|
||||
videoUri = videoUri,
|
||||
@@ -250,6 +290,8 @@ fun VideoViewInner(
|
||||
controller = controller,
|
||||
thumbData = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
waveform = waveform,
|
||||
keepPlaying = keepPlaying,
|
||||
@@ -264,6 +306,49 @@ fun VideoViewInner(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GetMediaItem(
|
||||
videoUri: String,
|
||||
title: String?,
|
||||
artworkUri: String?,
|
||||
authorName: String?,
|
||||
inner: @Composable (State<MediaItem>) -> Unit
|
||||
) {
|
||||
val mediaItem = produceState<MediaItem?>(
|
||||
initialValue = null,
|
||||
key1 = videoUri
|
||||
) {
|
||||
this.value = MediaItem.Builder()
|
||||
.setMediaId(videoUri)
|
||||
.setUri(videoUri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setArtist(authorName?.ifBlank { null })
|
||||
.setTitle(title?.ifBlank { null } ?: videoUri)
|
||||
.setArtworkUri(
|
||||
try {
|
||||
if (artworkUri != null) {
|
||||
Uri.parse(artworkUri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
mediaItem.value?.let {
|
||||
val myState = remember(videoUri) {
|
||||
mutableStateOf(it)
|
||||
}
|
||||
inner(myState)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed class MediaControllerState {
|
||||
@Immutable
|
||||
@@ -279,7 +364,7 @@ sealed class MediaControllerState {
|
||||
@Composable
|
||||
@OptIn(UnstableApi::class)
|
||||
fun GetVideoController(
|
||||
mediaItem: MutableState<MediaItem>,
|
||||
mediaItem: State<MediaItem>,
|
||||
videoUri: String,
|
||||
defaultToStart: Boolean = false,
|
||||
nostrUriCallback: String? = null,
|
||||
@@ -534,6 +619,8 @@ private fun RenderVideoPlayer(
|
||||
controller: MediaController,
|
||||
thumbData: VideoThumb?,
|
||||
roundedCorner: Boolean,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
keepPlaying: MutableState<Boolean>,
|
||||
@@ -586,6 +673,8 @@ private fun RenderVideoPlayer(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
setBackgroundColor(Color.Transparent.toArgb())
|
||||
setShutterBackgroundColor(Color.Transparent.toArgb())
|
||||
controllerAutoShow = false
|
||||
thumbData?.thumb?.let { defaultArtwork = it }
|
||||
hideController()
|
||||
@@ -609,6 +698,19 @@ private fun RenderVideoPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
val ratio = remember {
|
||||
aspectRatio(dimensions)
|
||||
}
|
||||
|
||||
if (ratio != null) {
|
||||
DisplayBlurHash(
|
||||
blurhash,
|
||||
null,
|
||||
ContentScale.Crop,
|
||||
myModifier.aspectRatio(ratio)
|
||||
)
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = myModifier,
|
||||
factory = factory
|
||||
|
||||
@@ -22,12 +22,15 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
@@ -56,6 +59,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
@@ -77,6 +81,7 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.ViewCompat
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
@@ -99,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size24dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size75dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
@@ -145,7 +151,8 @@ class ZoomableUrlVideo(
|
||||
dim: String? = null,
|
||||
uri: String? = null,
|
||||
val artworkUri: String? = null,
|
||||
val authorName: String? = null
|
||||
val authorName: String? = null,
|
||||
val blurhash: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
@Immutable
|
||||
@@ -182,7 +189,7 @@ class ZoomableLocalVideo(
|
||||
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
|
||||
|
||||
fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
|
||||
@@ -246,6 +253,8 @@ fun ZoomableContentView(
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
dimensions = content.dim,
|
||||
blurhash = content.blurhash,
|
||||
roundedCorner = roundedCorner,
|
||||
nostrUriCallback = content.uri,
|
||||
onDialog = { dialogOpen = true },
|
||||
@@ -481,13 +490,38 @@ private fun AddedImageFeatures(
|
||||
verifiedModifier: Modifier,
|
||||
showImage: MutableState<Boolean>
|
||||
) {
|
||||
val ratio = remember {
|
||||
aspectRatio(content.dim)
|
||||
}
|
||||
|
||||
if (!showImage.value) {
|
||||
ImageUrlWithDownloadButton(content.uri, showImage)
|
||||
if (content.blurhash != null && ratio != null) {
|
||||
DisplayBlurHash(
|
||||
content.blurhash,
|
||||
content.description,
|
||||
ContentScale.Crop,
|
||||
myModifier.aspectRatio(ratio).clickable {
|
||||
showImage.value = true
|
||||
}
|
||||
)
|
||||
IconButton(
|
||||
modifier = Modifier.size(Size75dp),
|
||||
onClick = { showImage.value = true }
|
||||
) {
|
||||
DownloadForOfflineIcon(Size75dp, Color.White)
|
||||
}
|
||||
} else {
|
||||
ImageUrlWithDownloadButton(content.uri, showImage)
|
||||
}
|
||||
} else {
|
||||
when (painter.value) {
|
||||
null, is AsyncImagePainter.State.Loading -> {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
if (ratio != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, ContentScale.Crop, myModifier.aspectRatio(ratio))
|
||||
} else {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
}
|
||||
} else {
|
||||
FlowRow() {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
@@ -521,8 +555,29 @@ private fun AddedImageFeatures(
|
||||
verifiedModifier: Modifier,
|
||||
showImage: MutableState<Boolean>
|
||||
) {
|
||||
val ratio = remember {
|
||||
aspectRatio(content.dim)
|
||||
}
|
||||
|
||||
if (!showImage.value) {
|
||||
ImageUrlWithDownloadButton(content.url, showImage)
|
||||
if (content.blurhash != null && ratio != null) {
|
||||
DisplayBlurHash(
|
||||
content.blurhash,
|
||||
content.description,
|
||||
ContentScale.Crop,
|
||||
myModifier.aspectRatio(ratio).clickable {
|
||||
showImage.value = true
|
||||
}
|
||||
)
|
||||
IconButton(
|
||||
modifier = Modifier.size(Size75dp),
|
||||
onClick = { showImage.value = true }
|
||||
) {
|
||||
DownloadForOfflineIcon(Size75dp, Color.White)
|
||||
}
|
||||
} else {
|
||||
ImageUrlWithDownloadButton(content.url, showImage)
|
||||
}
|
||||
} else {
|
||||
var verifiedHash by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
@@ -531,7 +586,11 @@ private fun AddedImageFeatures(
|
||||
when (painter.value) {
|
||||
null, is AsyncImagePainter.State.Loading -> {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
if (ratio != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, ContentScale.Crop, myModifier.aspectRatio(ratio))
|
||||
} else {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
}
|
||||
} else {
|
||||
FlowRow(Modifier.fillMaxWidth()) {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
@@ -569,8 +628,9 @@ private fun AddedImageFeatures(
|
||||
}
|
||||
}
|
||||
|
||||
private fun aspectRatio(dim: String?): Float? {
|
||||
fun aspectRatio(dim: String?): Float? {
|
||||
if (dim == null) return null
|
||||
if (dim == "0x0") return null
|
||||
|
||||
val parts = dim.split("x")
|
||||
if (parts.size != 2) return null
|
||||
@@ -578,7 +638,12 @@ private fun aspectRatio(dim: String?): Float? {
|
||||
return try {
|
||||
val width = parts[0].toFloat()
|
||||
val height = parts[1].toFloat()
|
||||
width / height
|
||||
|
||||
if (width < 0.1 || height < 0.1) {
|
||||
null
|
||||
} else {
|
||||
width / height
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -664,7 +729,7 @@ private fun InlineLoadingIcon() =
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayBlurHash(
|
||||
fun DisplayBlurHash(
|
||||
blurhash: String?,
|
||||
description: String?,
|
||||
contentScale: ContentScale,
|
||||
@@ -694,7 +759,6 @@ fun ZoomableImageDialog(
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
println("This Log only exists to force orientation listener $orientation")
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -704,6 +768,8 @@ fun ZoomableImageDialog(
|
||||
)
|
||||
) {
|
||||
val view = LocalView.current
|
||||
val insets = ViewCompat.getRootWindowInsets(view)
|
||||
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
println("This Log only exists to force orientation listener $orientation")
|
||||
|
||||
@@ -810,6 +876,7 @@ private fun DialogContent(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.statusBarsPadding().systemBarsPadding()
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -947,6 +1014,10 @@ private fun RenderImageOrVideo(
|
||||
onToggleControllerVisibility: (() -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val automaticallyStartPlayback = remember {
|
||||
mutableStateOf<Boolean>(true)
|
||||
}
|
||||
|
||||
if (content is ZoomableUrlImage) {
|
||||
val mainModifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -968,16 +1039,15 @@ private fun RenderImageOrVideo(
|
||||
)
|
||||
} else if (content is ZoomableUrlVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(
|
||||
VideoViewInner(
|
||||
videoUri = content.url,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
accountViewModel = accountViewModel,
|
||||
alwaysShowVideo = true
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged
|
||||
)
|
||||
}
|
||||
} else if (content is ZoomableLocalImage) {
|
||||
@@ -1002,16 +1072,15 @@ private fun RenderImageOrVideo(
|
||||
} else if (content is ZoomableLocalVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
content.localFile?.let {
|
||||
VideoView(
|
||||
VideoViewInner(
|
||||
videoUri = it.toUri().toString(),
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
accountViewModel = accountViewModel,
|
||||
alwaysShowVideo = true
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFe
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return collection
|
||||
.filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
|
||||
.filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
|
||||
+49
-59
@@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
@@ -20,19 +19,15 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
val me = account.userProfile()
|
||||
val followingKeySet = account.followingKeySet()
|
||||
|
||||
val privateChatrooms = me.privateChatrooms
|
||||
val messagingWith = privateChatrooms.keys.filter {
|
||||
(
|
||||
privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
|
||||
me.hasSentMessagesTo(it)
|
||||
) && !account.isAllHidden(it.users)
|
||||
val knownChatrooms = me.privateChatrooms.filter {
|
||||
(it.value.senderIntersects(followingKeySet) || me.hasSentMessagesTo(it.key)) && !account.isAllHidden(it.key.users)
|
||||
}
|
||||
|
||||
val privateMessages = messagingWith.mapNotNull { it ->
|
||||
privateChatrooms[it]
|
||||
?.roomMessages
|
||||
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
?.lastOrNull { it.event != null }
|
||||
val privateMessages = knownChatrooms.mapNotNull { it ->
|
||||
it.value
|
||||
.roomMessages
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
val publicChannels = account.selectedChatsFollowList().mapNotNull {
|
||||
@@ -50,58 +45,53 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
}
|
||||
|
||||
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
|
||||
val (feed, elapsed) = measureTimedValue {
|
||||
val me = account.userProfile()
|
||||
val me = account.userProfile()
|
||||
|
||||
// Gets the latest message by channel from the new items.
|
||||
val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account)
|
||||
// Gets the latest message by channel from the new items.
|
||||
val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account)
|
||||
|
||||
// Gets the latest message by room from the new items.
|
||||
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
|
||||
// Gets the latest message by room from the new items.
|
||||
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
|
||||
|
||||
if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) {
|
||||
return oldList
|
||||
}
|
||||
|
||||
var myNewList = oldList
|
||||
|
||||
newRelevantPublicMessages.forEach { newNotePair ->
|
||||
var hasUpdated = false
|
||||
oldList.forEach { oldNote ->
|
||||
if (newNotePair.key == oldNote.channelHex()) {
|
||||
hasUpdated = true
|
||||
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
|
||||
myNewList = myNewList.updated(oldNote, newNotePair.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUpdated) {
|
||||
myNewList = myNewList.plus(newNotePair.value)
|
||||
}
|
||||
}
|
||||
|
||||
newRelevantPrivateMessages.forEach { newNotePair ->
|
||||
var hasUpdated = false
|
||||
oldList.forEach { oldNote ->
|
||||
val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex)
|
||||
|
||||
if (newNotePair.key == oldRoom) {
|
||||
hasUpdated = true
|
||||
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
|
||||
myNewList = myNewList.updated(oldNote, newNotePair.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUpdated) {
|
||||
myNewList = myNewList.plus(newNotePair.value)
|
||||
}
|
||||
}
|
||||
|
||||
sort(myNewList.toSet()).take(1000)
|
||||
if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) {
|
||||
return oldList
|
||||
}
|
||||
|
||||
// Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
|
||||
return feed
|
||||
var myNewList = oldList
|
||||
|
||||
newRelevantPublicMessages.forEach { newNotePair ->
|
||||
var hasUpdated = false
|
||||
oldList.forEach { oldNote ->
|
||||
if (newNotePair.key == oldNote.channelHex()) {
|
||||
hasUpdated = true
|
||||
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
|
||||
myNewList = myNewList.updated(oldNote, newNotePair.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUpdated) {
|
||||
myNewList = myNewList.plus(newNotePair.value)
|
||||
}
|
||||
}
|
||||
|
||||
newRelevantPrivateMessages.forEach { newNotePair ->
|
||||
var hasUpdated = false
|
||||
oldList.forEach { oldNote ->
|
||||
val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex)
|
||||
|
||||
if (newNotePair.key == oldRoom) {
|
||||
hasUpdated = true
|
||||
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
|
||||
myNewList = myNewList.updated(oldNote, newNotePair.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUpdated) {
|
||||
myNewList = myNewList.plus(newNotePair.value)
|
||||
}
|
||||
}
|
||||
|
||||
return sort(myNewList.toSet()).take(1000)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> {
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
@@ -19,17 +18,15 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
|
||||
val me = account.userProfile()
|
||||
val followingKeySet = account.followingKeySet()
|
||||
|
||||
val privateChatrooms = account.userProfile().privateChatrooms
|
||||
val messagingWith = privateChatrooms.keys.filter {
|
||||
privateChatrooms[it]?.senderIntersects(followingKeySet) == false &&
|
||||
!me.hasSentMessagesTo(it) && !account.isAllHidden(it.users)
|
||||
val newChatrooms = me.privateChatrooms.filter {
|
||||
!it.value.senderIntersects(followingKeySet) && !me.hasSentMessagesTo(it.key) && !account.isAllHidden(it.key.users)
|
||||
}
|
||||
|
||||
val privateMessages = messagingWith.mapNotNull { it ->
|
||||
privateChatrooms[it]
|
||||
?.roomMessages
|
||||
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
?.lastOrNull { it.event != null }
|
||||
val privateMessages = newChatrooms.mapNotNull { it ->
|
||||
it.value
|
||||
.roomMessages
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
return privateMessages
|
||||
@@ -38,40 +35,35 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
|
||||
}
|
||||
|
||||
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
|
||||
val (feed, elapsed) = measureTimedValue {
|
||||
val me = account.userProfile()
|
||||
val me = account.userProfile()
|
||||
|
||||
// Gets the latest message by room from the new items.
|
||||
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
|
||||
// Gets the latest message by room from the new items.
|
||||
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
|
||||
|
||||
if (newRelevantPrivateMessages.isEmpty()) {
|
||||
return oldList
|
||||
}
|
||||
|
||||
var myNewList = oldList
|
||||
|
||||
newRelevantPrivateMessages.forEach { newNotePair ->
|
||||
var hasUpdated = false
|
||||
oldList.forEach { oldNote ->
|
||||
val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex)
|
||||
|
||||
if (newNotePair.key == oldRoom) {
|
||||
hasUpdated = true
|
||||
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
|
||||
myNewList = myNewList.updated(oldNote, newNotePair.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUpdated) {
|
||||
myNewList = myNewList.plus(newNotePair.value)
|
||||
}
|
||||
}
|
||||
|
||||
sort(myNewList.toSet()).take(1000)
|
||||
if (newRelevantPrivateMessages.isEmpty()) {
|
||||
return oldList
|
||||
}
|
||||
|
||||
// Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
|
||||
return feed
|
||||
var myNewList = oldList
|
||||
|
||||
newRelevantPrivateMessages.forEach { newNotePair ->
|
||||
var hasUpdated = false
|
||||
oldList.forEach { oldNote ->
|
||||
val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex)
|
||||
|
||||
if (newNotePair.key == oldRoom) {
|
||||
hasUpdated = true
|
||||
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
|
||||
myNewList = myNewList.updated(oldNote, newNotePair.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUpdated) {
|
||||
myNewList = myNewList.plus(newNotePair.value)
|
||||
}
|
||||
}
|
||||
|
||||
return sort(myNewList.toSet()).take(1000)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
open class DiscoverMarketplaceFeedFilter(
|
||||
val account: Account
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + followList()
|
||||
}
|
||||
|
||||
open fun followList(): String {
|
||||
return account.defaultDiscoveryFollowList.value
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val classifieds = LocalCache.addressables
|
||||
.filter { it.value.event is ClassifiedsEvent }
|
||||
.map { it.value }
|
||||
|
||||
val notes = innerApplyFilter(classifieds)
|
||||
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val now = TimeUtils.now()
|
||||
val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS
|
||||
val isHiddenList = showHiddenKey()
|
||||
|
||||
val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet()
|
||||
val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet()
|
||||
val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet()
|
||||
|
||||
val activities = collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.event is ClassifiedsEvent &&
|
||||
it.event?.hasTagWithContent("image") == true &&
|
||||
it.event?.hasTagWithContent("price") == true &&
|
||||
it.event?.hasTagWithContent("title") == true
|
||||
}
|
||||
.filter {
|
||||
isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true || it.event?.isTaggedGeoHashes(followingGeohashSet) == true
|
||||
}
|
||||
.filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
|
||||
.filter { (it.createdAt() ?: 0) <= now }
|
||||
.toSet()
|
||||
|
||||
return activities
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
.asSequence()
|
||||
.filter { it ->
|
||||
val noteEvent = it.event
|
||||
val isGlobalRelay = it.relays.any { gRelays.contains(it) }
|
||||
val isGlobalRelay = it.relays.any { gRelays.contains(it.url) }
|
||||
(noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent || noteEvent is AudioHeaderEvent) &&
|
||||
(!ignoreAddressables || noteEvent.kind() < 10000) &&
|
||||
((isGlobal && isGlobalRelay) || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet) || noteEvent.isTaggedGeoHashes(followingGeohashSet) || noteEvent.isTaggedAddressableNotes(followingCommunities)) &&
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.vitorpamplona.amethyst.ui.elements
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparison
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun AddButtonPreview() {
|
||||
ThemeComparison(
|
||||
onDark = {
|
||||
Row() {
|
||||
Column {
|
||||
AddButton(isActive = true) {}
|
||||
AddButton(isActive = false) {}
|
||||
}
|
||||
|
||||
Column {
|
||||
RemoveButton(isActive = true) {}
|
||||
RemoveButton(isActive = false) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
onLight = {
|
||||
Row() {
|
||||
Column {
|
||||
AddButton(isActive = true) {}
|
||||
AddButton(isActive = false) {}
|
||||
}
|
||||
|
||||
Column {
|
||||
RemoveButton(isActive = true) {}
|
||||
RemoveButton(isActive = false) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddButton(
|
||||
text: Int = R.string.add,
|
||||
isActive: Boolean = true,
|
||||
modifier: Modifier = Modifier.padding(start = 3.dp),
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Button(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (isActive) {
|
||||
onClick()
|
||||
}
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
enabled = isActive,
|
||||
contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RemoveButton(
|
||||
isActive: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.padding(start = 3.dp),
|
||||
onClick = {
|
||||
if (isActive) {
|
||||
onClick()
|
||||
}
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
enabled = isActive,
|
||||
contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.remove), color = Color.White)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.vitorpamplona.amethyst.ui.elements
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
|
||||
@Composable
|
||||
fun DisplayFollowingCommunityInPost(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Column(HalfStartPadding) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DisplayCommunity(baseNote, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayCommunity(note: Note, nav: (String) -> Unit) {
|
||||
val communityTag = remember(note) {
|
||||
note.event?.getTagOfAddressableKind(CommunityDefinitionEvent.kind)
|
||||
} ?: return
|
||||
|
||||
val displayTag = remember(note) { AnnotatedString(getCommunityShortName(communityTag)) }
|
||||
val route = remember(note) { "Community/${communityTag.toTag()}" }
|
||||
|
||||
ClickableText(
|
||||
text = displayTag,
|
||||
onClick = { nav(route) },
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colorScheme.primary.copy(
|
||||
alpha = 0.52f
|
||||
)
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
private fun getCommunityShortName(communityTag: ATag): String {
|
||||
val name = if (communityTag.dTag.length > 10) {
|
||||
communityTag.dTag.take(10) + "..."
|
||||
} else {
|
||||
communityTag.dTag.take(10)
|
||||
}
|
||||
|
||||
return "/n/$name"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.vitorpamplona.amethyst.ui.elements
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.text.AnnotatedString
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun DisplayFollowingHashtagsInPost(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val noteEvent = remember { baseNote.event } ?: return
|
||||
|
||||
val userFollowState by accountViewModel.userFollows.observeAsState()
|
||||
var firstTag by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = userFollowState) {
|
||||
launch(Dispatchers.Default) {
|
||||
val followingTags = userFollowState?.user?.cachedFollowingTagSet() ?: emptySet()
|
||||
val newFirstTag = noteEvent.firstIsTaggedHashes(followingTags)
|
||||
|
||||
if (firstTag != newFirstTag) {
|
||||
launch(Dispatchers.Main) {
|
||||
firstTag = newFirstTag
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
firstTag?.let {
|
||||
Column(verticalArrangement = Arrangement.Center) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DisplayTagList(it, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayTagList(firstTag: String, nav: (String) -> Unit) {
|
||||
val displayTag = remember(firstTag) { AnnotatedString(" #$firstTag") }
|
||||
val route = remember(firstTag) { "Hashtag/$firstTag" }
|
||||
|
||||
ClickableText(
|
||||
text = displayTag,
|
||||
onClick = { nav(route) },
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colorScheme.primary.copy(
|
||||
alpha = 0.52f
|
||||
)
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.vitorpamplona.amethyst.ui.elements
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparison
|
||||
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun DisplayPoWPreview() {
|
||||
ThemeComparison(
|
||||
onDark = {
|
||||
DisplayPoW(pow = 24)
|
||||
},
|
||||
onLight = {
|
||||
DisplayPoW(pow = 24)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayPoW(
|
||||
pow: Int
|
||||
) {
|
||||
val powStr = remember(pow) {
|
||||
"PoW-$pow"
|
||||
}
|
||||
|
||||
Text(
|
||||
powStr,
|
||||
color = MaterialTheme.colorScheme.lessImportantLink,
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
+101
-1
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
package com.vitorpamplona.amethyst.ui.elements
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
@@ -9,19 +10,26 @@ 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.text.ClickableText
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
@@ -35,10 +43,102 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.ZappedIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Stable
|
||||
data class Reward(val amount: BigDecimal)
|
||||
|
||||
@Composable
|
||||
fun DisplayReward(
|
||||
baseReward: Reward,
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Column() {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { popupExpanded = true }
|
||||
) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("#bounty"),
|
||||
onClick = { nav("Hashtag/bounty") },
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colorScheme.primary.copy(
|
||||
alpha = 0.52f
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
RenderPledgeAmount(baseNote, baseReward, accountViewModel)
|
||||
}
|
||||
|
||||
if (popupExpanded) {
|
||||
AddBountyAmountDialog(baseNote, accountViewModel) {
|
||||
popupExpanded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderPledgeAmount(
|
||||
baseNote: Note,
|
||||
baseReward: Reward,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val repliesState by baseNote.live().replies.observeAsState()
|
||||
var reward by remember {
|
||||
mutableStateOf<String>(
|
||||
showAmount(baseReward.amount)
|
||||
)
|
||||
}
|
||||
|
||||
var hasPledge by remember {
|
||||
mutableStateOf<Boolean>(
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = repliesState) {
|
||||
launch(Dispatchers.IO) {
|
||||
repliesState?.note?.pledgedAmountByOthers()?.let {
|
||||
val newRewardAmount = showAmount(baseReward.amount.add(it))
|
||||
if (newRewardAmount != reward) {
|
||||
reward = newRewardAmount
|
||||
}
|
||||
}
|
||||
val newHasPledge = repliesState?.note?.hasPledgeBy(accountViewModel.userProfile()) == true
|
||||
if (hasPledge != newHasPledge) {
|
||||
launch(Dispatchers.Main) {
|
||||
hasPledge = newHasPledge
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPledge) {
|
||||
ZappedIcon(modifier = Size20Modifier)
|
||||
} else {
|
||||
ZapIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = reward,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
class AddBountyAmountViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.vitorpamplona.amethyst.ui.elements
|
||||
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DisplayUncitedHashtags(
|
||||
hashtags: ImmutableList<String>,
|
||||
eventContent: String,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val unusedHashtags = remember(eventContent) {
|
||||
hashtags.filter { !eventContent.contains(it, true) }
|
||||
}
|
||||
|
||||
if (unusedHashtags.isNotEmpty()) {
|
||||
FlowRow(
|
||||
modifier = HalfTopPadding
|
||||
) {
|
||||
unusedHashtags.forEach { hashtag ->
|
||||
ClickableText(
|
||||
text = remember { AnnotatedString("#$hashtag ") },
|
||||
onClick = { nav("Hashtag/$hashtag") },
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colorScheme.lessImportantLink
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.vitorpamplona.amethyst.ui.elements
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DisplayZapSplits(noteEvent: EventInterface, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val list = remember(noteEvent) { noteEvent.zapSplitSetup() }
|
||||
if (list.isEmpty()) return
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(25.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ArrowForwardIos,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier
|
||||
.size(13.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
FlowRow {
|
||||
list.forEach {
|
||||
if (it.isLnAddress) {
|
||||
ClickableText(
|
||||
text = AnnotatedString(it.lnAddressOrPubKeyHex),
|
||||
onClick = { },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
} else {
|
||||
UserPicture(
|
||||
userHex = it.lnAddressOrPubKeyHex,
|
||||
size = Size25dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.vitorpamplona.amethyst.ui.layouts
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.note.NewItemsBubble
|
||||
import com.vitorpamplona.amethyst.ui.note.TimeAgo
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatHeadlineBorders
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun ChannelNamePreview() {
|
||||
Column {
|
||||
ChatHeaderLayout(
|
||||
channelPicture = {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.github),
|
||||
contentDescription = stringResource(id = R.string.profile_banner),
|
||||
contentScale = ContentScale.FillWidth
|
||||
)
|
||||
},
|
||||
firstRow = {
|
||||
Text("This is my author", Modifier.weight(1f))
|
||||
TimeAgo(TimeUtils.now())
|
||||
},
|
||||
secondRow = {
|
||||
Text("This is a message from this person", Modifier.weight(1f))
|
||||
NewItemsBubble()
|
||||
},
|
||||
onClick = {
|
||||
}
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("This is my author", Modifier.weight(1f))
|
||||
TimeAgo(TimeUtils.now())
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("This is a message from this person", Modifier.weight(1f))
|
||||
NewItemsBubble()
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.github),
|
||||
contentDescription = stringResource(id = R.string.profile_banner),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Size55Modifier
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatHeaderLayout(
|
||||
channelPicture: @Composable () -> Unit,
|
||||
firstRow: @Composable RowScope.() -> Unit,
|
||||
secondRow: @Composable RowScope.() -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(modifier = remember { Modifier.clickable(onClick = onClick) }) {
|
||||
Row(
|
||||
modifier = ChatHeadlineBorders,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(Size55Modifier) {
|
||||
channelPicture()
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
firstRow()
|
||||
}
|
||||
|
||||
Spacer(modifier = Height4dpModifier)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
secondRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = StdTopPadding,
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.vitorpamplona.amethyst.ui.layouts
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.note.RepostedIcon
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
private fun GenericRepostSectionPreview() {
|
||||
GenericRepostLayout(
|
||||
baseAuthorPicture = {
|
||||
Text("ab")
|
||||
},
|
||||
repostAuthorPicture = {
|
||||
Text("cd")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GenericRepostLayout(
|
||||
baseAuthorPicture: @Composable () -> Unit,
|
||||
repostAuthorPicture: @Composable () -> Unit
|
||||
) {
|
||||
Box(modifier = Size55Modifier) {
|
||||
Box(remember { Size35Modifier.align(Alignment.TopStart) }) {
|
||||
baseAuthorPicture()
|
||||
}
|
||||
|
||||
Box(
|
||||
remember {
|
||||
Size18Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(1.dp)
|
||||
}
|
||||
) {
|
||||
RepostedIcon(modifier = Size18Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
|
||||
Box(
|
||||
remember { Size35Modifier.align(Alignment.BottomEnd) },
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
repostAuthorPicture()
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -48,7 +48,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
@@ -220,8 +220,8 @@ private fun ActiveMarker(acc: AccountInfo, accountViewModel: AccountViewModel) {
|
||||
private fun AccountPicture(user: User, loadProfilePicture: Boolean) {
|
||||
val profilePicture by user.live().profilePictureChanges.observeAsState()
|
||||
|
||||
RobohashAsyncImageProxy(
|
||||
robot = remember(user) { user.pubkeyHex },
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = user.pubkeyHex,
|
||||
model = profilePicture,
|
||||
contentDescription = stringResource(R.string.profile_image),
|
||||
modifier = AccountPictureModifier,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -29,7 +30,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -53,23 +53,30 @@ enum class Keyboard {
|
||||
Opened, Closed
|
||||
}
|
||||
|
||||
fun isKeyboardOpen(view: View): Keyboard {
|
||||
val rect = Rect()
|
||||
view.getWindowVisibleDisplayFrame(rect)
|
||||
val screenHeight = view.rootView.height
|
||||
val keypadHeight = screenHeight - rect.bottom
|
||||
|
||||
return if (keypadHeight > screenHeight * 0.15) {
|
||||
Keyboard.Opened
|
||||
} else {
|
||||
Keyboard.Closed
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun keyboardAsState(): State<Keyboard> {
|
||||
val keyboardState = remember { mutableStateOf(Keyboard.Closed) }
|
||||
val view = LocalView.current
|
||||
|
||||
val keyboardState = remember(view) {
|
||||
mutableStateOf(isKeyboardOpen(view))
|
||||
}
|
||||
|
||||
DisposableEffect(view) {
|
||||
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
|
||||
val rect = Rect()
|
||||
view.getWindowVisibleDisplayFrame(rect)
|
||||
val screenHeight = view.rootView.height
|
||||
val keypadHeight = screenHeight - rect.bottom
|
||||
|
||||
val newKeyboardValue = if (keypadHeight > screenHeight * 0.15) {
|
||||
Keyboard.Opened
|
||||
} else {
|
||||
Keyboard.Closed
|
||||
}
|
||||
val newKeyboardValue = isKeyboardOpen(view)
|
||||
|
||||
if (newKeyboardValue != keyboardState.value) {
|
||||
keyboardState.value = newKeyboardValue
|
||||
@@ -89,8 +96,8 @@ fun keyboardAsState(): State<Keyboard> {
|
||||
fun IfKeyboardClosed(
|
||||
inner: @Composable () -> Unit
|
||||
) {
|
||||
val isKeyboardOpen by keyboardAsState()
|
||||
if (isKeyboardOpen == Keyboard.Closed) {
|
||||
val isKeyboardState by keyboardAsState()
|
||||
if (isKeyboardState == Keyboard.Closed) {
|
||||
inner()
|
||||
}
|
||||
}
|
||||
@@ -135,49 +142,45 @@ private fun RowScope.HasNewItemsIcon(
|
||||
|
||||
NavigationBarItem(
|
||||
icon = {
|
||||
ObserveNewItems(route, accountViewModel) { hasNewItems ->
|
||||
NotifiableIcon(
|
||||
route.icon,
|
||||
route.notifSize,
|
||||
route.iconSize,
|
||||
selected,
|
||||
hasNewItems
|
||||
)
|
||||
}
|
||||
NotifiableIcon(
|
||||
selected,
|
||||
route,
|
||||
accountViewModel
|
||||
)
|
||||
},
|
||||
selected = selected,
|
||||
onClick = { nav(route, selected) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserveNewItems(route: Route, accountViewModel: AccountViewModel, inner: @Composable (hasNewItems: State<Boolean>?) -> Unit) {
|
||||
val hasNewItems = accountViewModel.notificationDots.hasNewItems[route]?.collectAsStateWithLifecycle()
|
||||
|
||||
inner(hasNewItems)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotifiableIcon(
|
||||
icon: Int,
|
||||
size: Dp,
|
||||
iconSize: Dp,
|
||||
selected: Boolean,
|
||||
hasNewItems: State<Boolean>?
|
||||
route: Route,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Box(remember { Modifier.size(size) }) {
|
||||
Box(route.notifSize) {
|
||||
Icon(
|
||||
painter = painterResource(id = icon),
|
||||
painter = painterResource(id = route.icon),
|
||||
contentDescription = null,
|
||||
modifier = remember { Modifier.size(iconSize) },
|
||||
modifier = route.iconSize,
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified
|
||||
)
|
||||
|
||||
if (hasNewItems?.value == true) {
|
||||
NotificationDotIcon(
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
AddNotifIconIfNeeded(route, accountViewModel, Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddNotifIconIfNeeded(
|
||||
route: Route,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier
|
||||
) {
|
||||
val flow = accountViewModel.notificationDots.hasNewItems[route] ?: return
|
||||
val hasNewItems by flow.collectAsStateWithLifecycle()
|
||||
if (hasNewItems) {
|
||||
NotificationDotIcon(modifier)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
|
||||
@@ -61,6 +62,7 @@ fun AppNavigation(
|
||||
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
|
||||
newFeedViewModel: NostrChatroomListNewFeedViewModel,
|
||||
videoFeedViewModel: NostrVideoFeedViewModel,
|
||||
discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel,
|
||||
discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel,
|
||||
@@ -137,6 +139,7 @@ fun AppNavigation(
|
||||
Route.Discover.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
DiscoverScreen(
|
||||
discoveryMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel,
|
||||
discoveryLiveFeedViewModel = discoveryLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel = discoveryChatFeedViewModel,
|
||||
|
||||
@@ -81,7 +81,7 @@ import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.note.AmethystIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
@@ -112,6 +112,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size34dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
@@ -526,7 +527,7 @@ private fun LoggedInUserPictureDrawer(
|
||||
IconButton(
|
||||
onClick = onClick
|
||||
) {
|
||||
RobohashAsyncImageProxy(
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = pubkeyHex,
|
||||
model = profilePicture,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
@@ -725,12 +726,12 @@ fun SimpleTextSpinner(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Modifier.size(20.dp))
|
||||
Spacer(modifier = Size20Modifier)
|
||||
Text(currentText)
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
}
|
||||
@@ -809,7 +810,7 @@ fun RenderOption(option: Name) {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
val name by option.note.live().metadata.map {
|
||||
"/n/" + (it.note as? AddressableNote)?.dTag()
|
||||
"/n/" + ((it.note as? AddressableNote)?.dTag() ?: "")
|
||||
}.observeAsState()
|
||||
|
||||
Text(text = name ?: "", color = MaterialTheme.colorScheme.onSurface)
|
||||
|
||||
@@ -67,19 +67,28 @@ import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPoolStatus
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.ShowQRDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font18SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.IconRowModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.IconRowTextModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.bannerModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.drawerSpacing
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -90,6 +99,15 @@ fun DrawerContent(
|
||||
openSheet: () -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val onClickUser = {
|
||||
nav("User/${accountViewModel.userProfile().pubkeyHex}")
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
@@ -100,27 +118,31 @@ fun DrawerContent(
|
||||
) {
|
||||
Column() {
|
||||
ProfileContent(
|
||||
accountViewModel.account.userProfile(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 25.dp)
|
||||
.padding(top = 70.dp),
|
||||
drawerState,
|
||||
baseAccountUser = accountViewModel.account.userProfile(),
|
||||
modifier = profileContentHeaderModifier,
|
||||
accountViewModel,
|
||||
nav
|
||||
onClickUser
|
||||
)
|
||||
|
||||
Column(drawerSpacing) {
|
||||
EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel)
|
||||
}
|
||||
|
||||
FollowingAndFollowerCounts(accountViewModel.account.userProfile(), onClickUser)
|
||||
|
||||
Divider(
|
||||
thickness = DividerThickness,
|
||||
modifier = Modifier.padding(top = 20.dp)
|
||||
)
|
||||
|
||||
ListContent(
|
||||
nav,
|
||||
drawerState,
|
||||
openSheet,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
accountViewModel
|
||||
drawerState,
|
||||
openSheet,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
|
||||
BottomContent(
|
||||
@@ -137,50 +159,53 @@ fun DrawerContent(
|
||||
fun ProfileContent(
|
||||
baseAccountUser: User,
|
||||
modifier: Modifier = Modifier,
|
||||
drawerState: DrawerState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
onClickUser: () -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val userInfo by baseAccountUser.live().userMetadataInfo.observeAsState()
|
||||
|
||||
val accountUserState by baseAccountUser.live().metadata.observeAsState()
|
||||
|
||||
val profilePubHex = remember(accountUserState) { accountUserState?.user?.pubkeyHex } ?: return
|
||||
|
||||
val profileBanner = remember(accountUserState) { accountUserState?.user?.info?.banner?.ifBlank { null } }
|
||||
val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture() }
|
||||
// val bestUserName = remember(accountUserState) { accountUserState?.user?.bestUsername() }
|
||||
val bestDisplayName = remember(accountUserState) { accountUserState?.user?.toBestDisplayName() }
|
||||
val tags = remember(accountUserState) { accountUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
|
||||
val route = remember(accountUserState) { "User/${accountUserState?.user?.pubkeyHex}" }
|
||||
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
ProfileContentTemplate(
|
||||
profilePubHex = baseAccountUser.pubkeyHex,
|
||||
profileBanner = userInfo?.banner,
|
||||
profilePicture = userInfo?.profilePicture(),
|
||||
bestDisplayName = userInfo?.bestDisplayName(),
|
||||
tags = userInfo?.tags,
|
||||
modifier = modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = onClickUser
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileContentTemplate(
|
||||
profilePubHex: HexKey,
|
||||
profileBanner: String?,
|
||||
profilePicture: String?,
|
||||
bestDisplayName: String?,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box {
|
||||
if (profileBanner != null) {
|
||||
AsyncImage(
|
||||
model = profileBanner,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
modifier = bannerModifier
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.profile_banner),
|
||||
contentDescription = stringResource(R.string.profile_banner),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
modifier = bannerModifier
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
RobohashAsyncImageProxy(
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = profilePubHex,
|
||||
model = profilePicture,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
@@ -190,13 +215,8 @@ fun ProfileContent(
|
||||
.clip(shape = CircleShape)
|
||||
.border(3.dp, MaterialTheme.colorScheme.background, CircleShape)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.clickable(onClick = {
|
||||
nav(route)
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
}),
|
||||
loadProfilePicture = automaticallyShowProfilePicture
|
||||
.clickable(onClick = onClick),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures.value
|
||||
)
|
||||
|
||||
if (bestDisplayName != null) {
|
||||
@@ -205,139 +225,98 @@ fun ProfileContent(
|
||||
tags = tags,
|
||||
modifier = Modifier
|
||||
.padding(top = 7.dp)
|
||||
.clickable(onClick = {
|
||||
nav(route)
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
}),
|
||||
.clickable(onClick = onClick),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Column(Modifier.padding(top = Size10dp)) {
|
||||
EditStatusBoxes(baseAccountUser, accountViewModel)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = Size10dp)
|
||||
.clickable(onClick = {
|
||||
nav(route)
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
})
|
||||
) {
|
||||
FollowingAndFollowerCounts(baseAccountUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditStatusBoxes(baseAccountUser: User, accountViewModel: AccountViewModel) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
LoadStatuses(user = baseAccountUser, accountViewModel) { statuses ->
|
||||
if (statuses.isEmpty()) {
|
||||
val currentStatus = remember {
|
||||
mutableStateOf("")
|
||||
}
|
||||
val hasChanged by remember {
|
||||
derivedStateOf {
|
||||
currentStatus.value != ""
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = currentStatus.value,
|
||||
onValueChange = { currentStatus.value = it },
|
||||
label = { Text(text = stringResource(R.string.status_update)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.status_update),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
imeAction = ImeAction.Send,
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSend = {
|
||||
accountViewModel.createStatus(currentStatus.value)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
if (hasChanged) {
|
||||
SendButton() {
|
||||
accountViewModel.createStatus(currentStatus.value)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
StatusEditBar(accountViewModel = accountViewModel)
|
||||
} else {
|
||||
statuses.forEach {
|
||||
val originalStatus by it.live().content.observeAsState("")
|
||||
val originalStatus by it.live().content.observeAsState()
|
||||
|
||||
val thisStatus = remember {
|
||||
mutableStateOf(originalStatus)
|
||||
}
|
||||
val hasChanged by remember {
|
||||
derivedStateOf {
|
||||
thisStatus.value != originalStatus
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = thisStatus.value,
|
||||
onValueChange = { thisStatus.value = it },
|
||||
label = { Text(text = stringResource(R.string.status_update)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.status_update),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
imeAction = ImeAction.Send,
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSend = {
|
||||
accountViewModel.updateStatus(it, thisStatus.value)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
if (hasChanged) {
|
||||
SendButton() {
|
||||
accountViewModel.updateStatus(it, thisStatus.value)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
} else {
|
||||
UserStatusDeleteButton() {
|
||||
accountViewModel.deleteStatus(it)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
StatusEditBar(originalStatus, it.address, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatusEditBar(
|
||||
savedStatus: String? = null,
|
||||
tag: ATag? = null,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val currentStatus = remember {
|
||||
mutableStateOf(savedStatus ?: "")
|
||||
}
|
||||
val hasChanged = remember {
|
||||
derivedStateOf {
|
||||
currentStatus.value != (savedStatus ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = currentStatus.value,
|
||||
onValueChange = { currentStatus.value = it },
|
||||
label = { Text(text = stringResource(R.string.status_update)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.status_update),
|
||||
color = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
imeAction = ImeAction.Send,
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSend = {
|
||||
if (tag == null) {
|
||||
accountViewModel.createStatus(currentStatus.value)
|
||||
} else {
|
||||
accountViewModel.updateStatus(tag, currentStatus.value)
|
||||
}
|
||||
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
if (hasChanged.value) {
|
||||
SendButton {
|
||||
if (tag == null) {
|
||||
accountViewModel.createStatus(currentStatus.value)
|
||||
} else {
|
||||
accountViewModel.updateStatus(tag, currentStatus.value)
|
||||
}
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
} else {
|
||||
if (tag != null) {
|
||||
UserStatusDeleteButton {
|
||||
accountViewModel.deleteStatus(tag)
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SendButton(onClick: () -> Unit) {
|
||||
IconButton(
|
||||
@@ -369,7 +348,7 @@ fun UserStatusDeleteButton(onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FollowingAndFollowerCounts(baseAccountUser: User) {
|
||||
private fun FollowingAndFollowerCounts(baseAccountUser: User, onClick: () -> Unit) {
|
||||
var followingCount by remember { mutableStateOf("--") }
|
||||
var followerCount by remember { mutableStateOf("--") }
|
||||
|
||||
@@ -385,21 +364,25 @@ private fun FollowingAndFollowerCounts(baseAccountUser: User) {
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = followingCount,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Row(
|
||||
modifier = drawerSpacing.clickable(onClick = onClick)
|
||||
) {
|
||||
Text(
|
||||
text = followingCount,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(stringResource(R.string.following))
|
||||
Text(stringResource(R.string.following))
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Text(
|
||||
text = followerCount,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = followerCount,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(stringResource(R.string.followers))
|
||||
Text(stringResource(R.string.followers))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -426,20 +409,18 @@ fun WatchFollower(baseAccountUser: User, onReady: (String) -> Unit) {
|
||||
|
||||
@Composable
|
||||
fun ListContent(
|
||||
nav: (String) -> Unit,
|
||||
modifier: Modifier,
|
||||
drawerState: DrawerState,
|
||||
openSheet: () -> Unit,
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountViewModel
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val route = remember(accountViewModel) {
|
||||
"User/${accountViewModel.userProfile().pubkeyHex}"
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var wantsToEditRelays by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var wantsToEditRelays by remember { mutableStateOf(false) }
|
||||
|
||||
var backupDialogOpen by remember { mutableStateOf(false) }
|
||||
var checked by remember { mutableStateOf(accountViewModel.account.proxy != null) }
|
||||
@@ -504,9 +485,8 @@ fun ListContent(
|
||||
)
|
||||
}
|
||||
|
||||
val textTorProxy = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short)
|
||||
IconRow(
|
||||
title = textTorProxy,
|
||||
title = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short),
|
||||
icon = R.drawable.ic_tor,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
onLongClick = {
|
||||
@@ -672,21 +652,19 @@ fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongCl
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 15.dp, horizontal = 25.dp),
|
||||
modifier = IconRowModifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(icon),
|
||||
null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
modifier = Size22Modifier,
|
||||
tint = tint
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
modifier = IconRowTextModifier,
|
||||
text = title,
|
||||
fontSize = 18.sp
|
||||
fontSize = Font18SP
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import java.net.URLEncoder
|
||||
|
||||
fun routeFor(note: Note, loggedIn: User): String? {
|
||||
val noteEvent = note.event
|
||||
|
||||
if (noteEvent is ChannelMessageEvent || noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) {
|
||||
note.channelHex()?.let {
|
||||
return "Channel/$it"
|
||||
}
|
||||
} else if (noteEvent is LiveActivitiesEvent || noteEvent is LiveActivitiesChatMessageEvent) {
|
||||
note.channelHex()?.let {
|
||||
return "Channel/${URLEncoder.encode(it, "utf-8")}"
|
||||
}
|
||||
} else if (noteEvent is ChatroomKeyable) {
|
||||
val room = noteEvent.chatroomKey(loggedIn.pubkeyHex)
|
||||
loggedIn.createChatroom(room)
|
||||
return "Room/${room.hashCode()}"
|
||||
} else if (noteEvent is CommunityDefinitionEvent) {
|
||||
return "Community/${URLEncoder.encode(note.idHex, "utf-8")}"
|
||||
} else {
|
||||
return "Note/${URLEncoder.encode(note.idHex, "utf-8")}"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun routeToMessage(user: HexKey, draftMessage: String?, accountViewModel: AccountViewModel): String {
|
||||
val withKey = ChatroomKey(persistentSetOf(user))
|
||||
accountViewModel.account.userProfile().createChatroom(withKey)
|
||||
return if (draftMessage != null) {
|
||||
"Room/${withKey.hashCode()}?message=$draftMessage"
|
||||
} else {
|
||||
"Room/${withKey.hashCode()}"
|
||||
}
|
||||
}
|
||||
|
||||
fun routeToMessage(user: User, draftMessage: String?, accountViewModel: AccountViewModel): String {
|
||||
return routeToMessage(user.pubkeyHex, draftMessage, accountViewModel)
|
||||
}
|
||||
|
||||
fun routeFor(note: Channel): String {
|
||||
return "Channel/${note.idHex}"
|
||||
}
|
||||
|
||||
fun routeFor(user: User): String {
|
||||
return "User/${user.pubkeyHex}"
|
||||
}
|
||||
|
||||
fun authorRouteFor(note: Note): String {
|
||||
return "User/${note.author?.pubkeyHex}"
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDestination
|
||||
@@ -37,16 +38,16 @@ sealed class Route(
|
||||
val route: String,
|
||||
val base: String = route.substringBefore("?"),
|
||||
val icon: Int,
|
||||
val notifSize: Dp = Size23dp,
|
||||
val iconSize: Dp = Size20dp,
|
||||
val notifSize: Modifier = Modifier.size(Size23dp),
|
||||
val iconSize: Modifier = Modifier.size(Size20dp),
|
||||
val hasNewItems: (Account, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _ -> false },
|
||||
val arguments: ImmutableList<NamedNavArgument> = persistentListOf()
|
||||
) {
|
||||
object Home : Route(
|
||||
route = "Home?nip47={nip47}",
|
||||
icon = R.drawable.ic_home,
|
||||
notifSize = Size25dp,
|
||||
iconSize = Size24dp,
|
||||
notifSize = Modifier.size(Size25dp),
|
||||
iconSize = Modifier.size(Size24dp),
|
||||
arguments = listOf(
|
||||
navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null }
|
||||
).toImmutableList(),
|
||||
@@ -335,8 +336,9 @@ private fun getRouteWithArguments(
|
||||
): String? {
|
||||
var route = destination.route ?: return null
|
||||
arguments?.let { bundle ->
|
||||
destination.arguments.keys.forEach { key ->
|
||||
val value = destination.arguments[key]?.type?.get(bundle, key)?.toString()
|
||||
destination.arguments.forEach {
|
||||
val key = it.key
|
||||
val value = it.value.type[bundle, key]?.toString()
|
||||
if (value == null) {
|
||||
val keyStart = route.indexOf("{$key}")
|
||||
// if it is a parameter, removes the complete segment `var={key}` and adjust connectors `#`, `&` or `&`
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.BadgeCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -15,8 +16,10 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
@@ -39,6 +42,7 @@ import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
@@ -58,23 +62,28 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_ENDED
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED
|
||||
import com.vitorpamplona.quartz.events.Participant
|
||||
import com.vitorpamplona.quartz.events.Price
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -95,7 +104,7 @@ fun ChannelCardCompose(
|
||||
) {
|
||||
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
|
||||
|
||||
Crossfade(targetState = hasEvent) {
|
||||
Crossfade(targetState = hasEvent, label = "ChannelCardCompose") {
|
||||
if (it) {
|
||||
if (forceEventKind == null || baseNote.event?.kind() == forceEventKind) {
|
||||
CheckHiddenChannelCardCompose(
|
||||
@@ -155,7 +164,7 @@ fun CheckHiddenChannelCardCompose(
|
||||
note.isHiddenFor(it)
|
||||
}.distinctUntilChanged().observeAsState(accountViewModel.isNoteHidden(note))
|
||||
|
||||
Crossfade(targetState = isHidden) {
|
||||
Crossfade(targetState = isHidden, label = "CheckHiddenChannelCardCompose") {
|
||||
if (!it) {
|
||||
LoadedChannelCardCompose(
|
||||
note,
|
||||
@@ -195,7 +204,7 @@ fun LoadedChannelCardCompose(
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = state) {
|
||||
Crossfade(targetState = state, label = "CheckHiddenChannelCardCompose") {
|
||||
RenderChannelCardReportState(
|
||||
it,
|
||||
note,
|
||||
@@ -220,7 +229,7 @@ fun RenderChannelCardReportState(
|
||||
) {
|
||||
var showReportedNote by remember { mutableStateOf(false) }
|
||||
|
||||
Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote ->
|
||||
Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "CheckHiddenChannelCardCompose") { showHiddenNote ->
|
||||
if (showHiddenNote) {
|
||||
HiddenNote(
|
||||
state.relevantReports,
|
||||
@@ -334,6 +343,28 @@ fun InnerChannelCardWithReactions(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
when (remember { baseNote.event }) {
|
||||
is LiveActivitiesEvent -> {
|
||||
InnerCardRow(baseNote, accountViewModel, nav)
|
||||
}
|
||||
is CommunityDefinitionEvent -> {
|
||||
InnerCardRow(baseNote, accountViewModel, nav)
|
||||
}
|
||||
is ChannelCreateEvent -> {
|
||||
InnerCardRow(baseNote, accountViewModel, nav)
|
||||
}
|
||||
is ClassifiedsEvent -> {
|
||||
InnerCardBox(baseNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InnerCardRow(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Column(StdPadding) {
|
||||
SensitivityWarning(
|
||||
@@ -353,6 +384,22 @@ fun InnerChannelCardWithReactions(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InnerCardBox(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Column(HalfPadding) {
|
||||
SensitivityWarning(
|
||||
note = baseNote,
|
||||
accountViewModel = accountViewModel
|
||||
) {
|
||||
RenderClassifiedsThumb(baseNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderNoteRow(
|
||||
baseNote: Note,
|
||||
@@ -372,6 +419,116 @@ private fun RenderNoteRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class ClassifiedsThumb(
|
||||
val image: String?,
|
||||
val title: String?,
|
||||
val price: Price?
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RenderClassifiedsThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteEvent = baseNote.event as? ClassifiedsEvent ?: return
|
||||
|
||||
val card by baseNote.live().metadata.map {
|
||||
val noteEvent = it.note.event as? ClassifiedsEvent
|
||||
|
||||
ClassifiedsThumb(
|
||||
image = noteEvent?.image(),
|
||||
title = noteEvent?.title(),
|
||||
price = noteEvent?.price()
|
||||
)
|
||||
}.distinctUntilChanged().observeAsState(
|
||||
ClassifiedsThumb(
|
||||
image = noteEvent.image(),
|
||||
title = noteEvent.title(),
|
||||
price = noteEvent.price()
|
||||
)
|
||||
)
|
||||
|
||||
RenderClassifiedsThumb(card, baseNote.author)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderClassifiedsThumbPreview() {
|
||||
Surface(Modifier.size(200.dp)) {
|
||||
RenderClassifiedsThumb(
|
||||
card = ClassifiedsThumb(
|
||||
image = null,
|
||||
title = "Like New",
|
||||
price = Price("800000", "SATS", null)
|
||||
),
|
||||
author = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderClassifiedsThumb(card: ClassifiedsThumb, author: User?) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f),
|
||||
contentAlignment = BottomStart
|
||||
) {
|
||||
card.image?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} ?: run {
|
||||
author?.let {
|
||||
DisplayAuthorBanner(it)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.Black.copy(0.6f))
|
||||
.padding(Size5dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
card.title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.White,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
card.price?.let {
|
||||
val priceTag = remember(card) {
|
||||
val newAmount = it.amount.toBigDecimalOrNull()?.let {
|
||||
showAmountAxis(it)
|
||||
} ?: it.amount
|
||||
|
||||
if (it.frequency != null && it.currency != null) {
|
||||
"$newAmount ${it.currency}/${it.frequency}"
|
||||
} else if (it.currency != null) {
|
||||
"$newAmount ${it.currency}"
|
||||
} else {
|
||||
newAmount
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = priceTag,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class LiveActivityCard(
|
||||
val name: String,
|
||||
|
||||
@@ -2,19 +2,13 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.background
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -50,16 +44,12 @@ import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatHeadlineBorders
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size75dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.emptyLineItemModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
@@ -81,7 +71,7 @@ fun ChatroomHeaderCompose(
|
||||
if (hasEvent) {
|
||||
ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav)
|
||||
} else {
|
||||
BlankNote(Modifier)
|
||||
BlankNote()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,15 +106,11 @@ private fun ChatroomPrivateMessages(
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(userRoom) { room ->
|
||||
Crossfade(userRoom, label = "ChatroomPrivateMessages") { room ->
|
||||
if (room != null) {
|
||||
UserRoomCompose(baseNote, room, accountViewModel, nav)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.height(Size75dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Box(emptyLineItemModifier) {
|
||||
// Makes sure just a max amount of objects are loaded.
|
||||
}
|
||||
}
|
||||
@@ -202,7 +188,7 @@ private fun ChannelRoomCompose(
|
||||
ChannelTitleWithLabelInfo(channelName, modifier)
|
||||
},
|
||||
channelLastTime = remember(note) { note.createdAt() },
|
||||
channelLastContent = remember(note) { "$authorName: $description" },
|
||||
channelLastContent = remember(note, authorState) { "$authorName: $description" },
|
||||
hasNewMessages = hasNewMessages,
|
||||
loadProfilePicture = automaticallyShowProfilePicture,
|
||||
onClick = { nav(route) }
|
||||
@@ -295,8 +281,8 @@ fun RoomNameDisplay(room: ChatroomKey, modifier: Modifier, accountViewModel: Acc
|
||||
it.user.privateChatrooms[room]?.subject
|
||||
}.distinctUntilChanged().observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject)
|
||||
|
||||
Crossfade(targetState = roomSubject, modifier) {
|
||||
if (it != null && it.isNotBlank()) {
|
||||
Crossfade(targetState = roomSubject, modifier, label = "RoomNameDisplay") {
|
||||
if (!it.isNullOrBlank()) {
|
||||
if (room.users.size > 1) {
|
||||
DisplayRoomSubject(it)
|
||||
} else {
|
||||
@@ -408,7 +394,7 @@ private fun WatchNotificationChanges(
|
||||
accountViewModel: AccountViewModel,
|
||||
onNewStatus: (Boolean) -> Unit
|
||||
) {
|
||||
LaunchedEffect(key1 = note, accountViewModel.accountMarkAsReadUpdates.value) {
|
||||
LaunchedEffect(key1 = note, accountViewModel.accountMarkAsReadUpdates.intValue) {
|
||||
launch(Dispatchers.IO) {
|
||||
note.event?.createdAt()?.let {
|
||||
val lastTime = accountViewModel.account.loadLastRead(route)
|
||||
@@ -450,16 +436,11 @@ fun ChannelName(
|
||||
) {
|
||||
ChannelName(
|
||||
channelPicture = {
|
||||
RobohashAsyncImageProxy(
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = channelIdHex,
|
||||
model = channelPicture,
|
||||
contentDescription = stringResource(R.string.channel_image),
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(Size55dp)
|
||||
.height(Size55dp)
|
||||
.clip(shape = CircleShape)
|
||||
},
|
||||
modifier = AccountPictureModifier,
|
||||
loadProfilePicture = loadProfilePicture
|
||||
)
|
||||
},
|
||||
@@ -480,76 +461,38 @@ fun ChannelName(
|
||||
hasNewMessages: MutableState<Boolean>,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(modifier = remember { Modifier.clickable(onClick = onClick) }) {
|
||||
Row(modifier = ChatHeadlineBorders) {
|
||||
Column(Size55Modifier) {
|
||||
channelPicture()
|
||||
ChatHeaderLayout(
|
||||
channelPicture = channelPicture,
|
||||
firstRow = {
|
||||
channelTitle(Modifier.weight(1f))
|
||||
TimeAgo(channelLastTime)
|
||||
},
|
||||
secondRow = {
|
||||
if (channelLastContent != null) {
|
||||
Text(
|
||||
channelLastContent,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.referenced_event_not_found),
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.SpaceAround
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FirstRow(channelTitle, channelLastTime, remember { Modifier.weight(1f) })
|
||||
}
|
||||
|
||||
Spacer(modifier = Height4dpModifier)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SecondRow(channelLastContent, hasNewMessages, remember { Modifier.weight(1f) })
|
||||
}
|
||||
if (hasNewMessages.value) {
|
||||
NewItemsBubble()
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = StdTopPadding,
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecondRow(channelLastContent: String?, hasNewMessages: MutableState<Boolean>, modifier: Modifier) {
|
||||
if (channelLastContent != null) {
|
||||
Text(
|
||||
channelLastContent,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.referenced_event_not_found),
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
if (hasNewMessages.value) {
|
||||
NewItemsBubble()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FirstRow(
|
||||
channelTitle: @Composable (Modifier) -> Unit,
|
||||
channelLastTime: Long?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
channelTitle(modifier)
|
||||
TimeAgo(channelLastTime)
|
||||
},
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -557,10 +500,8 @@ private fun TimeAgo(channelLastTime: Long?) {
|
||||
if (channelLastTime == null) return
|
||||
|
||||
val context = LocalContext.current
|
||||
val timeAgo by remember(channelLastTime) {
|
||||
derivedStateOf {
|
||||
timeAgo(channelLastTime, context)
|
||||
}
|
||||
val timeAgo = remember(channelLastTime) {
|
||||
timeAgo(channelLastTime, context)
|
||||
}
|
||||
Text(
|
||||
text = timeAgo,
|
||||
|
||||
@@ -48,7 +48,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -59,7 +59,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
@@ -450,7 +450,7 @@ private fun MessageBubbleLines(
|
||||
grayTint = MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel = accountViewModel,
|
||||
showCounter = false,
|
||||
iconSize = Size15dp
|
||||
iconSizeModifier = Size15Modifier
|
||||
) {
|
||||
onWantsToReply(baseNote)
|
||||
}
|
||||
@@ -784,7 +784,7 @@ private fun UserIcon(
|
||||
nav: (String) -> Unit,
|
||||
route: String
|
||||
) {
|
||||
RobohashAsyncImageProxy(
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = pubkeyHex,
|
||||
model = userProfilePicture,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
|
||||
@@ -6,12 +6,14 @@ import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.DownloadForOffline
|
||||
import androidx.compose.material.icons.filled.Downloading
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.material.icons.filled.VolumeOff
|
||||
@@ -76,12 +78,12 @@ fun MessageIcon(modifier: Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DownloadForOfflineIcon(iconSize: Dp) {
|
||||
fun DownloadForOfflineIcon(iconSize: Dp, tint: Color = MaterialTheme.colorScheme.primary) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DownloadForOffline,
|
||||
null,
|
||||
modifier = remember(iconSize) { Modifier.size(iconSize) },
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = tint
|
||||
)
|
||||
}
|
||||
|
||||
@@ -121,11 +123,11 @@ fun LikedIcon(modifier: Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LikeIcon(iconSize: Dp, grayTint: Color) {
|
||||
fun LikeIcon(iconSizeModifier: Modifier, grayTint: Color) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_like),
|
||||
null,
|
||||
modifier = remember(iconSize) { Modifier.size(iconSize) },
|
||||
modifier = iconSizeModifier,
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
@@ -160,11 +162,6 @@ fun ZappedIcon(modifier: Modifier) {
|
||||
ZapIcon(modifier = modifier, BitcoinOrange)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ZapIcon(iconSize: Dp, tint: Color = Color.Unspecified) {
|
||||
ZapIcon(modifier = remember(iconSize) { Modifier.size(iconSize) }, tint)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ZapIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
|
||||
Icon(
|
||||
@@ -175,6 +172,36 @@ fun ZapIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CashuIcon(modifier: Modifier) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.cashu),
|
||||
"Cashu",
|
||||
tint = Color.Unspecified,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CopyIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
stringResource(id = R.string.copy_to_clipboard),
|
||||
tint = tint,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OpenInNewIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.OpenInNew,
|
||||
stringResource(id = R.string.copy_to_clipboard),
|
||||
tint = tint,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpandLessIcon(modifier: Modifier) {
|
||||
Icon(
|
||||
@@ -196,11 +223,11 @@ fun ExpandMoreIcon(modifier: Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CommentIcon(iconSize: Dp, tint: Color) {
|
||||
fun CommentIcon(iconSizeModifier: Modifier, tint: Color) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
contentDescription = null,
|
||||
modifier = remember { Modifier.size(iconSize) },
|
||||
modifier = iconSizeModifier,
|
||||
tint = tint
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
@@ -44,11 +43,14 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
|
||||
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.authorRouteFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -75,7 +77,6 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
|
||||
@Composable
|
||||
@@ -129,27 +130,21 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, showHi
|
||||
}
|
||||
|
||||
Column(modifier = columnModifier) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Galeries(multiSetCard, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
Log.d("Rendering Metrics", "All Galeries: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed - ")
|
||||
Galeries(multiSetCard, backgroundColor, accountViewModel, nav)
|
||||
|
||||
Row(remember { Modifier.fillMaxWidth() }) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Spacer(modifier = WidthAuthorPictureModifierWithPadding)
|
||||
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
NoteCompose(
|
||||
baseNote = baseNote,
|
||||
routeForLastRead = null,
|
||||
modifier = remember { Modifier.padding(top = 5.dp) },
|
||||
isBoostedNote = true,
|
||||
showHidden = showHidden,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
Log.d("Rendering Metrics", "Complete: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
NoteCompose(
|
||||
baseNote = baseNote,
|
||||
routeForLastRead = null,
|
||||
modifier = remember { Modifier.padding(top = 5.dp) },
|
||||
isBoostedNote = true,
|
||||
showHidden = showHidden,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
|
||||
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
|
||||
}
|
||||
@@ -167,16 +162,15 @@ private fun Galeries(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val boostEvents by remember { derivedStateOf { multiSetCard.boostEvents } }
|
||||
val likeEvents by remember { derivedStateOf { multiSetCard.likeEventsByType } }
|
||||
|
||||
val hasZapEvents by remember { derivedStateOf { multiSetCard.zapEvents.isNotEmpty() } }
|
||||
val hasBoostEvents by remember { derivedStateOf { multiSetCard.boostEvents.isNotEmpty() } }
|
||||
val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } }
|
||||
|
||||
if (hasZapEvents) {
|
||||
var zapEvents by remember(multiSetCard.zapEvents) {
|
||||
mutableStateOf<ImmutableList<ZapAmountCommentNotification>>(persistentListOf())
|
||||
mutableStateOf(
|
||||
accountViewModel.cachedDecryptAmountMessageInGroup(multiSetCard.zapEvents)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
@@ -185,26 +179,17 @@ private fun Galeries(
|
||||
}
|
||||
}
|
||||
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
Log.d("Rendering Metrics", "Galeries Zaps: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
|
||||
if (hasBoostEvents) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
RenderBoostGallery(boostEvents, nav, accountViewModel)
|
||||
}
|
||||
Log.d("Rendering Metrics", "Galeries Repost: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
RenderBoostGallery(multiSetCard.boostEvents, nav, accountViewModel)
|
||||
}
|
||||
|
||||
if (hasLikeEvents) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
likeEvents.forEach {
|
||||
RenderLikeGallery(it.key, it.value, nav, accountViewModel)
|
||||
}
|
||||
multiSetCard.likeEventsByType.forEach {
|
||||
RenderLikeGallery(it.key, it.value, nav, accountViewModel)
|
||||
}
|
||||
Log.d("Rendering Metrics", "Galeries Like: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +286,31 @@ fun RenderBoostGallery(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderBoostGallery(
|
||||
noteToGetBoostEvents: NoteState,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = NotificationIconModifierSmaller
|
||||
) {
|
||||
RepostedIcon(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.size(Size19dp)
|
||||
.align(Alignment.TopEnd)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AuthorGallery(noteToGetBoostEvents, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapZaps(
|
||||
zaps: ImmutableList<CombinedZap>,
|
||||
@@ -489,6 +499,22 @@ fun AuthorGallery(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun AuthorGallery(
|
||||
noteToGetBoostEvents: NoteState,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Column(modifier = StdStartPadding) {
|
||||
FlowRow() {
|
||||
noteToGetBoostEvents.note.boosts.forEach { note ->
|
||||
BoxedAuthor(note, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxedAuthor(
|
||||
note: Note,
|
||||
@@ -527,7 +553,7 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
|
||||
|
||||
WatchUserMetadata(author) { baseUserPicture ->
|
||||
// Crossfade(targetState = baseUserPicture) { userPicture ->
|
||||
RobohashAsyncImageProxy(
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = author.pubkeyHex,
|
||||
model = baseUserPicture,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
|
||||
@@ -34,12 +34,12 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
|
||||
import com.vitorpamplona.amethyst.ui.note.NIP05CheckingIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.NIP05FailedVerification
|
||||
import com.vitorpamplona.amethyst.ui.note.NIP05VerifiedIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user