Compare commits

...
146 Commits
Author SHA1 Message Date
Vitor Pamplona bfae5871c8 v0.75.12 2023-08-31 19:05:12 -04:00
Vitor Pamplona 451c5d1648 Removing LiveData Redirections 2023-08-31 19:04:07 -04:00
Vitor Pamplona 3423626c60 Fixes global feed 2023-08-31 19:02:54 -04:00
Vitor Pamplona 8982f02a4a v0.75.11 2023-08-31 17:13:32 -04:00
Vitor Pamplona f06b5fdacf New thread ordering scheme:
1. Thread author, ascending order by date.
2. My replies, descending by date.
3. My follow's replies, descending by date.
4. Everybody else, descending by date.
2023-08-31 16:41:02 -04:00
Vitor Pamplona 8ea8cf2985 Marks keep screen on to false when leaving a video player. 2023-08-31 15:23:30 -04:00
Vitor Pamplona 2771d34951 Fixes the report card when the author has been blocked by the logged in user but the list loaded before the block user list. 2023-08-31 14:53:43 -04:00
Vitor Pamplona 669b33a3e1 Reducing the height of the banner in the drawer 2023-08-31 13:43:57 -04:00
Vitor Pamplona c934a1cdfe Reduce the space between display name and status. 2023-08-31 13:36:46 -04:00
Vitor Pamplona 0ccd41f5e8 Fixes wrong EOSE updates. 2023-08-31 13:21:08 -04:00
Vitor Pamplona 1af3bb7dfc Improve cohesiveness of the pruning system. 2023-08-31 11:48:22 -04:00
Vitor Pamplona f0fcf1ea8a Fixes the grammar in the nip05 error messages 2023-08-31 09:04:09 -04:00
Vitor Pamplona 5325542c81 update vico 2023-08-30 19:56:37 -04:00
Vitor Pamplona 3d9675841b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-08-30 19:32:31 -04:00
Vitor PamplonaandGitHub 0d475fba0e Merge pull request #564 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-08-30 17:31:45 -04:00
Crowdin Bot 05714de945 New Crowdin translations by GitHub Action 2023-08-30 21:14:24 +00:00
Vitor PamplonaandGitHub 89c1b53c95 Merge pull request #563 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-08-30 17:13:08 -04:00
Vitor Pamplona 1c9041f12b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-08-30 10:12:23 -04:00
Crowdin Bot d3de5e0a42 New Crowdin translations by GitHub Action 2023-08-30 12:50:26 +00:00
Vitor PamplonaandGitHub b1bd21533d Merge pull request #562 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-08-30 08:49:11 -04:00
Crowdin Bot 51f997a34a New Crowdin translations by GitHub Action 2023-08-30 12:48:43 +00:00
Vitor Pamplona d5607faadd Requires 10 sat zaps to trigger notifications 2023-08-30 08:47:25 -04:00
Vitor Pamplona 1a9c3ccb79 Must have a valid ZapRequest to process. 2023-08-30 08:44:14 -04:00
Vitor Pamplona 053de4d06f Removes unnecessary checks since the dual LiveData structure was activated 2023-08-29 17:57:47 -04:00
Vitor Pamplona 4016458540 v0.75.10 2023-08-29 17:40:51 -04:00
Vitor Pamplona 8b98efaad7 Refines zap and report pruning. 2023-08-29 17:24:48 -04:00
Vitor Pamplona 217ed90d9a Fix the deletion of reports from authors 2023-08-29 17:14:25 -04:00
Vitor PamplonaandGitHub b82aefbd5d Merge pull request #559 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-08-29 16:54:41 -04:00
Crowdin Bot 63b954d38d New Crowdin translations by GitHub Action 2023-08-29 20:54:17 +00:00
Vitor Pamplona 76cdea0087 Fixing xml 2023-08-29 16:53:09 -04:00
Vitor Pamplona da1a3a8f85 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-08-29 16:51:05 -04:00
Vitor Pamplona d11e92e166 v0.75.9 2023-08-29 16:46:34 -04:00
Vitor Pamplona 61efd237c2 Fixes: https://github.com/vitorpamplona/amethyst/issues/495 and https://github.com/vitorpamplona/amethyst/issues/398 2023-08-29 16:46:27 -04:00
Vitor Pamplona 6099948605 Migrates from Toast Message to a Dialog when Zap fails in the reaction row. 2023-08-29 16:43:45 -04:00
Vitor PamplonaandGitHub 45b743e7c8 Merge pull request #557 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-08-29 16:42:50 -04:00
Crowdin Bot 108c6cbeb4 New Crowdin translations by GitHub Action 2023-08-28 21:18:41 +00:00
Vitor Pamplona da02891c6a Correct an xml issue from crowdin 2023-08-28 17:12:48 -04:00
Vitor PamplonaandGitHub a03e9ea946 Merge pull request #556 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-08-28 16:59:51 -04:00
Crowdin Bot dd4a6f8da0 New Crowdin translations by GitHub Action 2023-08-28 20:57:58 +00:00
Vitor Pamplona c01033cbb0 Fixes case 2023-08-28 16:56:52 -04:00
Vitor Pamplona 1fe93395c5 gotta choose one 2023-08-28 16:51:05 -04:00
Vitor Pamplona cf960fe59d Avoid creating english files when they have not been translated yet. 2023-08-28 16:49:12 -04:00
Vitor Pamplona 2416d41131 removes filter to only run crowdin when strings are updated 2023-08-28 16:41:53 -04:00
Vitor Pamplona 0699095d77 triggers crowdin 2023-08-28 16:32:00 -04:00
Vitor Pamplona 6ed3e84d95 This might work 2023-08-28 16:31:09 -04:00
Vitor Pamplona 6c196cfeb7 triggering crowdin 2023-08-28 16:27:26 -04:00
Vitor Pamplona 987784fb89 Fixing yaml again 2023-08-28 16:26:59 -04:00
Vitor Pamplona d60ac5d73b updates strings 2023-08-28 16:24:04 -04:00
Vitor Pamplona c2361c034c corrects yaml 2023-08-28 16:23:51 -04:00
Vitor Pamplona 199514e7f3 trigger crowdin 2023-08-28 16:22:07 -04:00
Vitor Pamplona a1364d19c2 correctly identifies mapping languages. 2023-08-28 16:21:47 -04:00
Vitor Pamplona e73428b156 updates to strings.xml 2023-08-28 16:09:28 -04:00
Vitor Pamplona a684a222cb runs github action on any updates to source or dst translations 2023-08-28 16:08:32 -04:00
Vitor Pamplona 1646cbbd68 mapping language codes to crowdin 2023-08-28 16:07:26 -04:00
Vitor Pamplona 035a48a0cd adds a type android 2023-08-28 15:55:32 -04:00
Vitor Pamplona 71be8ce239 testing update to strings.xml 2023-08-28 15:43:49 -04:00
Vitor Pamplona 1ccf9bf467 only runs with strings change 2023-08-28 15:14:47 -04:00
Vitor Pamplona 42a0cd0b1b adding the configuration file for crowdin 2023-08-28 15:12:39 -04:00
Vitor Pamplona 39547b1d11 Adds Crowin 2023-08-28 14:43:22 -04:00
Vitor Pamplona c69969947b v0.75.8: Fixes status's cache update crash. 2023-08-27 19:33:08 -04:00
Vitor Pamplona e1738a25a1 Do a warning log and not an error log for verification fails (it usually happens in stringified JSON inside .content) 2023-08-27 19:17:58 -04:00
Vitor Pamplona 6c54082a12 clears the index cache when changing status fields. 2023-08-27 19:16:58 -04:00
Vitor Pamplona bc5500731d v0.75.7 2023-08-27 18:12:41 -04:00
Vitor Pamplona 0665c552aa BugFix for a crash when receiving a new notification 2023-08-27 18:11:14 -04:00
Vitor Pamplona 1ab8463578 v0.75.6 2023-08-27 17:13:29 -04:00
Vitor Pamplona e016e68e42 removing unused sentence. 2023-08-27 17:11:49 -04:00
Vitor Pamplona d116159ed9 v0.75.5 2023-08-27 16:49:31 -04:00
Vitor Pamplona 3c24c011e3 Avoids rebuilding the channel object in DataSource. 2023-08-27 16:45:12 -04:00
Vitor Pamplona 536bdfe43a - When All Follows, the live tab will load any event with a follow in the participant list.
- Removes prefix filters
2023-08-27 16:32:35 -04:00
Vitor Pamplona 0b41110938 Fixes formatting 2023-08-27 15:53:41 -04:00
Vitor Pamplona da1d4018cf Fixes crash when more than 1 status 2023-08-27 15:52:35 -04:00
Vitor Pamplona b569c67d3b Fixes Hungarian labels 2023-08-27 15:33:18 -04:00
Vitor PamplonaandGitHub 50a5a68b71 Merge pull request #554 from anthony-robin/translations
Update french translations
2023-08-27 15:30:42 -04:00
Vitor Pamplona 23a4d80ddb v0.75.4 2023-08-27 12:25:53 -04:00
Vitor Pamplona 6a64ebcc5b migrates to a double layer observer structure for the memory cache 2023-08-27 12:18:11 -04:00
Vitor Pamplona 8e622bfbb1 Updates to debugging logs 2023-08-27 12:16:36 -04:00
Vitor Pamplona d189dce7f0 Extracts chatroom from user file 2023-08-27 12:16:13 -04:00
Vitor Pamplona 43ac1bf555 Allows cleaning up on App Pause to perform separately from memory pruning functions 2023-08-27 12:10:12 -04:00
Anthony Robin d2d26e4e57 Update french translations
Update french translations according to newly added english ones.
2023-08-27 07:52:15 +02:00
Vitor Pamplona de1f13d64c Faster crossfade animation between screens 2023-08-26 13:04:35 -04:00
Vitor Pamplona 2be04bfbe2 Better error handling when Zaps fail 2023-08-26 13:00:53 -04:00
Vitor Pamplona 57c33ae06a Improves User filters by merging lastEOSEs into a single filter. 2023-08-26 12:59:41 -04:00
Vitor Pamplona 99e545f0b8 Adds polls to the hashtag screen 2023-08-26 11:32:37 -04:00
Vitor Pamplona accb115a20 Making statuses filter just one with the minimum of all observing users. 2023-08-26 10:36:44 -04:00
Vitor PamplonaandGitHub 9de1cec160 Merge pull request #553 from ZsZolee/main
Additional Hungarian translation
2023-08-26 09:34:36 -04:00
ZoltanandGitHub f7774700db Additional Hungarian translation 2023-08-26 13:22:27 +02:00
Vitor Pamplona ffb81c6246 v0.75.3 2023-08-25 17:51:38 -04:00
Vitor Pamplona b7c9b14b1f Allowing any BaseTextNote on the reply window 2023-08-25 17:47:01 -04:00
Vitor Pamplona 53b0db61ea Moves isAcceptableNote calculations to viewModel 2023-08-25 17:32:19 -04:00
Vitor Pamplona 6c09e47e4f Moves coroutine of NIP-05 verification to the viewModel 2023-08-25 17:26:47 -04:00
Vitor Pamplona 9d9ad63b4d BugFix deleting status 2023-08-25 17:15:17 -04:00
Vitor Pamplona 7ef46a2662 Continues to move away from starting co-routines directly in composables 2023-08-25 17:14:33 -04:00
Vitor Pamplona a7d8c87c47 1. Triggers a Toast when no wallet is found to pay the invoice
2. Starts the wallet in a separate task to allow people to come back to Amethyst switching apps instead of the back button.
2023-08-25 15:18:01 -04:00
Vitor Pamplona 1bb2459277 Activates min/max values for polls 2023-08-25 15:08:10 -04:00
Vitor Pamplona 6b39562925 Allows the keyboard action to be used as commit in the status field. 2023-08-25 13:55:41 -04:00
Vitor Pamplona 3d34e1fc4e Reduces the icon to the music and adds space before the status 2023-08-25 13:41:17 -04:00
Vitor Pamplona 469e1241d5 Updates some dependencies 2023-08-25 13:28:17 -04:00
Vitor Pamplona 86c0ca975b Moves the User DataSource invalidation to the right channel 2023-08-25 13:27:57 -04:00
Vitor Pamplona 53eeea3225 Removes unused time 2023-08-25 13:26:56 -04:00
Vitor Pamplona e9ff9d3b37 Adds a space between the status and the clickable icon 2023-08-25 13:24:47 -04:00
Vitor Pamplona e2c011ec60 Avoids lists with the same hexId because the app was pruning events while loading them 2023-08-25 13:01:49 -04:00
Vitor Pamplona 5328740bff Improves blinking when loading a thread. 2023-08-25 13:01:08 -04:00
Vitor Pamplona 017df9845d Fixes r filter in tags 2023-08-25 12:59:21 -04:00
Vitor Pamplona ed70658be9 Removes unecessary @ExperimentalTime tag 2023-08-25 11:52:44 -04:00
Vitor Pamplona dc74c3014f updates cached content when note changes 2023-08-25 11:51:41 -04:00
Vitor Pamplona 53d5c5f354 BugFix for serialization filter 2023-08-25 11:49:41 -04:00
Vitor Pamplona 63ab2a9fbe Moves NIP315 to NIP38 2023-08-25 10:31:51 -04:00
Vitor Pamplona 30391160da v0.75.2 2023-08-24 18:56:39 -04:00
Vitor Pamplona 3273aa32a5 Removes the debug r tag used to test. 2023-08-24 18:54:48 -04:00
Vitor Pamplona 1e358d31d5 v0.75.1 2023-08-24 17:59:26 -04:00
Vitor Pamplona b57dd98059 Displays links in the Status if they are present. 2023-08-24 17:58:34 -04:00
Vitor Pamplona 5b596d2a56 - Updating Status on the Drawer
- Reducing font size for statuses and nip05s
2023-08-24 17:34:02 -04:00
Vitor Pamplona 62db68c7b2 Correctly display status 2023-08-24 16:17:20 -04:00
Vitor Pamplona b3bb85cd53 Display statuses if NIP05 succeeds. 2023-08-24 15:57:18 -04:00
Vitor Pamplona fe6fb8470d Adds isExpired method in events. 2023-08-24 15:56:43 -04:00
Vitor Pamplona ee97eac00c Downloads Statuses from Relays 2023-08-24 15:56:08 -04:00
Vitor Pamplona 48e3ccc165 Allow changing message status 2023-08-24 15:55:32 -04:00
Vitor Pamplona 52c0503b6d Support for pruning expired events. 2023-08-24 13:13:30 -04:00
Vitor Pamplona c052399a16 Adds support to consume Status Events 2023-08-24 13:12:10 -04:00
Vitor Pamplona 76faf7ae35 Moving livedata creation to long-lived objects 2023-08-24 12:53:07 -04:00
Vitor Pamplona 36ea33a919 Adds StatusEvent support 2023-08-24 08:56:51 -04:00
Vitor Pamplona 8aad4d8030 Moves Manifest to target API 34 2023-08-24 08:56:37 -04:00
Vitor Pamplona a23c8c5c04 v0.75.0 2023-08-23 18:22:38 -04:00
Vitor Pamplona 0434ef6ec6 Fixing quotes and boosts for NIP94 and NIP95 content 2023-08-23 17:31:11 -04:00
Vitor Pamplona 4d85a2f6fb Correct imePadding of the NewPostView 2023-08-23 17:08:46 -04:00
Vitor Pamplona e9b235f748 Don't show zaps on search. 2023-08-23 16:27:04 -04:00
Vitor Pamplona 30320805da Adds Thai translation 2023-08-23 10:19:00 -04:00
Vitor Pamplona 8e913ef974 Adds replies and boosts to Stories 2023-08-22 17:49:44 -04:00
Vitor Pamplona eb191406e6 Vertical Arrangement not working as intended. 2023-08-22 17:32:59 -04:00
Vitor Pamplona ca89b70150 Activates pruning of old replaceable versions 2023-08-22 17:07:59 -04:00
Vitor Pamplona 6bcd6950ef Refactors replaceable consumers into a single function
Activates calendar consumers
2023-08-22 16:59:58 -04:00
Vitor Pamplona dfa9c77e3e Migrates Addressable Events to a Base class 2023-08-22 16:39:23 -04:00
Vitor Pamplona af143f2ea1 Base support for Calendar events 2023-08-22 16:19:59 -04:00
Vitor Pamplona 52761b3901 Activates pull to refresh on the Stories feed. 2023-08-22 16:19:37 -04:00
Vitor Pamplona f027d23fcf Small refactoring to create the PrivateZap Event 2023-08-22 16:10:32 -04:00
Vitor Pamplona 2d30f3e9d2 Finishing the prune clean up to make sure we don't delete post from other accounts logged in. 2023-08-22 16:01:59 -04:00
Vitor Pamplona a63b383425 BugFix Force Relay Reconnection when writing a new events. 2023-08-22 14:06:02 -04:00
Vitor Pamplona e04eb733e7 - Protecting contact lists of all accounts in the device.
- Pruning events that are not from or cite an account.
2023-08-22 14:04:49 -04:00
Vitor Pamplona 0a49298ec2 Faster ReactionRow compositions 2023-08-22 12:23:45 -04:00
Vitor Pamplona 1121cfcaf9 BugFix on bundled update dispatchers. 2023-08-22 12:23:26 -04:00
Vitor Pamplona 66b5fad6c2 Saves some loading time by moving the check if notification was already asked forward. 2023-08-22 11:23:11 -04:00
Vitor Pamplona 5d352f1b6c Updates compose navigation, exoplayer and vico dependencies to the latest. 2023-08-22 11:16:31 -04:00
Vitor Pamplona 9018175f54 Removes unecessary import. 2023-08-22 11:06:10 -04:00
Vitor Pamplona 98c55661e8 Migrates to Compose 1.5.0 2023-08-22 11:05:17 -04:00
Vitor Pamplona 521407f879 Updates Gradle plugin 2023-08-22 10:07:40 -04:00
Vitor Pamplona fa4257ad7d Separating NIP24 into the encryption NIP 44 and the messaging NIP 24 2023-08-22 09:41:40 -04:00
194 changed files with 5485 additions and 3059 deletions
+30
View File
@@ -0,0 +1,30 @@
name: Crowdin Action
on:
push:
# paths: ["app/src/main/res/**/strings.xml"] // removes filter to allow downloads at any moment.
branches: [ main ]
jobs:
synchronize-with-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: crowdin action
uses: crowdin/github-action@v1
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: l10n_crowdin_translations
create_pull_request: true
pull_request_title: 'New Crowdin Translations'
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
pull_request_base_branch_name: 'main'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+2 -1
View File
@@ -82,13 +82,14 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [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
- [ ] Expiration Support (NIP-40)
- [ ] Infinity Scroll
- [ ] Relay List Metadata (NIP-65)
- [ ] Signing Requests (NIP-46)
+6 -6
View File
@@ -13,14 +13,14 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 278
versionName "0.74.5"
versionCode 291
versionName "0.75.12"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'th', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
}
buildTypes {
@@ -88,7 +88,7 @@ android {
dependencies {
implementation project(path: ':quartz')
implementation 'androidx.core:core-ktx:1.10.1'
implementation "androidx.core:core-ktx:$core_ktx_version"
implementation 'androidx.activity:activity-compose:1.7.2'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -112,7 +112,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Zoomable images
implementation "net.engawapg.lib:zoomable:1.4.3"
implementation "net.engawapg.lib:zoomable:1.5.0"
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
@@ -171,7 +171,7 @@ dependencies {
playImplementation 'com.google.mlkit:translate:17.0.1'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.2.2')
playImplementation platform('com.google.firebase:firebase-bom:32.2.3')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
// Charts
@@ -21,7 +21,7 @@ class ImageUploadTesting {
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=="
fun testBase(server: FileServer) {
private fun testBase(server: FileServer) {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
@@ -39,7 +39,7 @@ class ImageUploadTesting {
"image/gif",
server,
onSuccess = { newUrl, contentType ->
println("Uploaded to $url")
println("Uploaded $contentType to $url")
url = newUrl
countDownLatch.countDown()
},
@@ -4128,7 +4128,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
}
private fun printStateForDebug(state: RichTextViewerState) {
state.paragraphs.forEachIndexed { index, paragraph ->
state.paragraphs.forEach { paragraph ->
paragraph.words.forEach { seg ->
println(
"\"${
+1 -1
View File
@@ -60,7 +60,7 @@
android:usesCleartextTraffic="true"
android:hardwareAccelerated="true"
android:localeConfig="@xml/locales_config"
tools:targetApi="33">
tools:targetApi="34">
<activity
android:name=".ui.MainActivity"
android:exported="true"
@@ -205,6 +205,10 @@ object LocalPreferences {
}
}
fun allLocalAccountNPubs(): Set<String> {
return savedAccounts().toSet()
}
fun saveToEncryptedStorage(account: Account) {
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
prefs.edit().apply {
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import java.io.File
object ServiceManager {
@@ -114,14 +115,22 @@ object ServiceManager {
Client.disconnect()
}
fun cleanUp() {
fun cleanObservers() {
LocalCache.cleanObservers()
}
fun trimMemory() {
LocalCache.cleanObservers()
val accounts = LocalPreferences.allLocalAccountNPubs().mapNotNull { decodePublicKeyAsHexOrNull(it) }.toSet()
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneContactLists(it)
LocalCache.pruneRepliesAndReactions(it)
LocalCache.pruneContactLists(accounts)
LocalCache.pruneRepliesAndReactions(accounts)
LocalCache.prunePastVersionsOfReplaceables()
LocalCache.pruneExpiredEvents()
}
}
}
@@ -101,14 +101,16 @@ class Account(
val showSensitiveContent: Boolean?
)
val liveHiddenUsers: LiveData<LiveHiddenUsers> = live.combineWith(getBlockListNote().live().metadata) { localLive, liveMuteListEvent ->
val liveBlockedUsers = (liveMuteListEvent?.note?.event as? PeopleListEvent)?.publicAndPrivateUsers(keyPair.privKey)
LiveHiddenUsers(
hiddenUsers = liveBlockedUsers ?: persistentSetOf(),
spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(),
showSensitiveContent = showSensitiveContent
)
}.distinctUntilChanged()
val liveHiddenUsers: LiveData<LiveHiddenUsers> by lazy {
live.combineWith(getBlockListNote().live().metadata) { localLive, liveMuteListEvent ->
val liveBlockedUsers = (liveMuteListEvent?.note?.event as? PeopleListEvent)?.publicAndPrivateUsers(keyPair.privKey)
LiveHiddenUsers(
hiddenUsers = liveBlockedUsers ?: persistentSetOf(),
spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(),
showSensitiveContent = showSensitiveContent
)
}.distinctUntilChanged()
}
var userProfileCache: User? = null
@@ -1008,6 +1010,40 @@ class Account(
}
}
fun updateStatus(oldStatus: AddressableNote, newStatus: String) {
if (!isWriteable()) return
val oldEvent = oldStatus.event as? StatusEvent ?: return
val event = StatusEvent.update(oldEvent, newStatus, keyPair.privKey!!)
Client.send(event)
LocalCache.consume(event, null)
}
fun createStatus(newStatus: String) {
if (!isWriteable()) return
val event = StatusEvent.create(newStatus, "general", expiration = null, keyPair.privKey!!)
Client.send(event)
LocalCache.consume(event, null)
}
fun deleteStatus(oldStatus: AddressableNote) {
if (!isWriteable()) return
val oldEvent = oldStatus.event as? StatusEvent ?: return
val event = StatusEvent.clear(oldEvent, keyPair.privKey!!)
Client.send(event)
LocalCache.consume(event, null)
val event2 = DeletionEvent.create(listOf(event.id), keyPair.privKey!!)
Client.send(event2)
LocalCache.consume(event2)
}
fun removeEmojiPack(usersEmojiList: Note, emojiList: Note) {
if (!isWriteable()) return
@@ -164,20 +164,12 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
override fun onActive() {
super.onActive()
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.add(channel.idHex)
} else {
NostrSingleChannelDataSource.add(channel.idHex)
}
NostrSingleChannelDataSource.add(channel)
}
override fun onInactive() {
super.onInactive()
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.remove(channel.idHex)
} else {
NostrSingleChannelDataSource.remove(channel.idHex)
}
NostrSingleChannelDataSource.remove(channel)
}
}
@@ -0,0 +1,63 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
@Stable
class Chatroom() {
var roomMessages: Set<Note> = setOf()
var subject: String? = null
var subjectCreatedAt: Long? = null
@Synchronized
fun addMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
val newSubject = msg.event?.subject()
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
subject = newSubject
subjectCreatedAt = msg.createdAt()
}
}
}
@Synchronized
fun removeMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let {
subject = it.event?.subject()
subjectCreatedAt = it.createdAt()
}
}
}
fun senderIntersects(keySet: Set<HexKey>): Boolean {
return roomMessages.any { it.author?.pubkeyHex in keySet }
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
// Recent messages, keep last 100
sorted.take(100).toSet()
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.liveSet?.isInUse() ?: false }
val toRemove = roomMessages.minus(toKeep)
roomMessages = toKeep
return toRemove
}
}
@@ -14,7 +14,9 @@ import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.*
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -204,46 +206,6 @@ object LocalCache {
}
}
fun consume(event: PeopleListEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: AdvertisedRelayListEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
@@ -349,25 +311,6 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: LiveActivitiesEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -392,102 +335,16 @@ object LocalCache {
}
}
fun consume(event: EmojiPackSelectionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: EmojiPackEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: ClassifiedsEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: PinListEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: RelaySetEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: AudioTrackEvent) {
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: StatusEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -503,30 +360,14 @@ object LocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun consume(event: BadgeDefinitionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList<Note>())
author.liveSet?.innerStatuses?.invalidateData()
refreshObservers(note)
}
}
fun consume(event: BadgeDefinitionEvent) { consumeBaseReplaceable(event) }
fun consume(event: BadgeProfilesEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -541,7 +382,7 @@ object LocalCache {
if (note.event?.id() == event.id()) return
val replyTo = event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } +
event.badgeAwardDefinitions().mapNotNull { getOrCreateAddressableNote(it) }
event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) }
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, replyTo)
@@ -571,7 +412,14 @@ object LocalCache {
refreshObservers(note)
}
private fun comsume(event: NNSEvent) {
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 consumeBaseReplaceable(event: BaseAddressableEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -591,45 +439,7 @@ object LocalCache {
}
}
fun consume(event: AppDefinitionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun consume(event: AppRecommendationEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun consume(event: AppRecommendationEvent) { consumeBaseReplaceable(event) }
@Suppress("UNUSED_PARAMETER")
fun consume(event: RecommendRelayEvent) {
@@ -729,7 +539,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, repliesTo)
@@ -751,7 +561,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, repliesTo)
@@ -840,7 +650,7 @@ object LocalCache {
mentions.forEach {
// doesn't add to reports, but triggers recounts
it.liveSet?.reports?.invalidateData()
it.liveSet?.innerReports?.invalidateData()
}
}
@@ -1301,7 +1111,7 @@ object LocalCache {
}
return notes.values.filter {
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent) &&
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent && it.event !is LnZapEvent && it.event !is LnZapRequestEvent) &&
(
it.event?.content()?.contains(text, true) ?: false ||
it.event?.matchTag1With(text) ?: false ||
@@ -1309,7 +1119,7 @@ object LocalCache {
it.idNote().startsWith(text, true)
)
} + addressables.values.filter {
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent) &&
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent && it.event !is LnZapEvent && it.event !is LnZapRequestEvent) &&
(
it.event?.content()?.contains(text, true) ?: false ||
it.event?.matchTag1With(text) ?: false ||
@@ -1338,6 +1148,18 @@ object LocalCache {
}
}
suspend fun findStatusesForUser(user: User): ImmutableList<AddressableNote> {
checkNotInMainThread()
return addressables.filter {
val noteEvent = it.value.event
(noteEvent is StatusEvent && noteEvent.pubKey == user.pubkeyHex && !noteEvent.isExpired() && noteEvent.content.isNotBlank())
}.values
.sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex }))
.reversed()
.toImmutableList()
}
fun cleanObservers() {
notes.forEach {
it.value.clearLive()
@@ -1361,13 +1183,7 @@ object LocalCache {
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
// Doesn't need to clean up the replies and mentions.. Too small to matter.
// Counts the replies
it.replyTo?.forEach { _ ->
it.removeReply(it)
}
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
@@ -1386,12 +1202,7 @@ object LocalCache {
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
// Counts the replies
it.replyTo?.forEach { _ ->
it.removeReply(it)
}
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
@@ -1405,9 +1216,37 @@ object LocalCache {
}
}
fun pruneRepliesAndReactions(account: Account) {
fun prunePastVersionsOfReplaceables() {
val toBeRemoved = notes.filter {
val noteEvent = it.value.event
if (noteEvent is AddressableEvent) {
noteEvent.createdAt() < (addressables[noteEvent.address().toTag()]?.event?.createdAt() ?: 0)
} else {
false
}
}.values
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
val newerVersion = addressables[(it.event as? AddressableEvent)?.address()?.toTag()]
if (newerVersion != null) {
it.moveAllReferencesTo(newerVersion)
}
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} old version of addressables removed.")
}
}
fun pruneRepliesAndReactions(accounts: Set<HexKey>) {
checkNotInMainThread()
val user = account.userProfile()
val toBeRemoved = notes.filter {
(
@@ -1415,27 +1254,113 @@ object LocalCache {
it.value.event is ReactionEvent || it.value.event is LnZapEvent || it.value.event is LnZapRequestEvent ||
it.value.event is ReportEvent || it.value.event is GenericRepostEvent
) &&
it.value.replyTo?.any { it.liveSet?.isInUse() == true } != true &&
it.value.liveSet?.isInUse() != true && // don't delete if observing.
it.value.author != user && // don't delete if it is the logged in account
it.value.event?.isTaggedUser(user.pubkeyHex) != true // don't delete if it's a notification to the logged in user
it.value.author?.pubkeyHex !in accounts && // don't delete if it is the logged in account
it.value.event?.isTaggedUsers(accounts) != true // don't delete if it's a notification to the logged in user
}.values
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
it.replyTo?.forEach { masterNote ->
masterNote.removeReply(it)
masterNote.removeBoost(it)
masterNote.removeReaction(it)
masterNote.removeZap(it)
masterNote.clearEOSE() // allows reloading of these events
}
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
toBeRemoved.forEach {
it.replyTo?.forEach { masterNote ->
masterNote.clearEOSE() // allows reloading of these events
}
}
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
}
}
private fun removeFromCache(note: Note) {
note.replyTo?.forEach { masterNote ->
masterNote.removeReply(note)
masterNote.removeBoost(note)
masterNote.removeReaction(note)
masterNote.removeZap(note)
masterNote.removeReport(note)
masterNote.clearEOSE() // allows reloading of these events if needed
}
if (note.event is LnZapEvent) {
(note.event as LnZapEvent).zappedAuthor().mapNotNull {
val author = getUserIfExists(it)
author?.removeZap(note)
author?.clearEOSE()
}
}
if (note.event is LnZapRequestEvent) {
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull {
val author = getUserIfExists(it)
author?.removeZap(note)
author?.clearEOSE()
}
}
if (note.event is ReportEvent) {
(note.event as ReportEvent).reportedAuthor().mapNotNull {
val author = getUserIfExists(it.key)
author?.removeReport(note)
author?.clearEOSE()
}
}
notes.remove(note.idHex)
}
fun removeAuthorLinkTo(note: Note) {
if (note.event is LnZapEvent) {
(note.event as LnZapEvent).zappedAuthor().mapNotNull {
val author = getUserIfExists(it)
author?.removeZap(note)
author?.clearEOSE()
}
}
if (note.event is LnZapRequestEvent) {
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull {
val author = getUserIfExists(it)
author?.removeZap(note)
author?.clearEOSE()
}
}
if (note.event is ReportEvent) {
(note.event as ReportEvent).reportedAuthor().mapNotNull {
val author = getUserIfExists(it.key)
author?.removeReport(note)
author?.clearEOSE()
}
}
}
fun removeChildrenOf(nextToBeRemoved: List<Note>) {
nextToBeRemoved.forEach { note ->
removeFromCache(note)
}
}
fun pruneExpiredEvents() {
checkNotInMainThread()
val toBeRemoved = notes.filter {
it.value.event?.isExpired() == true
}.values
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
}
@@ -1457,17 +1382,7 @@ object LocalCache {
}.flatten()
toBeRemoved.forEach {
// Counts the replies
it.replyTo?.forEach { masterNote ->
masterNote.removeReply(it)
masterNote.removeBoost(it)
masterNote.removeReaction(it)
masterNote.removeZap(it)
masterNote.removeReport(it)
}
notes.remove(it.idHex)
removeFromCache(it)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
@@ -1476,27 +1391,15 @@ object LocalCache {
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
fun removeChildrenOf(nextToBeRemoved: List<Note>) {
nextToBeRemoved.forEach { note ->
if (note.event is LnZapEvent) {
(note.event as LnZapEvent).zappedAuthor().mapNotNull { getUserIfExists(it)?.removeZap(note) }
}
if (note.event is LnZapRequestEvent) {
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull { getUserIfExists(it)?.removeZap(note) }
}
if (note.event is ReportEvent) {
(note.event as ReportEvent).reportedAuthor().mapNotNull { getUserIfExists(it.key)?.removeZap(note) }
}
notes.remove(note.idHex)
}
}
fun pruneContactLists(userAccount: Account) {
fun pruneContactLists(loggedIn: Set<HexKey>) {
checkNotInMainThread()
var removingContactList = 0
users.values.forEach {
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false) && it.latestContactList != null) {
if (it.pubkeyHex !in loggedIn &&
(it.liveSet == null || it.liveSet?.isInUse() == false) &&
it.latestContactList != null
) {
it.latestContactList = null
removingContactList++
}
@@ -1547,6 +1450,10 @@ object LocalCache {
is BadgeDefinitionEvent -> consume(event)
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 ChannelCreateEvent -> consume(event)
is ChannelHideMessageEvent -> consume(event)
is ChannelMessageEvent -> consume(event, relay)
@@ -1576,9 +1483,10 @@ object LocalCache {
is LiveActivitiesChatMessageEvent -> consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let {
// must have a valid request
verifyAndConsume(it, relay)
consume(event)
}
consume(event)
}
is LnZapRequestEvent -> consume(event)
is LnZapPaymentRequestEvent -> consume(event)
@@ -1589,6 +1497,7 @@ object LocalCache {
is PrivateDmEvent -> consume(event, relay)
is PinListEvent -> consume(event)
is PeopleListEvent -> consume(event)
is PollNoteEvent -> consume(event, relay)
is ReactionEvent -> consume(event)
is RecommendRelayEvent -> consume(event)
is RelaySetEvent -> consume(event)
@@ -1605,8 +1514,9 @@ object LocalCache {
}
consume(event)
}
is StatusEvent -> consume(event, relay)
is TextNoteEvent -> consume(event, relay)
is PollNoteEvent -> consume(event, relay)
else -> {
Log.w("Event Not Supported", event.toJson())
}
@@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
@@ -10,6 +12,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
@@ -19,6 +22,7 @@ import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import java.math.BigDecimal
import java.time.Instant
@@ -123,7 +127,7 @@ open class Note(val idHex: String) {
this.author = author
this.replyTo = replyTo
liveSet?.metadata?.invalidateData()
liveSet?.innerMetadata?.invalidateData()
}
}
@@ -132,26 +136,60 @@ open class Note(val idHex: String) {
.format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
}
data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?)
/**
* This method caches signatures during each execution to avoid recalculation in longer threads
*/
fun replyLevelSignature(
eventsToConsider: Set<Note>,
cachedSignatures: MutableMap<Note, String>
): String {
cachedSignatures: MutableMap<Note, LevelSignature>,
account: User,
accountFollowingSet: Set<String>,
now: Long
): LevelSignature {
val replyTo = replyTo
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
return "/" + formattedDateTime(createdAt() ?: 0) + ";"
return LevelSignature(
signature = "/" + formattedDateTime(createdAt() ?: 0) + ";",
createdAt = createdAt(),
author = author
)
}
val mySignature = (
val parent = (
replyTo
.filter { it in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it] ?: it.replyLevelSignature(eventsToConsider, cachedSignatures).apply { cachedSignatures.put(it, this) }
cachedSignatures[it] ?: it.replyLevelSignature(
eventsToConsider,
cachedSignatures,
account,
accountFollowingSet,
now
).apply { cachedSignatures.put(it, this) }
}
.maxByOrNull { it.length }?.removeSuffix(";") ?: ""
) + "/" + formattedDateTime(createdAt() ?: 0) + ";"
.maxByOrNull { it.signature.length }
)
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
val threadOrder = if (parent?.author == author && createdAt() != null) {
// author of the thread first, in **ascending** order
"9" + formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0)))
} else if (author?.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(createdAt() ?: 0) // my replies
} else if (author?.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(createdAt() ?: 0) // my follows replies.
} else {
"0" + formattedDateTime(createdAt() ?: 0) // everyone else.
}
val mySignature = LevelSignature(
signature = parentSignature + "/" + threadOrder + ";",
createdAt = createdAt(),
author = author
)
cachedSignatures[this] = mySignature
return mySignature
@@ -171,21 +209,21 @@ open class Note(val idHex: String) {
fun addReply(note: Note) {
if (note !in replies) {
replies = replies + note
liveSet?.replies?.invalidateData()
liveSet?.innerReplies?.invalidateData()
}
}
fun removeReply(note: Note) {
if (note in replies) {
replies = replies - note
liveSet?.replies?.invalidateData()
liveSet?.innerReplies?.invalidateData()
}
}
fun removeBoost(note: Note) {
if (note in boosts) {
boosts = boosts - note
liveSet?.boosts?.invalidateData()
liveSet?.innerBoosts?.invalidateData()
}
}
@@ -208,11 +246,11 @@ open class Note(val idHex: String) {
relays = listOf<String>()
lastReactionsDownloadTime = emptyMap()
liveSet?.replies?.invalidateData()
liveSet?.reactions?.invalidateData()
liveSet?.boosts?.invalidateData()
liveSet?.reports?.invalidateData()
liveSet?.zaps?.invalidateData()
liveSet?.innerReplies?.invalidateData()
liveSet?.innerReactions?.invalidateData()
liveSet?.innerBoosts?.invalidateData()
liveSet?.innerReports?.invalidateData()
liveSet?.innerZaps?.invalidateData()
return toBeRemoved
}
@@ -231,7 +269,7 @@ open class Note(val idHex: String) {
reactions = reactions + Pair(reaction, newList)
}
liveSet?.reactions?.invalidateData()
liveSet?.innerReactions?.invalidateData()
}
}
}
@@ -243,7 +281,7 @@ open class Note(val idHex: String) {
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
}
}
}
@@ -251,29 +289,28 @@ open class Note(val idHex: String) {
fun removeZap(note: Note) {
if (zaps[note] != null) {
zaps = zaps.minus(note)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
} else if (zaps.containsValue(note)) {
val toRemove = zaps.filterValues { it == note }
zaps = zaps.minus(toRemove.keys)
liveSet?.zaps?.invalidateData()
zaps = zaps.filterValues { it != note }
liveSet?.innerZaps?.invalidateData()
}
}
fun removeZapPayment(note: Note) {
if (zapPayments[note] != null) {
zapPayments = zapPayments.minus(note)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
} else if (zapPayments.containsValue(note)) {
val toRemove = zapPayments.filterValues { it == note }
zapPayments = zapPayments.minus(toRemove.keys)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
fun addBoost(note: Note) {
if (note !in boosts) {
boosts = boosts + note
liveSet?.boosts?.invalidateData()
liveSet?.innerBoosts?.invalidateData()
}
}
@@ -295,12 +332,12 @@ open class Note(val idHex: String) {
if (zapRequest !in zaps.keys) {
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
} else if (zaps[zapRequest] == null) {
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
}
@@ -323,12 +360,12 @@ open class Note(val idHex: String) {
if (zapPaymentRequest !in zapPayments.keys) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) {
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
} else if (zapPayments[zapPaymentRequest] == null) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) {
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
}
@@ -339,10 +376,10 @@ open class Note(val idHex: String) {
if (reaction !in reactions.keys) {
reactions = reactions + Pair(reaction, listOf(note))
liveSet?.reactions?.invalidateData()
liveSet?.innerReactions?.invalidateData()
} else if (reactions[reaction]?.contains(note) == false) {
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
liveSet?.reactions?.invalidateData()
liveSet?.innerReactions?.invalidateData()
}
}
@@ -351,17 +388,17 @@ open class Note(val idHex: String) {
if (author !in reports.keys) {
reports = reports + Pair(author, listOf(note))
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
}
}
fun addRelay(relay: Relay) {
if (relay.url !in relays) {
relays = relays + relay.url
liveSet?.relays?.invalidateData()
liveSet?.innerRelays?.invalidateData()
}
}
@@ -461,10 +498,6 @@ open class Note(val idHex: String) {
}.flatten()
}
fun countReactions(): Int {
return reactions.values.sumOf { it.size }
}
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
// Regular Zap Receipts
val completedZaps = zaps.asSequence()
@@ -652,16 +685,54 @@ open class Note(val idHex: String) {
}
}
@Stable
class NoteLiveSet(u: Note) {
// Observers line up here.
val metadata: NoteLiveData = NoteLiveData(u)
val innerMetadata = NoteBundledRefresherLiveData(u)
val innerReactions = NoteBundledRefresherLiveData(u)
val innerBoosts = NoteBundledRefresherLiveData(u)
val innerReplies = NoteBundledRefresherLiveData(u)
val innerReports = NoteBundledRefresherLiveData(u)
val innerRelays = NoteBundledRefresherLiveData(u)
val innerZaps = NoteBundledRefresherLiveData(u)
val reactions: NoteLiveData = NoteLiveData(u)
val boosts: NoteLiveData = NoteLiveData(u)
val replies: NoteLiveData = NoteLiveData(u)
val reports: NoteLiveData = NoteLiveData(u)
val relays: NoteLiveData = NoteLiveData(u)
val zaps: NoteLiveData = NoteLiveData(u)
val metadata = innerMetadata.map { it }
val reactions = innerReactions.map { it }
val boosts = innerBoosts.map { it }
val replies = innerReplies.map { it }
val reports = innerReports.map { it }
val relays = innerRelays.map { it }
val zaps = innerZaps.map { it }
val authorChanges = innerMetadata.map {
it.note.author
}
val hasEvent = innerMetadata.map {
it.note.event != null
}.distinctUntilChanged()
val hasReactions = innerZaps.combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState ->
zapState?.note?.zaps?.isNotEmpty() ?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false
}.distinctUntilChanged()
val replyCount = innerReplies.map {
it.note.replies.size
}.distinctUntilChanged()
val reactionCount = innerReactions.map {
it.note.reactions.values.sumOf { it.size }
}.distinctUntilChanged()
val boostCount = innerBoosts.map {
it.note.boosts.size
}.distinctUntilChanged()
val boostList = innerBoosts.map {
it.note.boosts.toImmutableList()
}.distinctUntilChanged()
fun isInUse(): Boolean {
return metadata.hasObservers() ||
@@ -670,21 +741,28 @@ class NoteLiveSet(u: Note) {
replies.hasObservers() ||
reports.hasObservers() ||
relays.hasObservers() ||
zaps.hasObservers()
zaps.hasObservers() ||
authorChanges.hasObservers() ||
hasEvent.hasObservers() ||
hasReactions.hasObservers() ||
replyCount.hasObservers() ||
reactionCount.hasObservers() ||
boostCount.hasObservers() ||
boostList.hasObservers()
}
fun destroy() {
metadata.destroy()
reactions.destroy()
boosts.destroy()
replies.destroy()
reports.destroy()
relays.destroy()
zaps.destroy()
innerMetadata.destroy()
innerReactions.destroy()
innerBoosts.destroy()
innerReplies.destroy()
innerReports.destroy()
innerRelays.destroy()
innerZaps.destroy()
}
}
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
@@ -693,19 +771,26 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
}
fun invalidateData() {
if (!hasObservers()) return
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(NoteState(note))
}
postValue(NoteState(note))
}
}
fun <Y> map(
transform: (NoteState) -> Y
): NoteLoadingLiveData<Y> {
val initialValue = this.value?.let { transform(it) }
val result = NoteLoadingLiveData(note, initialValue)
result.addSource(this) { x -> result.value = transform(x) }
return result
}
}
class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
if (note is AddressableNote) {
@@ -3,6 +3,9 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.EOSETime
@@ -21,7 +24,6 @@ import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import java.math.BigDecimal
@@ -99,7 +101,11 @@ class User(val pubkeyHex: String) {
if (event.id == latestBookmarkList?.id) return
latestBookmarkList = event
liveSet?.bookmarks?.invalidateData()
liveSet?.innerBookmarks?.invalidateData()
}
fun clearEOSE() {
latestEOSEs = emptyMap()
}
fun updateContactList(event: ContactListEvent) {
@@ -109,18 +115,18 @@ class User(val pubkeyHex: String) {
latestContactList = event
// Update following of the current user
liveSet?.follows?.invalidateData()
liveSet?.innerFollows?.invalidateData()
// Update Followers of the past user list
// Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
}
liveSet?.relays?.invalidateData()
liveSet?.innerRelays?.invalidateData()
}
fun addReport(note: Note) {
@@ -128,10 +134,10 @@ class User(val pubkeyHex: String) {
if (author !in reports.keys) {
reports = reports + Pair(author, setOf(note))
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
}
}
@@ -141,7 +147,7 @@ class User(val pubkeyHex: String) {
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.reports?.invalidateData()
liveSet?.innerReports?.invalidateData()
}
}
}
@@ -149,20 +155,20 @@ class User(val pubkeyHex: String) {
fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
fun removeZap(zapRequestOrZapEvent: Note) {
if (zapRequestOrZapEvent in zaps.keys) {
zaps = zaps.minus(zapRequestOrZapEvent)
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
} else if (zapRequestOrZapEvent in zaps.values) {
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
liveSet?.zaps?.invalidateData()
liveSet?.innerZaps?.invalidateData()
}
}
@@ -216,7 +222,7 @@ class User(val pubkeyHex: String) {
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData()
liveSet?.innerMessages?.invalidateData()
}
}
@@ -224,7 +230,7 @@ class User(val pubkeyHex: String) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData()
liveSet?.innerMessages?.invalidateData()
}
}
@@ -238,7 +244,7 @@ class User(val pubkeyHex: String) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.removeMessageSync(msg)
liveSet?.messages?.invalidateData()
liveSet?.innerMessages?.invalidateData()
}
}
@@ -253,7 +259,7 @@ class User(val pubkeyHex: String) {
here.counter++
}
liveSet?.relayInfo?.invalidateData()
liveSet?.innerRelayInfo?.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
@@ -278,7 +284,7 @@ class User(val pubkeyHex: String) {
}
}
liveSet?.metadata?.invalidateData()
liveSet?.innerMetadata?.invalidateData()
}
fun isFollowing(user: User): Boolean {
@@ -378,40 +384,72 @@ class User(val pubkeyHex: String) {
}
}
@Stable
class UserLiveSet(u: User) {
val innerMetadata = UserBundledRefresherLiveData(u)
// UI Observers line up here.
val follows: UserLiveData = UserLiveData(u)
val followers: UserLiveData = UserLiveData(u)
val reports: UserLiveData = UserLiveData(u)
val messages: UserLiveData = UserLiveData(u)
val relays: UserLiveData = UserLiveData(u)
val relayInfo: UserLiveData = UserLiveData(u)
val metadata: UserLiveData = UserLiveData(u)
val zaps: UserLiveData = UserLiveData(u)
val bookmarks: UserLiveData = UserLiveData(u)
val innerFollows = UserBundledRefresherLiveData(u)
val innerFollowers = UserBundledRefresherLiveData(u)
val innerReports = UserBundledRefresherLiveData(u)
val innerMessages = UserBundledRefresherLiveData(u)
val innerRelays = UserBundledRefresherLiveData(u)
val innerRelayInfo = UserBundledRefresherLiveData(u)
val innerZaps = UserBundledRefresherLiveData(u)
val innerBookmarks = UserBundledRefresherLiveData(u)
val innerStatuses = UserBundledRefresherLiveData(u)
// UI Observers line up here.
val metadata = innerMetadata.map { it }
val follows = innerFollows.map { it }
val followers = innerFollowers.map { it }
val reports = innerReports.map { it }
val messages = innerMessages.map { it }
val relays = innerRelays.map { it }
val relayInfo = innerRelayInfo.map { it }
val zaps = innerZaps.map { it }
val bookmarks = innerBookmarks.map { it }
val statuses = innerStatuses.map { it }
val profilePictureChanges = innerMetadata.map {
it.user.profilePicture()
}.distinctUntilChanged()
val nip05Changes = innerMetadata.map {
it.user.nip05()
}.distinctUntilChanged()
val userMetadataInfo = innerMetadata.map {
it.user.info
}.distinctUntilChanged()
fun isInUse(): Boolean {
return follows.hasObservers() ||
return metadata.hasObservers() ||
follows.hasObservers() ||
followers.hasObservers() ||
reports.hasObservers() ||
messages.hasObservers() ||
relays.hasObservers() ||
relayInfo.hasObservers() ||
metadata.hasObservers() ||
zaps.hasObservers() ||
bookmarks.hasObservers()
bookmarks.hasObservers() ||
statuses.hasObservers() ||
profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() ||
userMetadataInfo.hasObservers()
}
fun destroy() {
follows.destroy()
followers.destroy()
reports.destroy()
messages.destroy()
relays.destroy()
relayInfo.destroy()
metadata.destroy()
zaps.destroy()
bookmarks.destroy()
innerMetadata.destroy()
innerFollows.destroy()
innerFollowers.destroy()
innerReports.destroy()
innerMessages.destroy()
innerRelays.destroy()
innerRelayInfo.destroy()
innerZaps.destroy()
innerBookmarks.destroy()
innerStatuses.destroy()
}
}
@@ -422,64 +460,7 @@ data class RelayInfo(
var counter: Long
)
@Stable
class Chatroom() {
var roomMessages: Set<Note> = setOf()
var subject: String? = null
var subjectCreatedAt: Long? = null
@Synchronized
fun addMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
val newSubject = msg.event?.subject()
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
subject = newSubject
subjectCreatedAt = msg.createdAt()
}
}
}
@Synchronized
fun removeMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let {
subject = it.event?.subject()
subjectCreatedAt = it.createdAt()
}
}
}
fun senderIntersects(keySet: Set<HexKey>): Boolean {
return roomMessages.any { it.author?.pubkeyHex in keySet }
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
// Recent messages, keep last 100
sorted.take(100).toSet()
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.liveSet?.isInUse() ?: false }
val toRemove = roomMessages.minus(toKeep)
roomMessages = toKeep
return toRemove
}
}
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
@@ -488,18 +469,26 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
}
fun invalidateData() {
if (!hasObservers()) return
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(UserState(user))
}
postValue(UserState(user))
}
}
fun <Y> map(
transform: (UserState) -> Y
): UserLoadingLiveData<Y> {
val initialValue = this.value?.let { transform(it) }
val result = UserLoadingLiveData(user, initialValue)
result.addSource(this) { x -> result.value = transform(x) }
return result
}
}
class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
NostrSingleUserDataSource.add(user)
@@ -47,13 +47,13 @@ class Nip05Verifier() {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct")
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct")
onError("Could not resolve $url. Check if the server is up and if the address $nip05 is correct")
e.printStackTrace()
}
})
@@ -52,9 +52,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(AdvertisedRelayListEvent.kind),
kinds = listOf(AdvertisedRelayListEvent.kind, StatusEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
limit = 5
)
)
}
@@ -49,7 +49,7 @@ abstract class NostrDataSource(val debugName: String) {
// Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
}
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
override fun onRelayStateChange(type: Relay.Type, relay: Relay, subscriptionId: String?) {
// Log.d("RELAY", "Relay ${relay.url} ${when (type) {
// Relay.Type.CONNECT -> "connected."
// Relay.Type.DISCONNECT -> "disconnected."
@@ -57,9 +57,9 @@ abstract class NostrDataSource(val debugName: String) {
// Relay.Type.EOSE -> "sent all events it had stored."
// }}")
if (type == Relay.Type.EOSE && channel != null) {
if (type == Relay.Type.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
// updates a per subscripton since date
subscriptions[channel]?.updateEOSE(TimeUtils.now(), relay.url)
subscriptions[subscriptionId]?.updateEOSE(TimeUtils.now(), relay.url)
}
}
@@ -111,12 +111,14 @@ abstract class NostrDataSource(val debugName: String) {
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateFilters() {
bundler.invalidate() {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
scope.launch(Dispatchers.IO) {
bundler.invalidate() {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
}
}
}
@@ -18,53 +18,66 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
val latestEOSEs = EOSEAccount()
fun createLiveStreamFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
fun createLiveStreamFilter(): List<TypedFilter> {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
val followKeys = follows?.map {
it.substring(0, 8)
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = follows,
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
),
follows?.let {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
tags = mapOf("p" to it),
kinds = listOf(LiveActivitiesEvent.kind),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
}
)
}
fun createPublicChatFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
fun createPublicChatFilter(): List<TypedFilter> {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
val followChats = account.selectedChatsFollowList().toList()
val followKeys = follows?.map {
it.substring(0, 8)
}
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
return listOf(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
authors = follows,
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
),
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
)
}
fun createCommunitiesFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 8)
}
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
authors = follows,
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
@@ -197,16 +210,16 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
}
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters = listOfNotNull(
createLiveStreamFilter(),
createPublicChatFilter(),
createCommunitiesFilter(),
createLiveStreamTagsFilter(),
createPublicChatsTagsFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsGeohashesFilter(),
createLiveStreamGeohashesFilter()
discoveryFeedChannel.typedFilters = createLiveStreamFilter().plus(createPublicChatFilter()).plus(
listOfNotNull(
createLiveStreamTagsFilter(),
createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
createPublicChatsTagsFilter(),
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsGeohashesFilter()
)
).ifEmpty { null }
}
}
@@ -55,12 +55,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
val followKeys = follows?.map {
it.substring(0, 8)
}
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 8))
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -11,10 +11,10 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
private var channelsToWatch = setOf<String>()
private var channelsToWatch = setOf<Channel>()
private fun createRepliesAndReactionsFilter(): TypedFilter? {
val reactionsToWatch = channelsToWatch.map { it }
private fun createMetadataChangeFilter(): TypedFilter? {
val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex }
if (reactionsToWatch.isEmpty()) {
return null
@@ -32,7 +32,6 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad = channelsToWatch
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filter { it.notes.isEmpty() && it is PublicChatChannel }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
@@ -53,7 +52,6 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = channelsToWatch
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filterIsInstance<LiveActivitiesChannel>()
.filter { it.info == null }
@@ -81,7 +79,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
val singleChannelChannel = requestNewChannel()
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val reactions = createMetadataChangeFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
@@ -90,13 +88,17 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
).ifEmpty { null }
}
fun add(eventId: String) {
channelsToWatch = channelsToWatch.plus(eventId)
invalidateFilters()
fun add(eventId: Channel) {
if (eventId !in channelsToWatch) {
channelsToWatch = channelsToWatch.plus(eventId)
invalidateFilters()
}
}
fun remove(eventId: String) {
channelsToWatch = channelsToWatch.minus(eventId)
invalidateFilters()
fun remove(eventId: Channel) {
if (eventId in channelsToWatch) {
channelsToWatch = channelsToWatch.minus(eventId)
invalidateFilters()
}
}
}
@@ -7,38 +7,65 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.StatusEvent
object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
var usersToWatch = setOf<User>()
fun createUserFilter(): List<TypedFilter>? {
fun createUserMetadataFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex }
return listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
kinds = listOf(MetadataEvent.kind, StatusEvent.kind),
authors = firstTimers,
limit = 10 * firstTimers.size
)
)
}
)
}
fun createUserReportFilter(): List<TypedFilter>? {
fun createUserMetadataFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.map {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex)),
since = it.latestEOSEs
)
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(StatusEvent.kind),
authors = usersToWatch.map { it.pubkeyHex },
since = minLatestEOSEs
)
}
)
}
fun createUserStatusFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(StatusEvent.kind),
authors = usersToWatch.map { it.pubkeyHex },
since = minLatestEOSEs
)
)
}
fun createUserReportFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to usersToWatch.map { it.pubkeyHex }),
since = minLatestEOSEs
)
)
}
val userChannel = requestNewChannel() { time, relayUrl ->
@@ -50,17 +77,40 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
eose.time = time
}
}
}
val userChannelFirstTimers = requestNewChannel() { time, relayUrl ->
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
val userChannelOnce = requestNewChannel()
override fun updateChannelFilters() {
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
val neverGottenAnEOSE = mutableSetOf<String>()
usersToWatch.forEach {
if (it.latestEOSEs.isEmpty()) { // first time
neverGottenAnEOSE.add(it.pubkeyHex)
} else {
it.latestEOSEs.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
} else if (it.value.time < minEose.time) {
minEose.time = it.value.time
}
}
}
}
userChannel.typedFilters = listOfNotNull(
createUserMetadataFilter(minLatestEOSEs),
createUserStatusFilter(minLatestEOSEs),
createUserReportFilter(minLatestEOSEs)
).ifEmpty { null }
userChannelFirstTimers.typedFilters = listOfNotNull(
createUserMetadataFilter()
).flatten().ifEmpty { null }
}
fun add(user: User) {
@@ -36,8 +36,10 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
}
fun loadThread(noteId: String?) {
eventToWatch = noteId
if (eventToWatch != noteId) {
eventToWatch = noteId
invalidateFilters()
invalidateFilters()
}
}
}
@@ -14,16 +14,12 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
val latestEOSEs = EOSEAccount()
fun createContextualFilter(): TypedFilter? {
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)?.toList()
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
authors = follows,
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
@@ -57,12 +57,12 @@ class LightningAddressResolver() {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
onError("The receiver's lightning service at $url is not available. It was calculated from the lightning address \"${lnaddress}\". Error: ${it.code}. Check if the server is up and if the lightning address is correct")
}
}
} catch (e: Exception) {
e.printStackTrace()
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
onError("Could not resolve $url. Check if the server is up and if the lightning address $lnaddress is correct")
}
}
@@ -20,10 +20,11 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import kotlinx.collections.immutable.persistentSetOf
import java.math.BigDecimal
class EventNotificationConsumer(private val applicationContext: Context) {
fun consume(event: Event) {
suspend fun consume(event: Event) {
if (LocalCache.notes[event.id] == null) {
if (LocalCache.justVerify(event)) {
LocalCache.justConsume(event, null)
@@ -40,7 +41,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
}
fun unwrapAndConsume(event: Event, account: Account): Event? {
suspend fun unwrapAndConsume(event: Event, account: Account): Event? {
if (!LocalCache.justVerify(event)) return null
return when (event) {
@@ -65,7 +66,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
}
private fun unwrapAndNotify(giftWrap: GiftWrapEvent) {
private suspend fun unwrapAndNotify(giftWrap: GiftWrapEvent) {
val giftWrapNote = LocalCache.notes[giftWrap.id] ?: return
LocalPreferences.allSavedAccounts().forEach {
@@ -129,15 +130,17 @@ class EventNotificationConsumer(private val applicationContext: Context) {
private fun notify(event: LnZapEvent) {
val noteZapEvent = LocalCache.notes[event.id] ?: return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) }
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
val amount = showAmount(event.amount)
val senderInfo = (noteZapRequest?.event as? LnZapRequestEvent)?.let {
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
if (decryptedContent != null) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
@@ -60,7 +60,7 @@ class JsonFilter(
} else {
val jsonObjectSince = factory.objectNode()
entries.forEach { sincePairs ->
put(sincePairs.key, "${sincePairs.value}")
jsonObjectSince.put(sincePairs.key, "${sincePairs.value}")
}
put("since", jsonObjectSince)
}
@@ -284,16 +284,36 @@ class Relay(
checkNotInMainThread()
if (signedEvent is RelayAuthEvent) {
// specific protocol for this event.
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
} else {
if (write) {
if (isConnected()) {
if (isReady) {
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
} else {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
// sends all filters after connection is successful.
connectAndRun {
checkNotInMainThread()
if (write) {
if (signedEvent !is RelayAuthEvent) {
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
val event = """["EVENT",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
}
}
}
}
}
}
@@ -103,6 +103,7 @@ class MainActivity : AppCompatActivity() {
}
override fun onPause() {
ServiceManager.cleanObservers()
// if (BuildConfig.DEBUG) {
debugState(this)
// }
@@ -127,7 +128,7 @@ class MainActivity : AppCompatActivity() {
super.onTrimMemory(level)
println("Trim Memory $level")
GlobalScope.launch(Dispatchers.Default) {
ServiceManager.cleanUp()
ServiceManager.trimMemory()
}
}
@@ -120,7 +120,7 @@ fun JoinUserOrChannelView(searchBarViewModel: SearchBarViewModel, onClose: () ->
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear()
onClose()
@@ -51,7 +51,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.clear()
onClose()
})
@@ -123,7 +123,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.cancel()
onClose()
})
@@ -1,9 +1,11 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
@@ -11,8 +13,6 @@ import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -21,51 +21,11 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
var textMax by rememberSaveable { mutableStateOf("") }
var textMin by rememberSaveable { mutableStateOf("") }
// check for zapMax amounts < 1
pollViewModel.isValidvalueMaximum.value = true
if (textMax.isNotEmpty()) {
try {
val int = textMax.toInt()
if (int < 1) {
pollViewModel.isValidvalueMaximum.value = false
} else { pollViewModel.valueMaximum = int }
} catch (e: Exception) { pollViewModel.isValidvalueMaximum.value = false }
}
// check for minZap amounts < 1
pollViewModel.isValidvalueMinimum.value = true
if (textMin.isNotEmpty()) {
try {
val int = textMin.toInt()
if (int < 1) {
pollViewModel.isValidvalueMinimum.value = false
} else { pollViewModel.valueMinimum = int }
} catch (e: Exception) { pollViewModel.isValidvalueMinimum.value = false }
}
// check for zapMin > zapMax
if (textMin.isNotEmpty() && textMax.isNotEmpty()) {
try {
val intMin = textMin.toInt()
val intMax = textMax.toInt()
if (intMin > intMax) {
pollViewModel.isValidvalueMinimum.value = false
pollViewModel.isValidvalueMaximum.value = false
}
} catch (e: Exception) {
pollViewModel.isValidvalueMinimum.value = false
pollViewModel.isValidvalueMaximum.value = false
}
}
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
@@ -80,10 +40,10 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
horizontalArrangement = Arrangement.Center
) {
OutlinedTextField(
value = textMin,
onValueChange = { textMin = it },
value = pollViewModel.valueMinimum?.toString() ?: "",
onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
modifier = Modifier.weight(1f),
colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid,
label = {
Text(
@@ -98,11 +58,14 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
)
}
)
Spacer(modifier = DoubleHorzSpacer)
OutlinedTextField(
value = textMax,
onValueChange = { textMax = it },
value = pollViewModel.valueMaximum?.toString() ?: "",
onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
modifier = Modifier.weight(1f),
colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid,
label = {
Text(
@@ -118,10 +81,25 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
}
)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.poll_zap_value_min_max_explainer),
color = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(vertical = 10.dp)
)
}
}
@Preview
@Composable
fun NewPollVoteValueRangePreview() {
NewPollVoteValueRange(NewPostViewModel())
Column(
modifier = Modifier.fillMaxWidth()
) {
NewPollVoteValueRange(NewPostViewModel())
}
}
@@ -42,6 +42,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -97,6 +98,7 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
@@ -165,314 +167,340 @@ fun NewPostView(
decorFitsSystemWindows = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
if (showRelaysDialog) {
RelaySelectionDialog(
list = relayList,
onClose = {
showRelaysDialog = false
},
onPost = {
relayList = it
},
accountViewModel = accountViewModel,
nav = nav
)
}
if (showRelaysDialog) {
RelaySelectionDialog(
list = relayList,
onClose = {
showRelaysDialog = false
},
onPost = {
relayList = it
},
accountViewModel = accountViewModel,
nav = nav
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
Column(
modifier = Modifier
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
.imePadding()
.weight(1f)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(end = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = StdHorzSpacer)
Box {
IconButton(
modifier = Modifier.align(Alignment.Center),
onClick = {
showRelaysDialog = true
}
) {
Icon(
painter = painterResource(R.drawable.relays),
contentDescription = null,
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colors.onBackground
)
}
}
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
postViewModel.sendPost(relayList = relayList)
onClose()
}
},
isActive = postViewModel.canPost()
)
}
},
navigationIcon = {
Spacer(modifier = StdHorzSpacer)
CloseButton(onPress = {
postViewModel.cancel()
onClose()
})
Box {
IconButton(
modifier = Modifier.align(Alignment.Center),
onClick = {
showRelaysDialog = true
}
) {
Icon(
painter = painterResource(R.drawable.relays),
contentDescription = null,
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colors.onBackground
)
}
}
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
postViewModel.sendPost(relayList = relayList)
onClose()
}
},
isActive = postViewModel.canPost()
)
}
Row(
},
backgroundColor = MaterialTheme.colors.surface,
elevation = 0.dp
)
}
) { pad ->
Surface(
modifier = Modifier
.padding(
start = Size10dp,
top = pad.calculateTopPadding(),
end = Size10dp,
bottom = pad.calculateBottomPadding()
)
.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp)
.imePadding()
.weight(1f)
) {
Column(
Row(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
.weight(1f)
) {
postViewModel.originalNote?.let {
NoteCompose(
baseNote = it,
makeItShort = true,
unPackReply = false,
isQuotedNote = true,
modifier = MaterialTheme.colors.replyModifier,
accountViewModel = accountViewModel,
nav = nav
)
}
Notifying(postViewModel.mentions?.toImmutableList()) {
postViewModel.removeFromReplyList(it)
}
if (enableMessageInterface) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
SendDirectMessageTo(postViewModel = postViewModel)
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
) {
postViewModel.originalNote?.let {
Row(Modifier.heightIn(max = 200.dp)) {
NoteCompose(
baseNote = it,
makeItShort = true,
unPackReply = false,
isQuotedNote = true,
modifier = MaterialTheme.colors.replyModifier,
accountViewModel = accountViewModel,
nav = nav
)
Spacer(modifier = StdVertSpacer)
}
}
}
MessageField(postViewModel)
if (postViewModel.wantsPoll) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
PollField(postViewModel)
Row() {
Notifying(postViewModel.mentions?.toImmutableList()) {
postViewModel.removeFromReplyList(it)
}
}
}
if (postViewModel.wantsToMarkAsSensitive) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = Size5dp, horizontal = Size10dp)
) {
ContentSensitivityExplainer(postViewModel)
if (enableMessageInterface) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
SendDirectMessageTo(postViewModel = postViewModel)
}
}
}
if (postViewModel.wantsToAddGeoHash) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = Size5dp, horizontal = Size10dp)
) {
LocationAsHash(postViewModel)
MessageField(postViewModel)
if (postViewModel.wantsPoll) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
PollField(postViewModel)
}
}
}
if (postViewModel.wantsForwardZapTo) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
FowardZapTo(postViewModel)
if (postViewModel.wantsToMarkAsSensitive) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = Size5dp, horizontal = Size10dp)
) {
ContentSensitivityExplainer(postViewModel)
}
}
}
val url = postViewModel.contentToAddUrl
if (url != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
ImageVideoDescription(
url,
account.defaultFileServer,
onAdd = { description, server, sensitiveContent ->
postViewModel.upload(url, description, sensitiveContent, server, context, relayList)
account.changeDefaultFileServer(server)
},
onCancel = {
postViewModel.contentToAddUrl = null
},
onError = {
scope.launch {
postViewModel.imageUploadingError.emit(it)
}
},
accountViewModel = accountViewModel
)
if (postViewModel.wantsToAddGeoHash) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = Size5dp, horizontal = Size10dp)
) {
LocationAsHash(postViewModel)
}
}
}
val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress()
if (postViewModel.wantsForwardZapTo) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
FowardZapTo(postViewModel)
}
}
if (lud16 != null && postViewModel.wantsInvoice) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
Column(Modifier.fillMaxWidth()) {
InvoiceRequest(
lud16,
user.pubkeyHex,
account,
stringResource(id = R.string.lightning_invoice),
stringResource(id = R.string.lightning_create_and_add_invoice),
onSuccess = {
postViewModel.message = TextFieldValue(postViewModel.message.text + "\n\n" + it)
postViewModel.wantsInvoice = false
val url = postViewModel.contentToAddUrl
if (url != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
ImageVideoDescription(
url,
account.defaultFileServer,
onAdd = { description, server, sensitiveContent ->
postViewModel.upload(url, description, sensitiveContent, server, context, relayList)
account.changeDefaultFileServer(server)
},
onClose = {
postViewModel.wantsInvoice = false
}
onCancel = {
postViewModel.contentToAddUrl = null
},
onError = {
scope.launch {
postViewModel.imageUploadingError.emit(it)
}
},
accountViewModel = accountViewModel
)
}
}
}
if (lud16 != null && postViewModel.wantsZapraiser) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
ZapRaiserRequest(
stringResource(id = R.string.zapraiser),
postViewModel
)
}
}
val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress()
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl =
myUrlPreview.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
if (lud16 != null && postViewModel.wantsInvoice) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
Column(Modifier.fillMaxWidth()) {
InvoiceRequest(
lud16,
user.pubkeyHex,
account,
stringResource(id = R.string.lightning_invoice),
stringResource(id = R.string.lightning_create_and_add_invoice),
onSuccess = {
postViewModel.message = TextFieldValue(postViewModel.message.text + "\n\n" + it)
postViewModel.wantsInvoice = false
},
onClose = {
postViewModel.wantsInvoice = false
}
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
VideoView(myUrlPreview, roundedCorner = true, accountViewModel = accountViewModel)
} else {
UrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
}
} else if (startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colors.background
val backgroundColor = remember {
mutableStateOf(bgColor)
}
}
}
BechLink(
myUrlPreview,
true,
backgroundColor,
accountViewModel,
nav
if (lud16 != null && postViewModel.wantsZapraiser) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
ZapRaiserRequest(
stringResource(id = R.string.zapraiser),
postViewModel
)
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
}
}
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl =
myUrlPreview.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
VideoView(myUrlPreview, roundedCorner = true, accountViewModel = accountViewModel)
} else {
UrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
}
} else if (startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colors.background
val backgroundColor = remember {
mutableStateOf(bgColor)
}
BechLink(
myUrlPreview,
true,
backgroundColor,
accountViewModel,
nav
)
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
}
}
}
}
}
}
val userSuggestions = postViewModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp
),
modifier = Modifier.heightIn(0.dp, 300.dp)
) {
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex }
) { _, item ->
UserLine(item, accountViewModel) {
postViewModel.autocompleteWithUser(item)
val userSuggestions = postViewModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp
),
modifier = Modifier.heightIn(0.dp, 300.dp)
) {
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex }
) { _, item ->
UserLine(item, accountViewModel) {
postViewModel.autocompleteWithUser(item)
}
}
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
verticalAlignment = Alignment.CenterVertically
) {
UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colors.onBackground,
Row(
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
verticalAlignment = Alignment.CenterVertically
) {
postViewModel.selectImage(it)
}
if (postViewModel.canUsePoll) {
// These should be hashtag recommendations the user selects in the future.
// val hashtag = stringResource(R.string.poll_hashtag)
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colors.onBackground,
modifier = Modifier
) {
postViewModel.selectImage(it)
}
}
if (postViewModel.canAddInvoice) {
AddLnInvoiceButton(postViewModel.wantsInvoice) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
if (postViewModel.canUsePoll) {
// These should be hashtag recommendations the user selects in the future.
// val hashtag = stringResource(R.string.poll_hashtag)
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
}
}
}
if (postViewModel.canAddZapRaiser) {
AddZapraiserButton(postViewModel.wantsZapraiser) {
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
if (postViewModel.canAddInvoice) {
AddLnInvoiceButton(postViewModel.wantsInvoice) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
}
}
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
}
if (postViewModel.canAddZapRaiser) {
AddZapraiserButton(postViewModel.wantsZapraiser) {
postViewModel.wantsZapraiser = !postViewModel.wantsZapraiser
}
}
AddGeoHash(postViewModel) {
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
}
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
}
ForwardZapTo(postViewModel) {
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
AddGeoHash(postViewModel) {
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
}
ForwardZapTo(postViewModel) {
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
}
}
}
}
@@ -490,6 +518,8 @@ private fun PollField(postViewModel: NewPostViewModel) {
NewPollOption(postViewModel, index)
}
NewPollVoteValueRange(postViewModel)
Button(
onClick = {
postViewModel.pollOptions[postViewModel.pollOptions.size] =
@@ -886,19 +916,18 @@ fun DisplayLocationInTitle(geohash: String) {
fun Notifying(baseMentions: ImmutableList<User>?, onClick: (User) -> Unit) {
val mentions = baseMentions?.toSet()
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
if (!mentions.isNullOrEmpty()) {
Text(
stringResource(R.string.reply_notify),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.placeholderText
color = MaterialTheme.colors.placeholderText,
modifier = Modifier.align(CenterVertically)
)
mentions.forEachIndexed { idx, user ->
val innerUserState by user.live().metadata.observeAsState()
innerUserState?.user?.let { myUser ->
Spacer(modifier = Modifier.width(5.dp))
val tags = remember(innerUserState) {
myUser.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
@@ -1159,10 +1188,10 @@ private fun MarkAsSensitive(
}
@Composable
fun CloseButton(onCancel: () -> Unit) {
fun CloseButton(onPress: () -> Unit) {
Button(
onClick = {
onCancel()
onPress()
},
shape = ButtonBorder,
colors = ButtonDefaults
@@ -73,8 +73,8 @@ open class NewPostViewModel() : ViewModel() {
var wantsPoll by mutableStateOf(false)
var zapRecipients = mutableStateListOf<HexKey>()
var pollOptions = newStateMapPollOptions()
var valueMaximum: Int? = null
var valueMinimum: Int? = null
var valueMaximum by mutableStateOf<Int?>(null)
var valueMinimum by mutableStateOf<Int?>(null)
var consensusThreshold: Int? = null
var closedAt: Int? = null
@@ -494,7 +494,7 @@ open class NewPostViewModel() : ViewModel() {
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount != null) &&
(!wantsDirectMessage || !toUsers.text.isNullOrBlank()) &&
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) &&
(!wantsPoll || (pollOptions.values.all { it.isNotEmpty() } && isValidvalueMinimum.value && isValidvalueMaximum.value)) &&
contentToAddUrl == null
}
@@ -612,6 +612,50 @@ open class NewPostViewModel() : ViewModel() {
nip24 = !nip24
}
}
fun updateMinZapAmountForPoll(textMin: String) {
if (textMin.isNotEmpty()) {
try {
val int = textMin.toInt()
if (int < 1) {
valueMinimum = null
} else {
valueMinimum = int
}
} catch (e: Exception) {}
} else {
valueMinimum = null
}
checkMinMax()
}
fun updateMaxZapAmountForPoll(textMax: String) {
if (textMax.isNotEmpty()) {
try {
val int = textMax.toInt()
if (int < 1) {
valueMaximum = null
} else {
valueMaximum = int
}
} catch (e: Exception) {}
} else {
valueMaximum = null
}
checkMinMax()
}
fun checkMinMax() {
if ((valueMinimum ?: 0) > (valueMaximum ?: Int.MAX_VALUE)) {
isValidvalueMinimum.value = false
isValidvalueMaximum.value = false
} else {
isValidvalueMinimum.value = true
isValidvalueMaximum.value = true
}
}
}
enum class GeohashPrecision(val digits: Int) {
@@ -129,7 +129,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
},
navigationIcon = {
Spacer(modifier = StdHorzSpacer)
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.clear()
onClose()
})
@@ -67,7 +67,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.clear()
onClose()
})
@@ -74,7 +74,7 @@ fun RelayInformationDialog(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
onClose()
})
}
@@ -110,7 +110,7 @@ fun RelaySelectionDialog(
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(
onCancel = {
onPress = {
onClose()
}
)
@@ -59,38 +59,38 @@ fun buildAnnotatedStringWithUrlHighlighting(text: AnnotatedString, color: Color)
)
)
newWord + restOfWord
} else if (Patterns.WEB_URL.matcher(word).matches()) {
val startIndex = builderBefore.toString().length
val endIndex = startIndex + word.length
val startNew = builderAfter.toString().length
val endNew = startNew + word.length
substitutions.add(
RangesChanges(
TextRange(startIndex, endIndex),
TextRange(startNew, endNew)
)
)
builderBefore.append("$word ")
builderAfter.append("$word ")
word
} else {
builderBefore.append(word + " ")
builderAfter.append(word + " ")
builderBefore.append("$word ")
builderAfter.append("$word ")
word
}
} catch (e: Exception) {
// if it can't parse the key, don't try to change.
builderBefore.append(word + " ")
builderAfter.append(word + " ")
builderBefore.append("$word ")
builderAfter.append("$word ")
word
}
}.joinToString(" ")
}.joinToString("\n")
)
val newText = toAnnotatedString()
newText.split("\\s+".toRegex()).filter { word ->
Patterns.WEB_URL.matcher(word).matches()
}.forEach {
val startIndex = text.indexOf(it)
val endIndex = startIndex + it.length
addStyle(
style = SpanStyle(
color = color,
textDecoration = TextDecoration.None
),
start = startIndex,
end = endIndex
)
}
substitutions.forEach {
addStyle(
style = SpanStyle(
@@ -35,7 +35,7 @@ class BundledUpdate(
return
}
scope.launch {
scope.launch(dispatcher) {
try {
onUpdate()
delay(delay)
@@ -77,7 +77,7 @@ class BundledInsert<T>(
return
}
scope.launch(Dispatchers.IO) {
scope.launch(dispatcher) {
try {
val mySet = mutableSetOf<T>()
queue.drainTo(mySet)
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
@@ -12,6 +13,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextDirection
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.quartz.encoders.LnWithdrawalUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -41,10 +43,7 @@ fun MayBeWithdrawal(lnurlWord: String) {
@Composable
fun ClickableWithdrawal(withdrawalString: String) {
val context = LocalContext.current
val uri = remember(withdrawalString) {
Uri.parse("lightning:$withdrawalString")
}
val scope = rememberCoroutineScope()
val withdraw = remember(withdrawalString) {
AnnotatedString("$withdrawalString ")
@@ -53,9 +52,18 @@ fun ClickableWithdrawal(withdrawalString: String) {
ClickableText(
text = withdraw,
onClick = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, uri)
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$withdrawalString"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
ContextCompat.startActivity(context, intent, null)
} catch (e: Exception) {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.lightning_wallets_not_found),
Toast.LENGTH_LONG
).show()
}
}
},
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
@@ -66,6 +67,7 @@ fun MayBeInvoicePreview(lnbcWord: String) {
@Composable
fun InvoicePreview(lnInvoice: String, amount: String?) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
modifier = Modifier
@@ -118,9 +120,18 @@ fun InvoicePreview(lnInvoice: String, amount: String?) {
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = {
runCatching {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(context, intent, null)
} catch (e: Exception) {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.lightning_wallets_not_found),
Toast.LENGTH_LONG
).show()
}
}
},
shape = QuoteBorder,
@@ -30,8 +30,6 @@ import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.em
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.material.MaterialRichText
@@ -421,9 +419,7 @@ private fun ObserveNIP19Event(
@Composable
fun ObserveNote(note: Note, onRefresh: () -> Unit) {
val loadedNoteId by note.live().metadata.map {
it.note.event?.id()
}.distinctUntilChanged().observeAsState(note.event?.id())
val loadedNoteId by note.live().metadata.observeAsState()
LaunchedEffect(key1 = loadedNoteId) {
if (loadedNoteId != null) {
@@ -460,9 +456,7 @@ private fun ObserveNIP19User(
@Composable
private fun ObserveUser(user: User, onRefresh: () -> Unit) {
val loadedUserMetaId by user.live().metadata.map {
it.user.info?.latestMetadata?.id
}.distinctUntilChanged().observeAsState(user.info?.latestMetadata?.id)
val loadedUserMetaId by user.live().metadata.observeAsState()
LaunchedEffect(key1 = loadedUserMetaId) {
if (loadedUserMetaId != null) {
@@ -854,9 +848,7 @@ private fun DisplayUserFromTag(
val route = remember { "User/${baseUser.pubkeyHex}" }
val hex = remember { baseUser.pubkeyDisplayHex() }
val meta by baseUser.live().metadata.map {
it.user.info
}.distinctUntilChanged().observeAsState(baseUser.info)
val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)
Crossfade(targetState = meta) {
Row() {
@@ -18,7 +18,6 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -30,9 +29,8 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SlidingCarousel(
pagerState: PagerState,
modifier: Modifier = Modifier,
pagerState: PagerState = remember { PagerState() },
itemsCount: Int,
itemContent: @Composable (index: Int) -> Unit
) {
val isDragged by pagerState.interactionSource.collectIsDraggedAsState()
@@ -40,7 +38,7 @@ fun SlidingCarousel(
Box(
modifier = modifier.fillMaxWidth()
) {
HorizontalPager(pageCount = itemsCount, state = pagerState) { page ->
HorizontalPager(state = pagerState) { page ->
itemContent(page)
}
@@ -55,7 +53,7 @@ fun SlidingCarousel(
) {
DotsIndicator(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
totalDots = itemsCount,
totalDots = pagerState.pageCount,
selectedIndex = if (isDragged) pagerState.currentPage else pagerState.targetPage,
dotSize = 8.dp
)
@@ -12,8 +12,6 @@ import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) {
@@ -37,10 +35,8 @@ fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel)
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
if (urlPreviewState == UrlPreviewState.Loading) {
LaunchedEffect(url) {
launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url) {
urlPreviewState = it
}
accountViewModel.urlPreview(url) {
urlPreviewState = it
}
}
}
@@ -704,6 +704,7 @@ fun ControlWhenPlayerIsActive(
controller.addListener(listener)
onDispose {
view.keepScreenOn = false
controller.removeListener(listener)
}
}
@@ -637,7 +637,7 @@ fun ZoomableImageDialog(
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
val pagerState: PagerState = rememberPagerState()
val pagerState: PagerState = rememberPagerState() { allImages.size }
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
val page = allImages.indexOf(imageUrl)
@@ -648,12 +648,10 @@ fun ZoomableImageDialog(
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index], false, accountViewModel)
}
)
pagerState = pagerState
) { index ->
RenderImageOrVideo(allImages[index], false, accountViewModel)
}
} else {
RenderImageOrVideo(imageUrl, false, accountViewModel)
}
@@ -665,7 +663,7 @@ fun ZoomableImageDialog(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onDismiss)
CloseButton(onPress = onDismiss)
val myContent = allImages[pagerState.currentPage]
if (myContent is ZoomableUrlContent) {
@@ -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.ExperimentalTime
import kotlin.time.measureTimedValue
class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -50,7 +49,6 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
.reversed()
}
@OptIn(ExperimentalTime::class)
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue {
val me = account.userProfile()
@@ -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.ExperimentalTime
import kotlin.time.measureTimedValue
class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -38,7 +37,6 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
.reversed()
}
@OptIn(ExperimentalTime::class)
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue {
val me = account.userProfile()
@@ -2,11 +2,9 @@ package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
abstract class FeedFilter<T> {
@OptIn(ExperimentalTime::class)
fun loadTop(): List<T> {
checkNotInMainThread()
@@ -33,7 +31,6 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
abstract fun applyFilter(collection: Set<T>): Set<T>
abstract fun sort(collection: Set<T>): List<T>
@OptIn(ExperimentalTime::class)
open fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
checkNotInMainThread()
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
@@ -34,6 +35,7 @@ class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
it.event is LongTextNoteEvent ||
it.event is ChannelMessageEvent ||
it.event is PrivateDmEvent ||
it.event is PollNoteEvent ||
it.event is AudioHeaderEvent
) &&
it.event?.isTaggedGeoHash(myTag) == true
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
@@ -34,6 +35,7 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
it.event is LongTextNoteEvent ||
it.event is ChannelMessageEvent ||
it.event is PrivateDmEvent ||
it.event is PollNoteEvent ||
it.event is AudioHeaderEvent
) &&
it.event?.isTaggedHash(myTag) == true
@@ -1,22 +1,29 @@
package com.vitorpamplona.amethyst.ui.dal
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class ThreadFeedFilter(val noteId: String) : FeedFilter<Note>() {
class ThreadFeedFilter(val account: Account, val noteId: String) : FeedFilter<Note>() {
override fun feedKey(): String {
return noteId
}
override fun feed(): List<Note> {
val cachedSignatures: MutableMap<Note, String> = mutableMapOf()
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: emptySet()
val cachedSignatures: MutableMap<Note, Note.LevelSignature> = mutableMapOf()
val followingSet = account.selectedUsersFollowList(KIND3_FOLLOWS) ?: emptySet()
val eventsToWatch = ThreadAssembler().findThreadFor(noteId)
val now = TimeUtils.now()
// Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature(eventsToWatch, cachedSignatures) }
val order = compareByDescending<Note> {
it.replyLevelSignature(eventsToWatch, cachedSignatures, account.userProfile(), followingSet, now).signature
}
return eventsToWatch.sortedWith(order)
}
@@ -3,6 +3,9 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -80,7 +83,12 @@ fun AppNavigation(
}
}
NavHost(navController, startDestination = Route.Home.route) {
NavHost(
navController,
startDestination = Route.Home.route,
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
Route.Home.let { route ->
composable(route.route, route.arguments, content = { it ->
val nip47 = it.arguments?.getString("nip47")
@@ -215,6 +223,7 @@ fun AppNavigation(
composable(route.route, route.arguments, content = {
ChatroomScreen(
roomId = it.arguments?.getString("id"),
draftMessage = it.arguments?.getString("message"),
accountViewModel = accountViewModel,
nav = nav
)
@@ -754,9 +754,9 @@ fun debugState(context: Context) {
Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.liveSet != null }.size + " / " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.liveSet != null }.size + " / " + LocalCache.addressables.filter { it.value.event != null }.size + "/" + LocalCache.addressables.size)
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.liveSet != null }.size + " / " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.liveSet != null }.size + " / " + LocalCache.notes.filter { it.value.event != null }.size + " / " + LocalCache.notes.size)
Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.liveSet != null }.size + " / " + LocalCache.addressables.filter { it.value.event != null }.size + " / " + LocalCache.addressables.size)
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.liveSet != null }.size + " / " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + " / " + LocalCache.users.size)
Log.d("STATE DUMP", "Memory used by Events: " + LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) + " MB")
@@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Divider
@@ -27,13 +29,18 @@ import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.ScaffoldState
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Send
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -46,13 +53,16 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.BuildConfig
@@ -65,12 +75,16 @@ import com.vitorpamplona.amethyst.service.HttpClient
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.note.LoadStatuses
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
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.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.coroutines.CoroutineScope
@@ -95,8 +109,9 @@ fun DrawerContent(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 25.dp)
.padding(top = 100.dp),
.padding(top = 70.dp),
scaffoldState,
accountViewModel,
nav
)
Divider(
@@ -123,6 +138,7 @@ fun ProfileContent(
baseAccountUser: User,
modifier: Modifier = Modifier,
scaffoldState: ScaffoldState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val coroutineScope = rememberCoroutineScope()
@@ -146,7 +162,7 @@ fun ProfileContent(
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.height(120.dp)
)
} else {
Image(
@@ -155,7 +171,7 @@ fun ProfileContent(
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.height(120.dp)
)
}
@@ -204,7 +220,6 @@ fun ProfileContent(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(top = 15.dp)
.clickable(
onClick = {
nav(route)
@@ -215,9 +230,14 @@ fun ProfileContent(
)
)
}
Row(Modifier.padding(top = Size10dp)) {
EditStatusBox(baseAccountUser, accountViewModel)
}
Row(
modifier = Modifier
.padding(top = 15.dp)
.padding(top = Size10dp)
.clickable(onClick = {
nav(route)
coroutineScope.launch {
@@ -231,6 +251,139 @@ fun ProfileContent(
}
}
@Composable
private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewModel) {
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
LoadStatuses(user = baseAccountUser) { 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.colors.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) {
UserStatusSendButton() {
accountViewModel.createStatus(currentStatus.value)
focusManager.clearFocus(true)
}
}
}
)
} else {
statuses.forEach {
val originalStatus by it.live().metadata.map {
it.note.event?.content() ?: ""
}.observeAsState(it.event?.content() ?: "")
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.colors.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) {
UserStatusSendButton() {
accountViewModel.updateStatus(it, thisStatus.value)
focusManager.clearFocus(true)
}
} else {
UserStatusDeleteButton() {
accountViewModel.deleteStatus(it)
focusManager.clearFocus(true)
}
}
}
)
}
}
}
}
@Composable
fun UserStatusSendButton(onClick: () -> Unit) {
IconButton(
modifier = Size26Modifier,
onClick = onClick
) {
Icon(
imageVector = Icons.Default.Send,
null,
modifier = Size20Modifier,
tint = MaterialTheme.colors.placeholderText
)
}
}
@Composable
fun UserStatusDeleteButton(onClick: () -> Unit) {
IconButton(
modifier = Size26Modifier,
onClick = onClick
) {
Icon(
imageVector = Icons.Default.Delete,
null,
modifier = Size20Modifier,
tint = MaterialTheme.colors.placeholderText
)
}
}
@Composable
private fun FollowingAndFollowerCounts(baseAccountUser: User) {
var followingCount by remember { mutableStateOf("--") }
@@ -484,7 +637,7 @@ private fun RelayStatus(
relayViewModel: RelayPoolViewModel
) {
val connectedRelaysText by relayViewModel.connectionStatus.observeAsState("--/--")
val isConnected by relayViewModel.isConnected.distinctUntilChanged().observeAsState(false)
val isConnected by relayViewModel.isConnected.observeAsState(false)
RenderRelayStatus(connectedRelaysText, isConnected)
}
@@ -120,9 +120,12 @@ sealed class Route(
)
object Room : Route(
route = "Room/{id}",
route = "Room/{id}?message={message}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
arguments = listOf(
navArgument("id") { type = NavType.StringType },
navArgument("message") { type = NavType.StringType; nullable = true; defaultValue = null }
).toImmutableList()
)
object RoomByAuthor : Route(
@@ -95,7 +95,7 @@ fun AddBountyAmountDialog(bounty: Note, accountViewModel: AccountViewModel, onCl
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.cancel()
onClose()
})
@@ -62,57 +62,63 @@ fun BlankNote(modifier: Modifier = Modifier, showDivider: Boolean = false, idHex
@Composable
fun HiddenNote(
reports: ImmutableSet<Note>,
isHiddenAuthor: Boolean,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
isQuote: Boolean = false,
nav: (String) -> Unit,
onClick: () -> Unit
) {
Column(modifier = modifier) {
Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) {
Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) {
Row(
modifier = Modifier.padding(
start = 20.dp,
end = 20.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(30.dp)) {
Text(
text = stringResource(R.string.post_was_flagged_as_inappropriate_by),
color = Color.Gray
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Row(
modifier = Modifier.padding(start = if (!isQuote) 30.dp else 25.dp, end = 20.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(30.dp)
) {
Text(
text = stringResource(R.string.post_was_flagged_as_inappropriate_by),
color = Color.Gray
)
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
if (isHiddenAuthor) {
UserPicture(
user = accountViewModel.userProfile(),
size = Size35dp,
nav = nav,
accountViewModel = accountViewModel
)
}
reports.forEach {
NoteAuthorPicture(
baseNote = it,
size = Size35dp,
nav = nav,
accountViewModel = accountViewModel
)
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
reports.forEach {
NoteAuthorPicture(
baseNote = it,
nav = nav,
accountViewModel = accountViewModel,
size = Size35dp
)
}
}
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = onClick,
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.show_anyway), color = Color.White)
}
}
}
Divider(
thickness = 0.25.dp
)
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = onClick,
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.show_anyway), color = Color.White)
}
}
}
Divider(
thickness = 0.25.dp
)
}
}
@@ -76,7 +76,6 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -93,24 +92,10 @@ fun ChannelCardCompose(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isBlank by baseNote.live().metadata.map {
it.note.event == null
}.distinctUntilChanged().observeAsState(baseNote.event == null)
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
Crossfade(targetState = isBlank) {
Crossfade(targetState = hasEvent) {
if (it) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
modifier.combinedClickable(
onClick = { },
onLongClick = showPopup
)
},
false
)
}
} else {
if (forceEventKind == null || baseNote.event?.kind() == forceEventKind) {
CheckHiddenChannelCardCompose(
baseNote,
@@ -122,6 +107,18 @@ fun ChannelCardCompose(
nav
)
}
} else {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
modifier.combinedClickable(
onClick = { },
onLongClick = showPopup
)
},
false
)
}
}
}
}
@@ -137,13 +134,9 @@ fun CheckHiddenChannelCardCompose(
nav: (String) -> Unit
) {
if (showHidden) {
var state by remember {
val state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
AccountViewModel.NoteComposeReportState()
)
}
@@ -187,19 +180,14 @@ fun LoadedChannelCardCompose(
) {
var state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
AccountViewModel.NoteComposeReportState()
)
}
val scope = rememberCoroutineScope()
WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
val newState = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports)
WatchForReports(note, accountViewModel) { newState ->
if (state != newState) {
scope.launch(Dispatchers.Main) {
state = newState
}
@@ -221,7 +209,7 @@ fun LoadedChannelCardCompose(
@Composable
fun RenderChannelCardReportState(
state: NoteComposeReportState,
state: AccountViewModel.NoteComposeReportState,
note: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
@@ -235,6 +223,7 @@ fun RenderChannelCardReportState(
if (showHiddenNote) {
HiddenNote(
state.relevantReports,
state.isHiddenAuthor,
accountViewModel,
modifier,
false,
@@ -627,7 +616,7 @@ fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, n
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
}
description?.let {
@@ -741,7 +730,7 @@ fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: Accou
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
}
description?.let {
@@ -770,7 +759,7 @@ fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: Accou
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Gallery(users: List<User>, accountViewModel: AccountViewModel) {
FlowRow(verticalAlignment = CenterVertically) {
FlowRow(verticalArrangement = Arrangement.Center) {
users.take(6).forEach {
ClickableUserPicture(it, Size35dp, accountViewModel)
}
@@ -77,14 +77,12 @@ fun ChatroomHeaderCompose(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isBlank by baseNote.live().metadata.map {
it.note.event == null
}.observeAsState(baseNote.event == null)
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
if (isBlank) {
BlankNote(Modifier)
} else {
if (hasEvent) {
ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav)
} else {
BlankNote(Modifier)
}
}
@@ -69,8 +69,6 @@ import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -85,12 +83,20 @@ fun ChatroomMessageCompose(
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit
) {
val isBlank by baseNote.live().metadata.map {
it.note.event == null
}.observeAsState(baseNote.event == null)
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
Crossfade(targetState = isBlank) {
Crossfade(targetState = hasEvent) {
if (it) {
CheckHiddenChatMessage(
baseNote,
routeForLastRead,
innerQuote,
parentBackgroundColor,
accountViewModel,
nav,
onWantsToReply
)
} else {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
@@ -101,16 +107,6 @@ fun ChatroomMessageCompose(
}
)
}
} else {
CheckHiddenChatMessage(
baseNote,
routeForLastRead,
innerQuote,
parentBackgroundColor,
accountViewModel,
nav,
onWantsToReply
)
}
}
}
@@ -154,17 +150,13 @@ fun LoadedChatMessageCompose(
) {
var state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
AccountViewModel.NoteComposeReportState()
)
}
WatchForReports(baseNote, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
state = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet())
WatchForReports(baseNote, accountViewModel) { newState ->
if (state != newState) {
state = newState
}
}
@@ -180,6 +172,7 @@ fun LoadedChatMessageCompose(
if (it) {
HiddenNote(
state.relevantReports,
state.isHiddenAuthor,
accountViewModel,
Modifier,
innerQuote,
@@ -568,7 +561,7 @@ private fun StatusRow(
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) {
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav = nav)
Spacer(modifier = DoubleHorzSpacer)
ReplyReaction(
baseNote = baseNote,
@@ -41,10 +41,7 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
@@ -54,7 +51,6 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller
@@ -72,8 +68,6 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -354,8 +348,7 @@ private fun ParseAuthorCommentAndAmount(
}
LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) {
launch(Dispatchers.IO) {
val newState = loadAmountState(zapRequest, zapEvent, accountViewModel)
accountViewModel.decryptAmountMessage(zapRequest, zapEvent) { newState ->
if (newState != null) {
content.value = newState
}
@@ -365,34 +358,6 @@ private fun ParseAuthorCommentAndAmount(
onReady(content)
}
private suspend fun loadAmountState(
zapRequest: Note,
zapEvent: Note?,
accountViewModel: AccountViewModel
): ZapAmountCommentNotification? {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
val amount = (zapEvent?.event as? LnZapEvent)?.amount
if (decryptedContent != null) {
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
return ZapAmountCommentNotification(
newAuthor,
decryptedContent.content.ifBlank { null },
showAmountAxis(amount)
)
} else {
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
return ZapAmountCommentNotification(
zapRequest.author,
zapRequest.event?.content()?.ifBlank { null },
showAmountAxis(amount)
)
}
}
}
return null
}
@Composable
private fun RenderState(
content: MutableState<ZapAmountCommentNotification>,
@@ -579,9 +544,7 @@ private fun WatchNoteAuthor(
baseNote: Note,
onContent: @Composable (User?) -> Unit
) {
val author by baseNote.live().metadata.map {
it.note.author
}.observeAsState(baseNote.author)
val author by baseNote.live().authorChanges.observeAsState(baseNote.author)
onContent(author)
}
@@ -591,9 +554,7 @@ private fun WatchUserMetadata(
author: User,
onNewMetadata: @Composable (String?) -> Unit
) {
val userProfile by author.live().metadata.map {
it.user.profilePicture()
}.distinctUntilChanged().observeAsState(author.profilePicture())
val userProfile by author.live().profilePictureChanges.observeAsState(author.profilePicture())
onNewMetadata(userProfile)
}
@@ -3,43 +3,62 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
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.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.map
import androidx.compose.ui.unit.sp
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.service.Nip05Verifier
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
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.nip05
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
@Composable
fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): MutableState<Boolean?> {
fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String, accountViewModel: AccountViewModel): MutableState<Boolean?> {
val nip05Verified = remember(userMetadata.nip05) {
// starts with null if must verify or already filled in if verified in the last hour
val default = if ((userMetadata.nip05LastVerificationTime ?: 0) > TimeUtils.oneHourAgo()) {
@@ -53,37 +72,9 @@ fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): Mu
if (nip05Verified.value == null) {
LaunchedEffect(key1 = userMetadata.nip05) {
launch(Dispatchers.IO) {
userMetadata.nip05?.ifBlank { null }?.let { nip05 ->
Nip05Verifier().verifyNip05(
nip05,
onSuccess = {
// Marks user as verified
if (it == pubkeyHex) {
userMetadata.nip05Verified = true
userMetadata.nip05LastVerificationTime = TimeUtils.now()
if (nip05Verified.value != true) {
nip05Verified.value = true
}
} else {
userMetadata.nip05Verified = false
userMetadata.nip05LastVerificationTime = 0
if (nip05Verified.value != false) {
nip05Verified.value = false
}
}
},
onError = {
userMetadata.nip05LastVerificationTime = 0
userMetadata.nip05Verified = false
if (nip05Verified.value != false) {
nip05Verified.value = false
}
}
)
accountViewModel.verifyNip05(userMetadata, pubkeyHex) { newVerificationStatus ->
if (nip05Verified.value != newVerificationStatus) {
nip05Verified.value = newVerificationStatus
}
}
}
@@ -93,47 +84,196 @@ fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): Mu
}
@Composable
fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifier) {
val noteState by baseNote.live().metadata.observeAsState()
val author by remember(noteState) {
derivedStateOf {
noteState?.note?.author
}
}
fun ObserveDisplayNip05Status(
baseNote: Note,
columnModifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val author by baseNote.live().authorChanges.observeAsState()
author?.let {
ObserveDisplayNip05Status(it, columnModifier)
ObserveDisplayNip05Status(it, columnModifier, accountViewModel, nav)
}
}
@Composable
fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) {
val nip05 by baseUser.live().metadata.map {
it.user.nip05()
}.observeAsState(baseUser.nip05())
fun ObserveDisplayNip05Status(
baseUser: User,
columnModifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05())
Crossfade(targetState = nip05, modifier = columnModifier) {
if (it != null) {
DisplayNIP05Line(it, baseUser, columnModifier)
} else {
Text(
text = baseUser.pubkeyDisplayHex(),
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = columnModifier
)
LoadStatuses(baseUser) { statuses ->
Crossfade(targetState = nip05, modifier = columnModifier, label = "ObserveDisplayNip05StatusCrossfade") {
VerifyAndDisplayNIP05OrStatusLine(it, statuses, baseUser, columnModifier, accountViewModel, nav)
}
}
}
@Composable
private fun DisplayNIP05Line(nip05: String, baseUser: User, columnModifier: Modifier = Modifier) {
private fun VerifyAndDisplayNIP05OrStatusLine(
nip05: String?,
statuses: ImmutableList<AddressableNote>,
baseUser: User,
columnModifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Column(modifier = columnModifier) {
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
Crossfade(targetState = nip05Verified) {
Row(verticalAlignment = Alignment.CenterVertically) {
DisplayNIP05(nip05, it)
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05 != null) {
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex, accountViewModel)
if (nip05Verified.value != true) {
DisplayNIP05(nip05, nip05Verified)
} else if (!statuses.isEmpty()) {
RotateStatuses(statuses, accountViewModel, nav)
} else {
DisplayNIP05(nip05, nip05Verified)
}
} else {
if (!statuses.isEmpty()) {
RotateStatuses(statuses, accountViewModel, nav)
} else {
DisplayUsersNpub(baseUser.pubkeyDisplayHex())
}
}
}
}
}
@Composable
fun RotateStatuses(
statuses: ImmutableList<AddressableNote>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var indexToDisplay by remember(statuses) {
mutableIntStateOf(0)
}
DisplayStatus(statuses[indexToDisplay], accountViewModel, nav)
if (statuses.size > 1) {
LaunchedEffect(Unit) {
while (true) {
delay(10.seconds)
indexToDisplay = (indexToDisplay + 1) % statuses.size
}
}
}
}
@Composable
fun DisplayUsersNpub(npub: String) {
Text(
text = npub,
fontSize = 14.sp,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
@Composable
fun DisplayStatus(
addressableNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteState by addressableNote.live().metadata.observeAsState()
val content = remember(noteState) { addressableNote.event?.content() ?: "" }
val type = remember(noteState) {
(addressableNote.event as? AddressableEvent)?.dTag() ?: ""
}
val url = remember(noteState) {
addressableNote.event?.firstTaggedUrl()?.ifBlank { null }
}
val nostrATag = remember(noteState) {
addressableNote.event?.firstTaggedAddress()
}
val nostrHexID = remember(noteState) {
addressableNote.event?.firstTaggedEvent()?.ifBlank { null }
}
when (type) {
"music" -> Icon(
painter = painterResource(id = R.drawable.tunestr),
null,
modifier = Size15Modifier.padding(end = Size5dp),
tint = MaterialTheme.colors.placeholderText
)
else -> {}
}
Text(
text = content,
fontSize = Font14SP,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (url != null) {
val uri = LocalUriHandler.current
Spacer(modifier = StdHorzSpacer)
IconButton(
modifier = Size15Modifier,
onClick = { runCatching { uri.openUri(url.trim()) } }
) {
Icon(
imageVector = Icons.Default.OpenInNew,
null,
modifier = Size15Modifier,
tint = MaterialTheme.colors.lessImportantLink
)
}
} else if (nostrATag != null) {
LoadAddressableNote(nostrATag) { note ->
if (note != null) {
Spacer(modifier = StdHorzSpacer)
IconButton(
modifier = Size15Modifier,
onClick = {
routeFor(
note,
accountViewModel.userProfile()
)?.let { nav(it) }
}
) {
Icon(
imageVector = Icons.Default.OpenInNew,
null,
modifier = Size15Modifier,
tint = MaterialTheme.colors.lessImportantLink
)
}
}
}
} else if (nostrHexID != null) {
LoadNote(baseNoteHex = nostrHexID) {
if (it != null) {
Spacer(modifier = StdHorzSpacer)
IconButton(
modifier = Size15Modifier,
onClick = {
routeFor(
it,
accountViewModel.userProfile()
)?.let { nav(it) }
}
) {
Icon(
imageVector = Icons.Default.OpenInNew,
null,
modifier = Size15Modifier,
tint = MaterialTheme.colors.lessImportantLink
)
}
}
}
}
@@ -157,6 +297,7 @@ private fun DisplayNIP05(
if (user != "_") {
Text(
text = remember(nip05) { AnnotatedString(user) },
fontSize = Font14SP,
color = MaterialTheme.colors.nip05,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -168,7 +309,7 @@ private fun DisplayNIP05(
ClickableText(
text = remember(nip05) { AnnotatedString(domain) },
onClick = { runCatching { uri.openUri("https://$domain") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.nip05),
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.nip05, fontSize = Font14SP),
maxLines = 1,
overflow = TextOverflow.Visible
)
@@ -186,12 +327,12 @@ private fun NIP05VerifiedSymbol(nip05Verified: MutableState<Boolean?>, modifier:
}
@Composable
fun DisplayNip05ProfileStatus(user: User) {
fun DisplayNip05ProfileStatus(user: User, accountViewModel: AccountViewModel) {
val uri = LocalUriHandler.current
user.nip05()?.let { nip05 ->
if (nip05.split("@").size <= 2) {
val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex)
val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel)
Row(verticalAlignment = Alignment.CenterVertically) {
NIP05VerifiedSymbol(nip05Verified, Size16Modifier)
var domainPadStart = 5.dp
@@ -38,7 +38,6 @@ import androidx.compose.material.Text
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
@@ -124,7 +123,6 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
@@ -166,6 +164,7 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent
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.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
@@ -196,7 +195,6 @@ import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
@@ -224,24 +222,10 @@ fun NoteCompose(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isBlank by baseNote.live().metadata.map {
it.note.event == null
}.distinctUntilChanged().observeAsState(baseNote.event == null)
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
Crossfade(targetState = isBlank) {
Crossfade(targetState = hasEvent) {
if (it) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
modifier.combinedClickable(
onClick = { },
onLongClick = showPopup
)
},
isBoostedNote || isQuotedNote
)
}
} else {
CheckHiddenNoteCompose(
note = baseNote,
routeForLastRead = routeForLastRead,
@@ -256,6 +240,18 @@ fun NoteCompose(
accountViewModel = accountViewModel,
nav = nav
)
} else {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
modifier.combinedClickable(
onClick = { },
onLongClick = showPopup
)
},
isBoostedNote || isQuotedNote
)
}
}
}
}
@@ -279,11 +275,7 @@ fun CheckHiddenNoteCompose(
// Ignores reports as well
val state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
AccountViewModel.NoteComposeReportState()
)
}
@@ -326,13 +318,6 @@ fun CheckHiddenNoteCompose(
}
}
@Immutable
data class NoteComposeReportState(
val isAcceptable: Boolean,
val canPreview: Boolean,
val relevantReports: ImmutableSet<Note>
)
@Composable
fun LoadedNoteCompose(
note: Note,
@@ -349,19 +334,14 @@ fun LoadedNoteCompose(
) {
var state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
AccountViewModel.NoteComposeReportState()
)
}
val scope = rememberCoroutineScope()
WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
val newState = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports)
WatchForReports(note, accountViewModel) { newState ->
if (state != newState) {
scope.launch(Dispatchers.Main) {
state = newState
}
@@ -388,7 +368,7 @@ fun LoadedNoteCompose(
@Composable
fun RenderReportState(
state: NoteComposeReportState,
state: AccountViewModel.NoteComposeReportState,
note: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
@@ -407,6 +387,7 @@ fun RenderReportState(
if (showHiddenNote) {
HiddenNote(
state.relevantReports,
state.isHiddenAuthor,
accountViewModel,
modifier,
isBoostedNote,
@@ -438,15 +419,13 @@ fun RenderReportState(
fun WatchForReports(
note: Note,
accountViewModel: AccountViewModel,
onChange: (Boolean, Boolean, ImmutableSet<Note>) -> Unit
onChange: (AccountViewModel.NoteComposeReportState) -> Unit
) {
val userFollowsState by accountViewModel.userFollows.observeAsState()
val noteReportsState by note.live().reports.observeAsState()
LaunchedEffect(key1 = noteReportsState, key2 = userFollowsState) {
launch(Dispatchers.Default) {
accountViewModel.isNoteAcceptable(note, onChange)
}
accountViewModel.isNoteAcceptable(note, onChange)
}
}
@@ -465,45 +444,86 @@ fun NormalNote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
when (baseNote.event) {
is ChannelCreateEvent, is ChannelMetadataEvent -> ChannelHeader(
channelNote = baseNote,
showVideo = !makeItShort,
showBottomDiviser = true,
sendToChannel = true,
accountViewModel = accountViewModel,
nav = nav
)
is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let {
CommunityHeader(
baseNote = it,
if (isQuotedNote || isBoostedNote) {
when (baseNote.event) {
is ChannelCreateEvent, is ChannelMetadataEvent -> ChannelHeader(
channelNote = baseNote,
showVideo = !makeItShort,
showBottomDiviser = true,
sendToCommunity = true,
sendToChannel = true,
accountViewModel = accountViewModel,
nav = nav
)
}
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
is FileHeaderEvent -> FileHeaderDisplay(baseNote, isQuotedNote, accountViewModel)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, isQuotedNote, accountViewModel)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderNote(
baseNote,
routeForLastRead,
modifier,
isBoostedNote,
isQuotedNote,
unPackReply,
makeItShort,
addMarginTop,
canPreview,
parentBackgroundColor,
accountViewModel,
showPopup,
nav
is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let {
CommunityHeader(
baseNote = it,
showBottomDiviser = true,
sendToCommunity = true,
accountViewModel = accountViewModel,
nav = nav
)
}
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderNote(
baseNote,
routeForLastRead,
modifier,
isBoostedNote,
isQuotedNote,
unPackReply,
makeItShort,
addMarginTop,
canPreview,
parentBackgroundColor,
accountViewModel,
showPopup,
nav
)
}
}
} else {
when (baseNote.event) {
is ChannelCreateEvent, is ChannelMetadataEvent -> ChannelHeader(
channelNote = baseNote,
showVideo = !makeItShort,
showBottomDiviser = true,
sendToChannel = true,
accountViewModel = accountViewModel,
nav = nav
)
is CommunityDefinitionEvent -> (baseNote as? AddressableNote)?.let {
CommunityHeader(
baseNote = it,
showBottomDiviser = true,
sendToCommunity = true,
accountViewModel = accountViewModel,
nav = nav
)
}
is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote)
is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderNote(
baseNote,
routeForLastRead,
modifier,
isBoostedNote,
isQuotedNote,
unPackReply,
makeItShort,
addMarginTop,
canPreview,
parentBackgroundColor,
accountViewModel,
showPopup,
nav
)
}
}
}
}
@@ -746,9 +766,9 @@ private fun ShortCommunityActionOptions(
nav: (String) -> Unit
) {
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
if (!isFollowing) {
@@ -1014,7 +1034,7 @@ private fun NoteBody(
)
}
Spacer(modifier = HalfVertSpacer)
Spacer(modifier = Modifier.height(3.dp))
if (!makeItShort) {
ReplyRow(
@@ -1144,6 +1164,14 @@ private fun RenderNoteRow(
)
}
is FileHeaderEvent -> {
FileHeaderDisplay(baseNote, true, accountViewModel)
}
is FileStorageHeaderEvent -> {
FileStorageHeaderDisplay(baseNote, true, accountViewModel)
}
is CommunityPostApprovalEvent -> {
RenderPostApproval(
baseNote,
@@ -1192,6 +1220,16 @@ fun routeFor(note: Note, loggedIn: User): String? {
return null
}
fun routeToMessage(user: User, draftMessage: String?, accountViewModel: AccountViewModel): String {
val withKey = ChatroomKey(persistentSetOf(user.pubkeyHex))
accountViewModel.account.userProfile().createChatroom(withKey)
return if (draftMessage != null) {
"Room/${withKey.hashCode()}?message=$draftMessage"
} else {
"Room/${withKey.hashCode()}"
}
}
fun routeFor(note: Channel): String {
return "Channel/${note.idHex}"
}
@@ -1269,7 +1307,7 @@ fun RenderPoll(
nav: (String) -> Unit
) {
val noteEvent = note.event as? PollNoteEvent ?: return
val eventContent = remember { noteEvent.content() }
val eventContent = remember(note) { noteEvent.content() }
if (makeItShort && accountViewModel.isLoggedUser(note.author)) {
Text(
@@ -2353,9 +2391,9 @@ private fun ReplyRow(
) {
val noteEvent = note.event
val showReply by remember {
val showReply by remember(note) {
derivedStateOf {
noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
noteEvent is BaseTextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
}
}
@@ -2441,7 +2479,7 @@ fun SecondUserInfoRow(
val noteAuthor = remember { note.author } ?: return
Row(verticalAlignment = CenterVertically, modifier = UserNameMaxRowHeight) {
ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) })
ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) }, accountViewModel, nav)
val geo = remember { noteEvent.getGeoHash() }
if (geo != null) {
@@ -2460,6 +2498,30 @@ fun SecondUserInfoRow(
}
}
@Composable
fun LoadStatuses(
user: User,
content: @Composable (ImmutableList<AddressableNote>) -> Unit
) {
var statuses: ImmutableList<AddressableNote> by remember {
mutableStateOf(persistentListOf())
}
val userStatus by user.live().statuses.observeAsState()
LaunchedEffect(key1 = userStatus) {
launch(Dispatchers.IO) {
val myUser = userStatus?.user ?: return@launch
val newStatuses = LocalCache.findStatusesForUser(myUser)
if (!equalImmutableLists(statuses, newStatuses)) {
statuses = newStatuses
}
}
}
content(statuses)
}
@Composable
fun DisplayLocation(geohash: String, nav: (String) -> Unit) {
val context = LocalContext.current
@@ -3199,7 +3261,7 @@ private fun RenderBadge(
}
@Composable
fun FileHeaderDisplay(note: Note, isQuotedNote: Boolean, accountViewModel: AccountViewModel) {
fun FileHeaderDisplay(note: Note, roundedCorner: Boolean, accountViewModel: AccountViewModel) {
val event = (note.event as? FileHeaderEvent) ?: return
val fullUrl = event.url() ?: return
@@ -3235,18 +3297,18 @@ fun FileHeaderDisplay(note: Note, isQuotedNote: Boolean, accountViewModel: Accou
}
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
ZoomableContentView(content = content, roundedCorner = isQuotedNote, accountViewModel = accountViewModel)
ZoomableContentView(content = content, roundedCorner = roundedCorner, accountViewModel = accountViewModel)
}
}
@Composable
fun FileStorageHeaderDisplay(baseNote: Note, isQuotedNote: Boolean, accountViewModel: AccountViewModel) {
fun FileStorageHeaderDisplay(baseNote: Note, roundedCorner: Boolean, accountViewModel: AccountViewModel) {
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
val dataEventId = eventHeader.dataEventId() ?: return
LoadNote(baseNoteHex = dataEventId) { contentNote ->
if (contentNote != null) {
ObserverAndRenderNIP95(baseNote, contentNote, isQuotedNote, accountViewModel)
ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel)
}
}
}
@@ -3255,7 +3317,7 @@ fun FileStorageHeaderDisplay(baseNote: Note, isQuotedNote: Boolean, accountViewM
private fun ObserverAndRenderNIP95(
header: Note,
content: Note,
isQuotedNote: Boolean,
roundedCorner: Boolean,
accountViewModel: AccountViewModel
) {
val eventHeader = (header.event as? FileStorageHeaderEvent) ?: return
@@ -3302,7 +3364,7 @@ private fun ObserverAndRenderNIP95(
Crossfade(targetState = content) {
if (it != null) {
SensitivityWarning(note = header, accountViewModel = accountViewModel) {
ZoomableContentView(content = it, roundedCorner = isQuotedNote, accountViewModel = accountViewModel)
ZoomableContentView(content = it, roundedCorner = roundedCorner, accountViewModel = accountViewModel)
}
}
}
@@ -98,9 +98,7 @@ private fun WatchZapsAndUpdateTallies(
val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
pollViewModel.refreshTallies()
}
pollViewModel.refreshTallies()
}
}
@@ -132,8 +130,7 @@ private fun OptionNote(
ZapVote(
baseNote,
poolOption,
accountViewModel,
pollViewModel,
pollViewModel = pollViewModel,
nonClickablePrepend = {
RenderOptionAfterVote(
poolOption.descriptor,
@@ -147,18 +144,21 @@ private fun OptionNote(
)
},
clickablePrepend = {
}
},
accountViewModel = accountViewModel,
nav = nav
)
} else {
ZapVote(
baseNote,
poolOption,
accountViewModel,
pollViewModel,
pollViewModel = pollViewModel,
nonClickablePrepend = {},
clickablePrepend = {
RenderOptionBeforeVote(poolOption.descriptor, canPreview, tags, backgroundColor, accountViewModel, nav)
}
},
accountViewModel = accountViewModel,
nav = nav
)
}
}
@@ -269,11 +269,12 @@ private fun RenderOptionBeforeVote(
fun ZapVote(
baseNote: Note,
poolOption: PollOption,
accountViewModel: AccountViewModel,
pollViewModel: PollNoteViewModel,
modifier: Modifier = Modifier,
pollViewModel: PollNoteViewModel,
nonClickablePrepend: @Composable () -> Unit,
clickablePrepend: @Composable () -> Unit
clickablePrepend: @Composable () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isLoggedUser by remember {
derivedStateOf {
@@ -283,6 +284,7 @@ fun ZapVote(
var wantsToZap by remember { mutableStateOf(false) }
var zappingProgress by remember { mutableStateOf(0f) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -384,7 +386,7 @@ fun ZapVote(
onError = {
scope.launch {
zappingProgress = 0f
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
showErrorMessageDialog = it
}
},
onProgress = {
@@ -395,6 +397,19 @@ fun ZapVote(
)
}
if (showErrorMessageDialog != null) {
ErrorMessageDialog(
title = stringResource(id = R.string.error_dialog_zap_error),
textContent = showErrorMessageDialog ?: "",
onClickStartMessage = {
baseNote.author?.let {
nav(routeToMessage(it, showErrorMessageDialog, accountViewModel))
}
},
onDismiss = { showErrorMessageDialog = null }
)
}
clickablePrepend()
if (poolOption.zappedByLoggedIn) {
@@ -3,13 +3,16 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
@@ -52,12 +55,12 @@ class PollNoteViewModel : ViewModel() {
closedAt = pollEvent?.getTagInt(CLOSED_AT)
}
suspend fun refreshTallies() {
totalZapped = totalZapped()
wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
fun refreshTallies() {
viewModelScope.launch(Dispatchers.Default) {
totalZapped = totalZapped()
wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
_tallies.emit(
pollOptions?.keys?.map {
val newOptions = pollOptions?.keys?.map {
val zappedInOption = zappedPollOptionAmount(it)
val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
@@ -71,8 +74,12 @@ class PollNoteViewModel : ViewModel() {
val consensus = consensusThreshold != null && myTally >= consensusThreshold!!
PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn)
} ?: emptyList()
)
}
_tallies.emit(
newOptions ?: emptyList()
)
}
}
fun canZap(): Boolean {
@@ -4,7 +4,7 @@ import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalAnimationApi
@@ -40,6 +40,7 @@ import androidx.compose.material.ProgressIndicatorDefaults
import androidx.compose.material.Text
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
@@ -57,6 +58,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@@ -209,7 +211,7 @@ private fun InnerReactionRow(
) {
val (value, elapsed) = measureTimedValue {
Row(verticalAlignment = CenterVertically) {
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav = nav)
}
}
Log.d("Rendering Metrics", "Reaction Zaps: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
@@ -240,7 +242,7 @@ private fun LoadAndDisplayZapraiser(
wantsToSeeReactions: MutableState<Boolean>,
accountViewModel: AccountViewModel
) {
val zapraiserAmount by remember {
val zapraiserAmount by remember(baseNote) {
derivedStateOf {
baseNote.event?.zapraiserAmount() ?: 0
}
@@ -260,42 +262,26 @@ private fun LoadAndDisplayZapraiser(
}
}
@Immutable
data class ZapraiserStatus(val progress: Float, val left: String)
@Composable
fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, accountViewModel: AccountViewModel) {
val zapsState by baseNote.live().zaps.observeAsState()
var zapraiserProgress by remember { mutableStateOf(0F) }
var zapraiserLeft by remember { mutableStateOf("$zapraiserAmount") }
var zapraiserStatus by remember { mutableStateOf(ZapraiserStatus(0F, "$zapraiserAmount")) }
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
zapsState?.note?.let {
val newZapAmount = accountViewModel.calculateZapAmount(it)
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat()
if (percentage > 1) {
percentage = 1f
}
if (Math.abs(zapraiserProgress - percentage) > 0.001) {
val newZapraiserProgress = percentage
val newZapraiserLeft = if (percentage > 0.99) {
"0"
} else {
showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal())
}
if (zapraiserLeft != newZapraiserLeft) {
zapraiserLeft = newZapraiserLeft
}
if (zapraiserProgress != newZapraiserProgress) {
zapraiserProgress = newZapraiserProgress
}
zapsState?.note?.let {
accountViewModel.calculateZapraiser(baseNote) { newStatus ->
if (zapraiserStatus != newStatus) {
zapraiserStatus = newStatus
}
}
}
}
val color = if (zapraiserProgress > 0.99) {
val color = if (zapraiserStatus.progress > 0.99) {
DarkerGreen
} else {
MaterialTheme.colors.mediumImportanceLink
@@ -306,7 +292,7 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
.fillMaxWidth()
.height(if (details) 24.dp else 4.dp),
color = color,
progress = zapraiserProgress
progress = zapraiserStatus.progress
)
if (details) {
@@ -314,14 +300,14 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
contentAlignment = Center,
modifier = TinyBorders
) {
val totalPercentage by remember(zapraiserProgress) {
val totalPercentage by remember(zapraiserStatus) {
derivedStateOf {
"${(zapraiserProgress * 100).roundToInt()}%"
"${(zapraiserStatus.progress * 100).roundToInt()}%"
}
}
Text(
text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserLeft),
text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserStatus.left),
modifier = NoSoTinyBorders,
color = MaterialTheme.colors.placeholderText,
fontSize = Font14SP,
@@ -333,15 +319,7 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
@Composable
private fun WatchReactionsZapsBoostsAndDisplayIfExists(baseNote: Note, content: @Composable () -> Unit) {
val hasReactions by baseNote.live().zaps.combineWith(
liveData1 = baseNote.live().boosts,
liveData2 = baseNote.live().reactions,
block = { zapsState, boostsState, reactionsState ->
zapsState?.note?.zaps?.isNotEmpty() == true ||
boostsState?.note?.boosts?.isNotEmpty() == true ||
reactionsState?.note?.reactions?.isNotEmpty() == true
}
).observeAsState(
val hasReactions by baseNote.live().hasReactions.observeAsState(
baseNote.zaps.isNotEmpty() ||
baseNote.boosts.isNotEmpty() ||
baseNote.reactions.isNotEmpty()
@@ -413,14 +391,7 @@ private fun ReactionDetailGallery(
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = remember { mutableStateOf<Color>(defaultBackgroundColor) }
val hasReactions by baseNote.live().zaps.combineWith(
baseNote.live().boosts,
baseNote.live().reactions
) { zapState, boostState, reactionState ->
zapState?.note?.zaps?.isNotEmpty() ?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false
}.distinctUntilChanged().observeAsState(
val hasReactions by baseNote.live().hasReactions.observeAsState(
baseNote.zaps.isNotEmpty() || baseNote.boosts.isNotEmpty() || baseNote.reactions.isNotEmpty()
)
@@ -441,17 +412,16 @@ private fun WatchBoostsAndRenderGallery(
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val boostsState by baseNote.live().boosts.observeAsState()
val boostsEvents by remember(boostsState) {
derivedStateOf { baseNote.boosts.toImmutableList() }
}
val boostsEvents by baseNote.live().boostList.observeAsState()
if (boostsEvents.isNotEmpty()) {
RenderBoostGallery(
boostsEvents,
nav,
accountViewModel
)
boostsEvents?.let {
if (it.isNotEmpty()) {
RenderBoostGallery(
it,
nav,
accountViewModel
)
}
}
}
@@ -593,9 +563,7 @@ fun ReplyReaction(
@Composable
fun ReplyCounter(baseNote: Note, textColor: Color) {
val repliesState by baseNote.live().replies.map {
it.note.replies.size
}.observeAsState(baseNote.replies.size)
val repliesState by baseNote.live().replyCount.observeAsState(baseNote.replies.size)
SlidingAnimationCount(repliesState, textColor)
}
@@ -610,14 +578,14 @@ private fun SlidingAnimationCount(baseCount: MutableState<Int>, textColor: Color
private fun SlidingAnimationCount(baseCount: Int, textColor: Color) {
AnimatedContent<Int>(
targetState = baseCount,
transitionSpec = AnimatedContentScope<Int>::transitionSpec
transitionSpec = AnimatedContentTransitionScope<Int>::transitionSpec
) { count ->
TextCount(count, textColor)
}
}
@OptIn(ExperimentalAnimationApi::class)
private fun <S> AnimatedContentScope<S>.transitionSpec(): ContentTransform {
private fun <S> AnimatedContentTransitionScope<S>.transitionSpec(): ContentTransform {
return slideAnimation
}
@@ -644,7 +612,7 @@ private fun TextCount(count: Int, textColor: Color) {
private fun SlidingAnimationAmount(amount: MutableState<String>, textColor: Color) {
AnimatedContent(
targetState = amount.value,
transitionSpec = AnimatedContentScope<String>::transitionSpec
transitionSpec = AnimatedContentTransitionScope<String>::transitionSpec
) { count ->
Text(
text = count,
@@ -695,20 +663,21 @@ fun BoostReaction(
}
) {
BoostIcon(baseNote, iconSize, grayTint, accountViewModel)
}
if (wantsToBoost) {
BoostTypeChoicePopup(
baseNote,
accountViewModel,
onDismiss = {
wantsToBoost = false
},
onQuote = {
wantsToBoost = false
onQuotePress()
}
)
if (wantsToBoost) {
BoostTypeChoicePopup(
baseNote,
iconSize,
accountViewModel,
onDismiss = {
wantsToBoost = false
},
onQuote = {
wantsToBoost = false
onQuotePress()
}
)
}
}
BoostText(baseNote, grayTint)
@@ -729,9 +698,7 @@ fun BoostIcon(baseNote: Note, iconSize: Dp = Size20dp, grayTint: Color, accountV
@Composable
fun BoostText(baseNote: Note, grayTint: Color) {
val boostState by baseNote.live().boosts.map {
it.note.boosts.size
}.distinctUntilChanged().observeAsState(baseNote.boosts.size)
val boostState by baseNote.live().boostCount.observeAsState(baseNote.boosts.size)
SlidingAnimationCount(boostState, grayTint)
}
@@ -795,6 +762,7 @@ fun LikeReaction(
if (wantsToReact) {
ReactionChoicePopup(
baseNote,
iconSize,
accountViewModel,
onDismiss = {
wantsToReact = false
@@ -844,10 +812,7 @@ private fun WatchReactionTypeForNote(baseNote: Note, accountViewModel: AccountVi
val reactionsState by baseNote.live().reactions.observeAsState()
LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) {
val reactionNote = reactionsState?.note?.getReactionBy(accountViewModel.userProfile())
onNewReactionType(reactionNote)
}
accountViewModel.loadReactionTo(reactionsState?.note, onNewReactionType)
}
}
@@ -882,32 +847,9 @@ private fun RenderReactionType(
@Composable
fun LikeText(baseNote: Note, grayTint: Color) {
val reactionsCount = remember(baseNote) {
mutableStateOf(baseNote.reactions.size)
}
val reactionCount by baseNote.live().reactionCount.observeAsState(0)
val scope = rememberCoroutineScope()
WatchReactionCountForNote(baseNote) { newReactionsCount ->
if (reactionsCount.value != newReactionsCount) {
scope.launch(Dispatchers.Main) {
reactionsCount.value = newReactionsCount
}
}
}
SlidingAnimationCount(reactionsCount, grayTint)
}
@Composable
private fun WatchReactionCountForNote(baseNote: Note, onNewReactionCount: (Int) -> Unit) {
val reactionsState by baseNote.live().reactions.observeAsState()
LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) {
onNewReactionCount(reactionsState?.note?.countReactions() ?: 0)
}
}
SlidingAnimationCount(reactionCount, grayTint)
}
private fun likeClick(
@@ -958,11 +900,13 @@ fun ZapReaction(
grayTint: Color,
accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp,
animationSize: Dp = 14.dp
animationSize: Dp = 14.dp,
nav: (String) -> Unit
) {
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -988,6 +932,12 @@ fun ZapReaction(
},
onMultipleChoices = {
wantsToZap = true
},
onError = {
scope.launch {
zappingProgress = 0f
showErrorMessageDialog = it
}
}
)
},
@@ -1014,7 +964,7 @@ fun ZapReaction(
onError = {
scope.launch {
zappingProgress = 0f
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
showErrorMessageDialog = it
}
},
onProgress = {
@@ -1025,6 +975,19 @@ fun ZapReaction(
)
}
if (showErrorMessageDialog != null) {
ErrorMessageDialog(
title = stringResource(id = R.string.error_dialog_zap_error),
textContent = showErrorMessageDialog ?: "",
onClickStartMessage = {
baseNote.author?.let {
nav(routeToMessage(it, showErrorMessageDialog, accountViewModel))
}
},
onDismiss = { showErrorMessageDialog = null }
)
}
if (wantsToChangeZapAmount) {
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, accountViewModel = accountViewModel)
}
@@ -1063,7 +1026,8 @@ private fun zapClick(
scope: CoroutineScope,
context: Context,
onZappingProgress: (Float) -> Unit,
onMultipleChoices: () -> Unit
onMultipleChoices: () -> Unit,
onError: (String) -> Unit
) {
if (accountViewModel.account.zapAmountChoices.isEmpty()) {
scope.launch {
@@ -1092,14 +1056,7 @@ private fun zapClick(
null,
"",
context,
onError = {
scope.launch {
onZappingProgress(0f)
Toast
.makeText(context, it, Toast.LENGTH_SHORT)
.show()
}
},
onError = onError,
onProgress = {
scope.launch(Dispatchers.Main) {
onZappingProgress(it)
@@ -1147,9 +1104,7 @@ private fun WatchZapsForNote(baseNote: Note, accountViewModel: AccountViewModel,
val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
onWasZapped(accountViewModel.calculateIfNoteWasZappedByAccount(baseNote))
}
accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, onWasZapped)
}
}
@@ -1179,9 +1134,7 @@ fun WatchZapAmountsForNote(baseNote: Note, accountViewModel: AccountViewModel, o
val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
onZapAmount(showAmount(accountViewModel.calculateZapAmount(baseNote)))
}
accountViewModel.calculateZapAmount(baseNote, onZapAmount)
}
}
@@ -1225,10 +1178,14 @@ private fun DrawViewCount(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onQuote: () -> Unit) {
private fun BoostTypeChoicePopup(baseNote: Note, iconSize: Dp, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onQuote: () -> Unit) {
val iconSizePx = with(LocalDensity.current) {
-iconSize.toPx().toInt()
}
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, -50),
offset = IntOffset(0, iconSizePx),
onDismissRequest = { onDismiss() }
) {
FlowRow {
@@ -1269,6 +1226,7 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
@Composable
fun ReactionChoicePopup(
baseNote: Note,
iconSize: Dp,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
onChangeAmount: () -> Unit
@@ -1280,9 +1238,13 @@ fun ReactionChoicePopup(
baseNote.reactedBy(account.userProfile()).toSet()
}
val iconSizePx = with(LocalDensity.current) {
-iconSize.toPx().toInt()
}
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, -50),
offset = IntOffset(0, iconSizePx),
onDismissRequest = { onDismiss() }
) {
FlowRow(horizontalArrangement = Arrangement.Center) {
@@ -155,7 +155,7 @@ fun UpdateReactionTypeDialog(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.cancel()
onClose()
})
@@ -250,7 +250,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, account
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.cancel()
onClose()
})
@@ -73,9 +73,7 @@ fun NoteAuthorPicture(
modifier: Modifier = Modifier,
onClick: ((User) -> Unit)? = null
) {
val author by baseNote.live().metadata.map {
it.note.author
}.distinctUntilChanged().observeAsState(baseNote.author)
val author by baseNote.live().authorChanges.observeAsState(baseNote.author)
Crossfade(targetState = author) {
if (it == null) {
@@ -112,23 +110,19 @@ fun UserPicture(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val route by remember {
val route by remember(user) {
derivedStateOf {
"User/${user.pubkeyHex}"
}
}
val scope = rememberCoroutineScope()
ClickableUserPicture(
baseUser = user,
size = size,
accountViewModel = accountViewModel,
modifier = pictureModifier,
onClick = {
scope.launch {
nav(route)
}
nav(route)
}
)
}
@@ -274,9 +268,7 @@ fun InnerBaseUserPicture(
accountViewModel: AccountViewModel,
modifier: Modifier
) {
val userProfile by baseUser.live().metadata.map {
it.user.profilePicture()
}.distinctUntilChanged().observeAsState(baseUser.profilePicture())
val userProfile by baseUser.live().profilePictureChanges.observeAsState(baseUser.profilePicture())
PictureAndFollowingMark(
userHex = baseUser.pubkeyHex,
@@ -508,9 +500,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
fun WatchBookmarksFollowsAndAccount(note: Note, accountViewModel: AccountViewModel, onNew: (DropDownParams) -> Unit) {
val followState by accountViewModel.userProfile().live().follows.observeAsState()
val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState()
val showSensitiveContent by accountViewModel.accountLiveData.map {
it.account.showSensitiveContent
}.distinctUntilChanged().observeAsState(accountViewModel.account.showSensitiveContent)
val showSensitiveContent by accountViewModel.showSensitiveContentChanges.observeAsState(accountViewModel.account.showSensitiveContent)
LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) {
launch(Dispatchers.IO) {
@@ -57,6 +57,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.time.Instant
@@ -109,26 +111,58 @@ fun UserReactionsRow(
@Composable
private fun UserZapModel(model: UserReactionsViewModel) {
val zaps by model.zaps.collectAsState()
UserZapReaction(showAmountAxis(zaps[model.today()]))
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Size24Modifier,
tint = BitcoinOrange
)
Spacer(modifier = Modifier.width(8.dp))
UserZapReaction(model)
}
@Composable
private fun UserReactionModel(model: UserReactionsViewModel) {
val reactions by model.reactions.collectAsState()
UserLikeReaction(reactions[model.today()])
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Size20Modifier,
tint = Color.Unspecified
)
Spacer(modifier = StdHorzSpacer)
UserLikeReaction(model)
}
@Composable
private fun UserBoostModel(model: UserReactionsViewModel) {
val boosts by model.boosts.collectAsState()
UserBoostReaction(boosts[model.today()])
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Size24Modifier,
tint = Color.Unspecified
)
Spacer(modifier = StdHorzSpacer)
UserBoostReaction(model)
}
@Composable
private fun UserReplyModel(model: UserReactionsViewModel) {
val replies by model.replies.collectAsState()
UserReplyReaction(replies[model.today()])
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Size20Modifier,
tint = RoyalBlue
)
Spacer(modifier = StdHorzSpacer)
UserReplyReaction(model)
}
@Stable
@@ -154,6 +188,11 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
private var takenIntoAccount = setOf<HexKey>()
private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat()
val todaysReplyCount = _replies.map { showCount(it[today()]) }.distinctUntilChanged()
val todaysBoostCount = _boosts.map { showCount(it[today()]) }.distinctUntilChanged()
val todaysReactionCount = _reactions.map { showCount(it[today()]) }.distinctUntilChanged()
val todaysZapAmount = _zaps.map { showAmountAxis(it[today()]) }.distinctUntilChanged()
fun formatDate(createAt: Long): String {
return sdf.format(
Instant.ofEpochSecond(createAt)
@@ -339,18 +378,9 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
@Composable
fun UserReplyReaction(
replyCount: Int?
model: UserReactionsViewModel
) {
val showCounts = remember(replyCount) { showCount(replyCount) }
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Size20Modifier,
tint = RoyalBlue
)
Spacer(modifier = StdHorzSpacer)
val showCounts by model.todaysReplyCount.collectAsState("")
Text(
showCounts,
@@ -361,21 +391,12 @@ fun UserReplyReaction(
@Composable
fun UserBoostReaction(
boostCount: Int?
model: UserReactionsViewModel
) {
val showCounts = remember(boostCount) { showCount(boostCount) }
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Size24Modifier,
tint = Color.Unspecified
)
Spacer(modifier = StdHorzSpacer)
val boosts by model.todaysBoostCount.collectAsState("")
Text(
showCounts,
boosts,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
@@ -383,21 +404,12 @@ fun UserBoostReaction(
@Composable
fun UserLikeReaction(
likeCount: Int?
model: UserReactionsViewModel
) {
val showCounts = remember(likeCount) { showCount(likeCount) }
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Size20Modifier,
tint = Color.Unspecified
)
Spacer(modifier = StdHorzSpacer)
val reactions by model.todaysReactionCount.collectAsState("")
Text(
text = showCounts,
text = reactions,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
@@ -405,17 +417,9 @@ fun UserLikeReaction(
@Composable
fun UserZapReaction(
amount: String
model: UserReactionsViewModel
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Size24Modifier,
tint = BitcoinOrange
)
Spacer(modifier = Modifier.width(8.dp))
val amount by model.todaysZapAmount.collectAsState("")
Text(
amount,
fontWeight = FontWeight.Bold,
@@ -4,11 +4,14 @@ import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Done
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
@@ -94,7 +97,7 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
postViewModel.cancel()
onClose()
})
@@ -111,8 +114,7 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas
onError = {
zappingProgress = 0f
scope.launch {
Toast
.makeText(context, it, Toast.LENGTH_SHORT).show()
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
},
onProgress = {
@@ -225,3 +227,51 @@ fun ZapButton(isActive: Boolean, onPost: () -> Unit) {
Text(text = "⚡Zap ", color = Color.White)
}
}
@Composable
fun ErrorMessageDialog(
title: String,
textContent: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onClickStartMessage: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(title)
},
text = {
Text(textContent)
},
buttons = {
Row(
modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = onClickStartMessage) {
Icon(
painter = painterResource(R.drawable.ic_dm),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.error_dialog_talk_to_user))
}
Button(onClick = onDismiss, colors = buttonColors) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Done,
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.error_dialog_button_ok))
}
}
}
}
)
}
@@ -61,7 +61,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onClose)
CloseButton(onPress = onClose)
}
Column(
@@ -35,7 +35,6 @@ import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable
@@ -102,11 +101,9 @@ private fun WatchScrollToTop(
val scrollToTop by viewModel.scrollToTop.collectAsState()
LaunchedEffect(scrollToTop) {
launch {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
}
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
}
}
}
@@ -34,7 +34,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@Stable
@@ -270,7 +269,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val bundler = BundledUpdate(1000, Dispatchers.IO)
private val bundlerInsert = BundledInsert<Set<Note>>(1000, Dispatchers.IO)
@OptIn(ExperimentalTime::class)
fun invalidateData(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
@@ -282,7 +280,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
@OptIn(ExperimentalTime::class)
fun invalidateDataAndSendToTop() {
clear()
bundler.invalidate(false) {
@@ -296,7 +293,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
@OptIn(ExperimentalTime::class)
fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) {
clear()
@@ -312,7 +308,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
@OptIn(ExperimentalTime::class)
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) {
val newObjects = it.flatten().toSet()
@@ -95,10 +95,10 @@ class NostrDiscoverChatFeedViewModel(val account: Account) : FeedViewModel(Disco
}
}
class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFilter(noteId)) {
class Factory(val noteId: String) : ViewModelProvider.Factory {
class NostrThreadFeedViewModel(account: Account, noteId: String) : FeedViewModel(ThreadFeedFilter(account, noteId)) {
class Factory(val account: Account, val noteId: String) : ViewModelProvider.Factory {
override fun <NostrThreadFeedViewModel : ViewModel> create(modelClass: Class<NostrThreadFeedViewModel>): NostrThreadFeedViewModel {
return NostrThreadFeedViewModel(noteId) as NostrThreadFeedViewModel
return NostrThreadFeedViewModel(account, noteId) as NostrThreadFeedViewModel
}
}
}
@@ -236,7 +236,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
checkNotInMainThread()
lastFeedKey = localFilter.feedKey()
val notes = localFilter.loadTop().toImmutableList()
val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList()
val oldNotesState = _feedContent.value
if (oldNotesState is FeedState.Loaded) {
@@ -301,11 +301,13 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) {
bundler.invalidate(false) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
viewModelScope.launch(Dispatchers.IO) {
bundler.invalidate(false) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
}
}
}
}
@@ -16,5 +16,5 @@ class RelayPoolViewModel : ViewModel() {
val isConnected = RelayPool.live.map {
it.relays.connectedRelays() > 0
}
}.distinctUntilChanged()
}
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.saveable.rememberSaveable
@@ -61,17 +62,19 @@ fun rememberForeverLazyListState(
fun rememberForeverPagerState(
key: String,
initialFirstVisibleItemIndex: Int = 0,
initialFirstVisibleItemScrollOffset: Float = 0.0f
initialFirstVisibleItemScrollOffset: Float = 0.0f,
pageCount: () -> Int
): PagerState {
val scrollState = rememberSaveable(saver = PagerState.Saver) {
val savedValue = savedScrollStates[key]
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset
PagerState(
savedIndex,
savedOffset
)
}
val savedValue = savedScrollStates[key]
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset
val scrollState = rememberPagerState(
savedIndex,
savedOffset,
pageCount
)
DisposableEffect(scrollState) {
onDispose {
val lastIndex = scrollState.currentPage
@@ -79,5 +82,6 @@ fun rememberForeverPagerState(
savedScrollStates[key] = ScrollState(lastIndex, lastOffset)
}
}
return scrollState
}
@@ -262,6 +262,7 @@ fun NoteMaster(
HiddenNote(
reports,
note.author?.let { account.isHidden(it) } ?: false,
accountViewModel,
Modifier,
false,
@@ -328,7 +329,7 @@ fun NoteMaster(
}
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(baseNote, remember { Modifier.weight(1f) })
ObserveDisplayNip05Status(baseNote, remember { Modifier.weight(1f) }, accountViewModel, nav)
val geo = remember { noteEvent.getGeoHash() }
if (geo != null) {
@@ -70,7 +70,7 @@ fun AccountBackupDialog(account: Account, onClose: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onClose)
CloseButton(onPress = onClose)
}
Column(
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData
@@ -14,18 +15,30 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.Nip05Verifier
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
@@ -44,22 +57,26 @@ class AccountViewModel(val account: Account) : ViewModel() {
val userFollows: LiveData<UserState> = account.userProfile().live().follows.map { it }
val userRelays: LiveData<UserState> = account.userProfile().live().relays.map { it }
val discoveryListLiveData = accountLiveData.map {
val discoveryListLiveData = account.live.map {
it.account.defaultDiscoveryFollowList
}.distinctUntilChanged()
val homeListLiveData = accountLiveData.map {
val homeListLiveData = account.live.map {
it.account.defaultHomeFollowList
}.distinctUntilChanged()
val notificationListLiveData = accountLiveData.map {
val notificationListLiveData = account.live.map {
it.account.defaultNotificationFollowList
}.distinctUntilChanged()
val storiesListLiveData = accountLiveData.map {
val storiesListLiveData = account.live.map {
it.account.defaultStoriesFollowList
}.distinctUntilChanged()
val showSensitiveContentChanges = account.live.map {
it.account.showSensitiveContent
}.distinctUntilChanged()
fun updateAutomaticallyStartPlayback(
automaticallyStartPlayback: ConnectivityType
) {
@@ -120,21 +137,86 @@ class AccountViewModel(val account: Account) : ViewModel() {
account.delete(account.boostsTo(note))
}
fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean {
return account.calculateIfNoteWasZappedByAccount(zappedNote)
fun calculateIfNoteWasZappedByAccount(zappedNote: Note, onWasZapped: (Boolean) -> Unit) {
viewModelScope.launch(Dispatchers.Default) {
onWasZapped(account.calculateIfNoteWasZappedByAccount(zappedNote))
}
}
fun calculateZapAmount(zappedNote: Note): BigDecimal {
suspend fun calculateZapAmount(zappedNote: Note): BigDecimal {
return account.calculateZappedAmount(zappedNote)
}
fun calculateZapAmount(zappedNote: Note, onZapAmount: (String) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onZapAmount(showAmount(account.calculateZappedAmount(zappedNote)))
}
}
fun calculateZapraiser(zappedNote: Note, onZapraiserStatus: (ZapraiserStatus) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0
val newZapAmount = calculateZapAmount(zappedNote)
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat()
if (percentage > 1) {
percentage = 1f
}
val newZapraiserProgress = percentage
val newZapraiserLeft = if (percentage > 0.99) {
"0"
} else {
showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal())
}
onZapraiserStatus(ZapraiserStatus(newZapraiserProgress, newZapraiserLeft))
}
}
fun decryptAmountMessage(
zapRequest: Note,
zapEvent: Note?,
onNewState: (ZapAmountCommentNotification?) -> Unit
) {
viewModelScope.launch(Dispatchers.IO) {
onNewState(innerDecryptAmountMessage(zapRequest, zapEvent))
}
}
private suspend fun innerDecryptAmountMessage(
zapRequest: Note,
zapEvent: Note?
): ZapAmountCommentNotification? {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = decryptZap(zapRequest)
val amount = (zapEvent?.event as? LnZapEvent)?.amount
if (decryptedContent != null) {
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
return ZapAmountCommentNotification(
newAuthor,
decryptedContent.content.ifBlank { null },
showAmountAxis(amount)
)
} else {
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
return ZapAmountCommentNotification(
zapRequest.author,
zapRequest.event?.content()?.ifBlank { null },
showAmountAxis(amount)
)
}
}
}
return null
}
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
viewModelScope.launch(Dispatchers.IO) {
innerZap(note, amount, pollOption, message, context, onError, onProgress, zapType)
}
}
suspend fun innerZap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
private suspend fun innerZap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) {
@@ -185,9 +267,12 @@ class AccountViewModel(val account: Account) : ViewModel() {
onProgress(0f)
}
} else {
runCatching {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
ContextCompat.startActivity(context, intent, null)
} catch (e: Exception) {
onError(context.getString(R.string.lightning_wallets_not_found))
}
onProgress(0f)
}
@@ -330,25 +415,44 @@ class AccountViewModel(val account: Account) : ViewModel() {
return account.defaultZapType
}
fun isNoteAcceptable(note: Note, onReady: (Boolean, Boolean, ImmutableSet<Note>) -> Unit) {
val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex
val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true
@Immutable
data class NoteComposeReportState(
val isAcceptable: Boolean = true,
val canPreview: Boolean = true,
val isHiddenAuthor: Boolean = false,
val relevantReports: ImmutableSet<Note> = persistentSetOf()
)
if (isFromLoggedIn || isFromLoggedInFollow) {
// No need to process if from trusted people
onReady(true, true, persistentSetOf())
} else {
val newCanPreview = !note.hasAnyReports()
fun isNoteAcceptable(note: Note, onReady: (NoteComposeReportState) -> Unit) {
viewModelScope.launch {
val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex
val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true
val newIsAcceptable = account.isAcceptable(note)
if (newCanPreview && newIsAcceptable) {
// No need to process reports if nothing is wrong
onReady(true, true, persistentSetOf())
if (isFromLoggedIn || isFromLoggedInFollow) {
// No need to process if from trusted people
onReady(NoteComposeReportState(true, true, false, persistentSetOf()))
} else if (note.author?.let { account.isHidden(it) } == true) {
onReady(NoteComposeReportState(false, false, true, persistentSetOf()))
} else {
val newRelevantReports = account.getRelevantReports(note)
val newCanPreview = !note.hasAnyReports()
onReady(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet())
val newIsAcceptable = account.isAcceptable(note)
if (newCanPreview && newIsAcceptable) {
// No need to process reports if nothing is wrong
onReady(NoteComposeReportState(true, true, false, persistentSetOf()))
} else {
val newRelevantReports = account.getRelevantReports(note)
onReady(
NoteComposeReportState(
newIsAcceptable,
newCanPreview,
false,
newRelevantReports.toImmutableSet()
)
)
}
}
}
}
@@ -378,6 +482,75 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
}
fun createStatus(newStatus: String) {
viewModelScope.launch(Dispatchers.IO) {
account.createStatus(newStatus)
}
}
fun updateStatus(it: AddressableNote, newStatus: String) {
viewModelScope.launch(Dispatchers.IO) {
account.updateStatus(it, newStatus)
}
}
fun deleteStatus(it: AddressableNote) {
viewModelScope.launch(Dispatchers.IO) {
account.deleteStatus(it)
}
}
fun checkIfOnline(url: String, onResult: (Boolean) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
val isOnline = OnlineChecker.isOnline(url)
onResult(isOnline)
}
}
fun urlPreview(url: String, onResult: suspend (UrlPreviewState) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url, onResult)
}
}
fun loadReactionTo(note: Note?, onNewReactionType: (String?) -> Unit) {
if (note == null) return
viewModelScope.launch(Dispatchers.Default) {
onNewReactionType(note.getReactionBy(userProfile()))
}
}
fun verifyNip05(userMetadata: UserMetadata, pubkeyHex: String, onResult: (Boolean) -> Unit) {
val nip05 = userMetadata.nip05?.ifBlank { null } ?: return
viewModelScope.launch(Dispatchers.IO) {
Nip05Verifier().verifyNip05(
nip05,
onSuccess = {
// Marks user as verified
if (it == pubkeyHex) {
userMetadata.nip05Verified = true
userMetadata.nip05LastVerificationTime = TimeUtils.now()
onResult(userMetadata.nip05Verified)
} else {
userMetadata.nip05Verified = false
userMetadata.nip05LastVerificationTime = 0
onResult(userMetadata.nip05Verified)
}
},
onError = {
userMetadata.nip05LastVerificationTime = 0
userMetadata.nip05Verified = false
onResult(userMetadata.nip05Verified)
}
)
}
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
return AccountViewModel(account) as AccountViewModel
@@ -50,7 +50,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
Column(Modifier.fillMaxHeight()) {
Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) {
val pagerState = rememberPagerState()
val pagerState = rememberPagerState() { 2 }
val coroutineScope = rememberCoroutineScope()
TabRow(
@@ -73,7 +73,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
}
)
}
HorizontalPager(pageCount = 2, state = pagerState) { page ->
HorizontalPager(state = pagerState) { page ->
when (page) {
0 -> RefresheableFeedView(
privateFeedViewModel,
@@ -623,7 +623,7 @@ fun ShowVideoStreaming(
}
url?.let {
CheckIfUrlIsOnline(url) {
CheckIfUrlIsOnline(url, accountViewModel) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = remember { Modifier.heightIn(max = 300.dp) }
@@ -893,7 +893,7 @@ private fun ShortChannelActionOptions(
Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
Spacer(modifier = StdHorzSpacer)
}
}
@@ -968,7 +968,7 @@ private fun LiveChannelActionOptions(
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
}
}
@@ -57,7 +57,7 @@ fun ChatroomListScreen(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val pagerState = rememberPagerState()
val pagerState = rememberPagerState() { 2 }
val coroutineScope = rememberCoroutineScope()
var moreActionsExpanded by remember { mutableStateOf(false) }
@@ -136,7 +136,6 @@ fun ChatroomListScreen(
}
HorizontalPager(
pageCount = 2,
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
@@ -101,6 +101,7 @@ import kotlinx.coroutines.withContext
@Composable
fun ChatroomScreen(
roomId: String?,
draftMessage: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -110,6 +111,7 @@ fun ChatroomScreen(
it?.let {
PrepareChatroomViewModels(
room = it,
draftMessage = draftMessage,
accountViewModel = accountViewModel,
nav = nav
)
@@ -120,6 +122,7 @@ fun ChatroomScreen(
@Composable
fun ChatroomScreenByAuthor(
authorPubKeyHex: String?,
draftMessage: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -129,6 +132,7 @@ fun ChatroomScreenByAuthor(
it?.let {
PrepareChatroomViewModels(
room = it,
draftMessage = draftMessage,
accountViewModel = accountViewModel,
nav = nav
)
@@ -166,7 +170,12 @@ fun LoadRoomByAuthor(authorPubKeyHex: String, accountViewModel: AccountViewModel
}
@Composable
fun PrepareChatroomViewModels(room: ChatroomKey, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun PrepareChatroomViewModels(
room: ChatroomKey,
draftMessage: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val feedViewModel: NostrChatroomFeedViewModel = viewModel(
key = room.hashCode().toString() + "ChatroomViewModels",
factory = NostrChatroomFeedViewModel.Factory(
@@ -193,6 +202,12 @@ fun PrepareChatroomViewModels(room: ChatroomKey, accountViewModel: AccountViewMo
}
}
if (draftMessage != null) {
LaunchedEffect(key1 = draftMessage) {
newPostModel.message = TextFieldValue(draftMessage)
}
}
ChatroomScreen(
room = room,
feedViewModel = feedViewModel,
@@ -504,7 +519,7 @@ fun ChatroomHeader(
Column(modifier = Modifier.padding(start = 10.dp)) {
UsernameDisplay(baseUser)
ObserveDisplayNip05Status(baseUser)
ObserveDisplayNip05Status(baseUser, accountViewModel = accountViewModel, nav = nav)
}
}
}
@@ -616,7 +631,7 @@ fun NewSubjectView(onClose: () -> Unit, accountViewModel: AccountViewModel, room
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
CloseButton(onPress = {
onClose()
})
@@ -56,7 +56,7 @@ fun ConnectOrbotDialog(onClose: () -> Unit, onPost: () -> Unit, portNumber: Muta
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
CloseButton(onCancel = {
CloseButton(onPress = {
onClose()
})
@@ -69,7 +69,17 @@ fun DiscoverScreen(
) {
val lifeCycleOwner = LocalLifecycleOwner.current
val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN)
val tabs by remember(discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel, discoveryChatFeedViewModel) {
mutableStateOf(
listOf(
TabItem(R.string.discover_live, discoveryLiveFeedViewModel, Route.Discover.base + "Live", ScrollStateKeys.DISCOVER_LIVE, LiveActivitiesEvent.kind),
TabItem(R.string.discover_community, discoveryCommunityFeedViewModel, Route.Discover.base + "Community", ScrollStateKeys.DISCOVER_COMMUNITY, CommunityDefinitionEvent.kind),
TabItem(R.string.discover_chat, discoveryChatFeedViewModel, Route.Discover.base + "Chats", ScrollStateKeys.DISCOVER_CHATS, ChannelCreateEvent.kind)
).toImmutableList()
)
}
val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size }
WatchAccountForDiscoveryScreen(
discoveryLiveFeedViewModel = discoveryLiveFeedViewModel,
@@ -92,16 +102,6 @@ fun DiscoverScreen(
}
}
val tabs by remember(discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel, discoveryChatFeedViewModel) {
mutableStateOf(
listOf(
TabItem(R.string.discover_live, discoveryLiveFeedViewModel, Route.Discover.base + "Live", ScrollStateKeys.DISCOVER_LIVE, LiveActivitiesEvent.kind),
TabItem(R.string.discover_community, discoveryCommunityFeedViewModel, Route.Discover.base + "Community", ScrollStateKeys.DISCOVER_COMMUNITY, CommunityDefinitionEvent.kind),
TabItem(R.string.discover_chat, discoveryChatFeedViewModel, Route.Discover.base + "Chats", ScrollStateKeys.DISCOVER_CHATS, ChannelCreateEvent.kind)
).toImmutableList()
)
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -139,7 +139,7 @@ private fun DiscoverPages(
}
}
HorizontalPager(pageCount = 3, state = pagerState) { page ->
HorizontalPager(state = pagerState) { page ->
RefresheableView(tabs[page].viewModel, true) {
SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState ->
RenderDiscoverFeed(
@@ -78,7 +78,7 @@ fun HiddenUsersScreen(
Column(Modifier.fillMaxHeight()) {
Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) {
val pagerState = rememberPagerState()
val pagerState = rememberPagerState() { 2 }
val coroutineScope = rememberCoroutineScope()
var warnAboutReports by remember { mutableStateOf(accountViewModel.account.warnAboutPostsWithReports) }
var filterSpam by remember { mutableStateOf(accountViewModel.account.filterSpamFromStrangers) }
@@ -129,7 +129,7 @@ fun HiddenUsersScreen(
}
)
}
HorizontalPager(pageCount = 2, state = pagerState) { page ->
HorizontalPager(state = pagerState) { page ->
when (page) {
0 -> RefreshingUserFeedView(hiddenFeedViewModel, accountViewModel, nav)
1 -> RefreshingUserFeedView(spammerFeedViewModel, accountViewModel, nav)
@@ -29,7 +29,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
@@ -42,7 +41,6 @@ import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@@ -56,7 +54,7 @@ fun HomeScreen(
) {
var wantsToAddNip47 by remember(nip47) { mutableStateOf(nip47) }
val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN)
val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN) { 2 }
WatchAccountForHomeScreen(homeFeedViewModel, repliesFeedViewModel, accountViewModel)
@@ -124,7 +122,7 @@ private fun HomePages(
}
}
HorizontalPager(pageCount = 2, state = pagerState) { page ->
HorizontalPager(state = pagerState) { page ->
RefresheableFeedView(
viewModel = tabs[page].viewModel,
routeForLastRead = tabs[page].routeForLastRead,
@@ -136,12 +134,14 @@ private fun HomePages(
}
@Composable
fun CheckIfUrlIsOnline(url: String, whenOnline: @Composable () -> Unit) {
fun CheckIfUrlIsOnline(url: String, accountViewModel: AccountViewModel, whenOnline: @Composable () -> Unit) {
var online by remember { mutableStateOf(false) }
LaunchedEffect(key1 = url) {
launch(Dispatchers.IO) {
online = OnlineChecker.isOnline(url)
accountViewModel.checkIfOnline(url) { isOnline ->
if (online != isOnline) {
online = isOnline
}
}
}
@@ -162,11 +162,9 @@ fun WatchAccountForHomeScreen(
val followState by accountViewModel.account.userProfile().live().follows.observeAsState()
LaunchedEffect(accountViewModel, accountState?.account?.defaultHomeFollowList, followState) {
launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.checkKeysInvalidateDataAndSendToTop()
repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop()
}
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.checkKeysInvalidateDataAndSendToTop()
repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -73,42 +73,38 @@ fun LoadRedirectScreen(eventId: String?, accountViewModel: AccountViewModel, nav
fun LoadRedirectScreen(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteState by baseNote.live().metadata.observeAsState()
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = noteState) {
scope.launch {
val note = noteState?.note ?: return@launch
var event = note.event
val channelHex = note.channelHex()
val note = noteState?.note ?: return@LaunchedEffect
var event = note.event
val channelHex = note.channelHex()
if (event is GiftWrapEvent) {
event = accountViewModel.unwrap(event)
}
if (event is GiftWrapEvent) {
event = accountViewModel.unwrap(event)
}
if (event is SealedGossipEvent) {
event = accountViewModel.unseal(event)
}
if (event is SealedGossipEvent) {
event = accountViewModel.unseal(event)
}
if (event == null) {
// stay here, loading
} else if (event is ChannelCreateEvent) {
nav("Channel/${note.idHex}")
} else if (event is ChatroomKeyable) {
note.author?.let {
val withKey = (event as ChatroomKeyable)
.chatroomKey(accountViewModel.userProfile().pubkeyHex)
if (event == null) {
// stay here, loading
} else if (event is ChannelCreateEvent) {
nav("Channel/${note.idHex}")
} else if (event is ChatroomKeyable) {
note.author?.let {
val withKey = (event as ChatroomKeyable)
.chatroomKey(accountViewModel.userProfile().pubkeyHex)
withContext(Dispatchers.IO) {
accountViewModel.userProfile().createChatroom(withKey)
}
nav("Room/${withKey.hashCode()}")
withContext(Dispatchers.IO) {
accountViewModel.userProfile().createChatroom(withKey)
}
} else if (channelHex != null) {
nav("Channel/$channelHex")
} else {
nav("Note/${note.idHex}")
nav("Room/${withKey.hashCode()}")
}
} else if (channelHex != null) {
nav("Channel/$channelHex")
} else {
nav("Note/${note.idHex}")
}
}
@@ -113,12 +113,12 @@ var hasAlreadyAskedNotificationPermissions = false
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CheckifItNeedsToRequestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasAlreadyAskedNotificationPermissions) {
val notificationPermissionState = rememberPermissionState(
Manifest.permission.POST_NOTIFICATIONS
)
if (!notificationPermissionState.status.isGranted && !hasAlreadyAskedNotificationPermissions) {
if (!notificationPermissionState.status.isGranted) {
hasAlreadyAskedNotificationPermissions = true
// This will pause the APP, including the connection with relays.
@@ -308,7 +308,6 @@ private fun RenderSurface(
columnSize = it
}
) {
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
@@ -347,7 +346,6 @@ private fun RenderSurface(
) {
RenderScreen(
baseUser,
pagerState,
tabRowModifier,
pagerModifier,
threadsViewModel,
@@ -370,7 +368,6 @@ private fun RenderSurface(
@OptIn(ExperimentalFoundationApi::class)
private fun RenderScreen(
baseUser: User,
pagerState: PagerState,
tabRowModifier: Modifier,
pagerModifier: Modifier,
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
@@ -384,6 +381,8 @@ private fun RenderScreen(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val pagerState = rememberPagerState { 9 }
Column() {
ProfileHeader(baseUser, appRecommendations, nav, accountViewModel)
ScrollableTabRow(
@@ -395,7 +394,6 @@ private fun RenderScreen(
CreateAndRenderTabs(baseUser, pagerState)
}
HorizontalPager(
pageCount = 9,
state = pagerState,
modifier = pagerModifier
) { page ->
@@ -922,7 +920,7 @@ private fun DrawAdditionalInfo(
DisplayBadges(baseUser, nav)
DisplayNip05ProfileStatus(user)
DisplayNip05ProfileStatus(user, accountViewModel)
val website = user.info?.website
if (!website.isNullOrEmpty()) {
@@ -1052,9 +1050,18 @@ fun DisplayLNAddress(
}
}
} else {
runCatching {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
ContextCompat.startActivity(context, intent, null)
} catch (e: Exception) {
scope.launch {
Toast.makeText(
context,
context.getString(R.string.lightning_wallets_not_found),
Toast.LENGTH_LONG
).show()
}
}
}
},
@@ -1089,7 +1096,7 @@ private fun DisplayAppRecommendations(
Text(stringResource(id = R.string.recommended_apps))
FlowRow(
verticalAlignment = Alignment.CenterVertically,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(vertical = 5.dp)
) {
state.feed.value.forEach { app ->
@@ -1174,7 +1181,7 @@ private fun RenderBadgeList(
nav: (String) -> Unit
) {
FlowRow(
verticalAlignment = Alignment.CenterVertically,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(vertical = 5.dp)
) {
list.forEach { badgeAwardEvent ->
@@ -23,12 +23,13 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, nav: (Stri
val feedViewModel: NostrThreadFeedViewModel = viewModel(
key = noteId + "NostrThreadFeedViewModel",
factory = NostrThreadFeedViewModel.Factory(noteId)
factory = NostrThreadFeedViewModel.Factory(accountViewModel.account, noteId)
)
NostrThreadDataSource.loadThread(noteId)
LaunchedEffect(noteId) {
NostrThreadDataSource.loadThread(noteId)
feedViewModel.invalidateData()
feedViewModel.invalidateData(true)
}
DisposableEffect(accountViewModel) {
@@ -37,7 +38,7 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, nav: (Stri
println("Thread Start")
NostrThreadDataSource.loadThread(noteId)
NostrThreadDataSource.start()
feedViewModel.invalidateData()
feedViewModel.invalidateData(true)
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Thread Stop")
@@ -12,12 +12,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
@@ -45,24 +47,27 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.BoostReaction
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.HiddenNote
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteComposeReportState
import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.RenderRelay
import com.vitorpamplona.amethyst.ui.note.ReplyReaction
import com.vitorpamplona.amethyst.ui.note.ViewCountReaction
import com.vitorpamplona.amethyst.ui.note.WatchForReports
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.routeFor
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
import com.vitorpamplona.amethyst.ui.screen.FeedError
import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.theme.Size35dp
@@ -71,7 +76,6 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -131,15 +135,7 @@ private fun SaveableFeedState(
nav: (String) -> Unit,
scrollStateKey: String? = null
) {
val pagerState = if (scrollStateKey != null) {
rememberForeverPagerState(scrollStateKey)
} else {
remember { PagerState() }
}
WatchScrollToTop(videoFeedView, pagerState)
RenderPage(videoFeedView, accountViewModel, pagerState, nav)
RenderPage(videoFeedView, accountViewModel, scrollStateKey, nav)
}
@OptIn(ExperimentalFoundationApi::class)
@@ -163,7 +159,7 @@ public fun WatchScrollToTop(
fun RenderPage(
videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel,
pagerState: PagerState,
pagerStateKey: String?,
nav: (String) -> Unit
) {
val feedState by videoFeedView.feedContent.collectAsState()
@@ -184,12 +180,7 @@ fun RenderPage(
}
is FeedState.Loaded -> {
SlidingCarousel(
state.feed,
pagerState,
accountViewModel,
nav
)
LoadedState(state, pagerStateKey, videoFeedView, accountViewModel, nav)
}
is FeedState.Loading -> {
@@ -201,6 +192,33 @@ fun RenderPage(
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun LoadedState(
state: FeedState.Loaded,
pagerStateKey: String?,
videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val pagerState = if (pagerStateKey != null) {
rememberForeverPagerState(pagerStateKey) { state.feed.value.size }
} else {
rememberPagerState { state.feed.value.size }
}
WatchScrollToTop(videoFeedView, pagerState)
RefresheableView(viewModel = videoFeedView) {
SlidingCarousel(
state.feed,
pagerState,
accountViewModel,
nav
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SlidingCarousel(
@@ -210,7 +228,6 @@ fun SlidingCarousel(
nav: (String) -> Unit
) {
VerticalPager(
pageCount = feed.value.size,
state = pagerState,
beyondBoundsPageCount = 1,
modifier = Modifier.fillMaxSize(1f),
@@ -232,19 +249,14 @@ fun LoadedVideoCompose(
) {
var state by remember {
mutableStateOf(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = persistentSetOf()
)
AccountViewModel.NoteComposeReportState()
)
}
val scope = rememberCoroutineScope()
WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
val newState = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports)
WatchForReports(note, accountViewModel) { newState ->
if (state != newState) {
scope.launch(Dispatchers.Main) {
state = newState
}
@@ -263,7 +275,7 @@ fun LoadedVideoCompose(
@Composable
fun RenderReportState(
state: NoteComposeReportState,
state: AccountViewModel.NoteComposeReportState,
note: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -272,11 +284,12 @@ fun RenderReportState(
Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote ->
if (showHiddenNote) {
Column(remember { Modifier.fillMaxSize(1f) }, verticalArrangement = Arrangement.Center) {
Column(remember { Modifier.fillMaxSize() }, verticalArrangement = Arrangement.Center) {
HiddenNote(
state.relevantReports,
state.isHiddenAuthor,
accountViewModel,
Modifier,
Modifier.fillMaxWidth(),
false,
nav,
onClick = { showReportedNote = true }
@@ -356,7 +369,9 @@ private fun RenderAuthorInformation(
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(
remember { note.author!! },
remember { Modifier.weight(1f) }
remember { Modifier.weight(1f) },
accountViewModel,
nav = nav
)
}
Row(
@@ -434,15 +449,20 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (St
Spacer(modifier = Modifier.height(8.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 75.dp, end = 20.dp)) {
/*
ReplyReaction(baseNote, accountViewModel, iconSize = 40.dp) {
wantsToReplyTo = baseNote
val scope = rememberCoroutineScope()
ReplyReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp) {
scope.launch {
routeFor(
baseNote,
accountViewModel.userProfile()
)?.let { nav(it) }
}
}
BoostReaction(baseNote, accountViewModel, iconSize = 40.dp) {
BoostReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp) {
wantsToQuote = baseNote
}*/
}
LikeReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, nav, iconSize = 40.dp, heartSize = Size35dp, 28.sp)
ZapReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, animationSize = Size35dp)
ZapReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, animationSize = Size35dp, nav = nav)
ViewCountReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, barChartSize = 39.dp, viewCountColorFilter = MaterialTheme.colors.onBackgroundColorFilter)
}
}
@@ -126,7 +126,7 @@ val ChatHeadlineBorders = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.
val VolumeBottomIconSize = Modifier.size(70.dp).padding(10.dp)
val PinBottomIconSize = Modifier.size(70.dp).padding(10.dp)
val NIP05IconSize = Modifier.size(14.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp)
val NIP05IconSize = Modifier.size(13.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp)
val EditFieldModifier = Modifier
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp)
+214 -22
View File
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="point_to_the_qr_code">أشر إلى رمز QR</string>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="point_to_the_qr_code">أشر إلى رمز (QR Code)</string>
<string name="show_qr">أظهر QR</string>
<string name="profile_image">صورة الملف الشخصي</string>
<string name="scan_qr">مسح QR</string>
<string name="scan_qr">مسح رمز ال QR</string>
<string name="show_anyway">عرض على أي حال</string>
<string name="post_was_flagged_as_inappropriate_by">تم وضع علامة على المنشور على أنه غير لائق بواسطة</string>
<string name="post_not_found">المنشور غير موجود</string>
@@ -12,17 +12,19 @@
<string name="could_not_decrypt_the_message">لا يمكن فك تشفير الرسالة</string>
<string name="group_picture">صورة المجموعة</string>
<string name="explicit_content">محتوى فاضح</string>
<string name="spam">رسائل عشوائية</string>
<string name="spam">محتوى عشوائي</string>
<string name="impersonation">التمثيل</string>
<string name="illegal_behavior">تصرف غير قانوني</string>
<string name="unknown">غير معروف</string>
<string name="relay_icon">Relay Icon</string>
<string name="relay_icon">أيقونة الخادم</string>
<string name="unknown_author">كاتب غير معروف</string>
<string name="copy_text">نسخ النص</string>
<string name="copy_user_pubkey">نسخ المفتاح العام</string>
<string name="copy_note_id">نسخ معرف </string>
<string name="broadcast">بث</string>
<string name="block_hide_user">حظر و إخفاء المستخدم</string>
<string name="request_deletion">حذف الطلب</string>
<string name="block_report">حظر / تبليغ</string>
<string name="block_hide_user"><![CDATA[حظر و إخفاء المستخدم]]></string>
<string name="report_spam_scam">الابلاغ عن البريد العشوائي / الاحتيال</string>
<string name="report_impersonation">الإبلاغ عن التمثيل</string>
<string name="report_explicit_content">الإبلاغ عن المحتوى الصريح / الفاضح</string>
@@ -32,19 +34,22 @@
<string name="login_with_a_private_key_to_like_posts">تسجيل الدخول باستخدام مفتاح خاص لإبداء الإعجاب بالمنشورات</string>
<string name="no_zap_amount_setup_long_press_to_change">لا يوجد إعداد لمبلغ Zap. اضغط لفترة طويلة للتغيير</string>
<string name="login_with_a_private_key_to_be_able_to_send_zaps">قم بتسجيل الدخول باستخدام مفتاح خاص لتتمكن من إرسال Zaps</string>
<string name="zaps">Zaps</string>
<string name="login_with_a_private_key_to_be_able_to_follow">قم بتسجيل الدخول باستخدام مفتاح خاص لتتمكن من متابعة المستخدمين</string>
<string name="login_with_a_private_key_to_be_able_to_unfollow">قم بتسجيل الدخول بمفتاحك الخاص للتتمكن من إلغاء متابعة المستخدمين </string>
<string name="view_count">مشاهدة العد</string>
<string name="boost">تعزيز</string>
<string name="boosted">معزز</string>
<string name="quote">إقتباس</string>
<string name="new_amount_in_sats">مبلغ جديد في Sats</string>
<string name="add">إضافة</string>
<string name="replying_to">الرد على</string>
<string name="replying_to">"الرد على"</string>
<string name="and">" و "</string>
<string name="in_channel">"في القناة "</string>
<string name="profile_banner">لافتة الملف الشخصي</string>
<string name="following">المتابعون</string>
<string name="followers">متابِعاً</string>
<string name="payment_successful">تم الدفع بنجاح</string>
<string name="error_parsing_error_message">خطأ أثناء تحليل رسالة الخطأ</string>
<string name="following">"المتابعون"</string>
<string name="followers">"متابِعاً"</string>
<string name="profile">الملف الشخصي</string>
<string name="security_filters">مرشحات الأمان</string>
<string name="log_out">تسجيل الخروج</string>
@@ -54,24 +59,25 @@
<string name="lightning_tips">Lightning تبرع</string>
<string name="note_to_receiver">ملاحظة للمستقبل</string>
<string name="thank_you_so_much">شكرا جزيلا!</string>
<string name="amount_in_sats">المبلغ في Sats</string>
<string name="amount_in_sats">المبلغ ب الSats</string>
<string name="send_sats">إرسال Sats</string>
<string name="error_parsing_preview_for">خطأ في تحليل المعاينة لـ %1$s : %2$s</string>
<string name="preview_card_image_for">معاينة صورة البطاقة لـ %1$s</string>
<string name="error_parsing_preview_for">"خطأ في تحليل المعاينة لـ %1$s : %2$s"</string>
<string name="preview_card_image_for">"معاينة صورة البطاقة لـ %1$s"</string>
<string name="new_channel">قناة جديدة</string>
<string name="channel_name">اسم القناة</string>
<string name="my_awesome_group">مجموعتي الرائعة</string>
<string name="picture_url">رابط الصورة</string>
<string name="description">الوصف</string>
<string name="about_us">معلومات عن القناة..</string>
<string name="about_us">"معلومات عن القناة.."</string>
<string name="what_s_on_your_mind">ما الذي يدور في ذهنك؟</string>
<string name="post">نشر</string>
<string name="save">حفظ</string>
<string name="create">إنشاء</string>
<string name="cancel">إلغاء</string>
<string name="failed_to_upload_the_image">فشل تحميل الصورة</string>
<string name="relay_address">Relay عنوان</string>
<string name="relay_address"> عنوان الخادم (Relay Adress)</string>
<string name="posts">المنشورات</string>
<string name="bytes">Bytes</string>
<string name="errors">الأخطاء</string>
<string name="home_feed">المنشورات الرئيسية</string>
<string name="private_message_feed"> الرسائل الخاصة</string>
@@ -92,7 +98,7 @@
<string name="failed_to_save_the_image">فشل حفظ الصورة</string>
<string name="upload_image">تحميل الصورة</string>
<string name="uploading">جاري التحميل…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">لا يمتلك المستخدم إعداد عنوان lighting لاستقبال sats</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">لا يمتلك المستخدم عنوان (Lightning Address) لاستقبال sats</string>
<string name="reply_here">"الرد هنا.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">نسخ معرف الملاحظة إلى الحافظة للمشاركة</string>
<string name="copy_channel_id_note_to_the_clipboard">انسخ معرف القناة (ملاحظة) إلى الحافظة</string>
@@ -105,10 +111,10 @@
<string name="conversations">المحادثات</string>
<string name="notes">الملاحظات</string>
<string name="replies">الردود</string>
<string name="follows">المتابَعون</string>
<string name="reports">التقارير</string>
<string name="follows">"المتابَعون"</string>
<string name="reports">"التقارير"</string>
<string name="more_options">المزيد من الخيارات</string>
<string name="relays">Relays</string>
<string name="relays">"Relays"</string>
<string name="website">الموقع</string>
<string name="lightning_address">Lightning عنوان</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">نسخ معرف Nsec (كلمة المرور الخاصة بك) إلى الحافظة للنسخ الاحتياطي</string>
@@ -121,7 +127,7 @@
<string name="unblock">رفع الحظر</string>
<string name="copy_user_id">نسخ معرف المستخدم</string>
<string name="unblock_user">إلغاء حظر عن مستخدم</string>
<string name="npub_hex_username">npub, hex, username </string>
<string name="npub_hex_username">"npub, hex, username "</string>
<string name="clear">نظف</string>
<string name="app_logo">شعار التطبيق</string>
<string name="nsec_npub_hex_private_key">nsec / npub / hex private key</string>
@@ -135,7 +141,7 @@
<string name="login">تسجيل دخول</string>
<string name="generate_a_new_key">إنشاء مفتاح جديد</string>
<string name="loading_feed">بإنتظار التحديثات</string>
<string name="error_loading_replies">خطأ في تحميل الردود:</string>
<string name="error_loading_replies">"خطأ في تحميل الردود:"</string>
<string name="try_again">المحاولة مرة اخرى</string>
<string name="feed_is_empty">لا توجد ملاحظات.</string>
<string name="refresh">تحديث</string>
@@ -148,7 +154,7 @@
<string name="leave">مغادرة</string>
<string name="unfollow">الغاء المتابعة</string>
<string name="channel_created">تم انشاء القناة</string>
<string name="channel_information_changed_to">تم تغيير معلومات القناة إلى</string>
<string name="channel_information_changed_to">"تم تغيير معلومات القناة إلى"</string>
<string name="public_chat">الدردشة العامة</string>
<string name="posts_received">تم استلام المشاركات</string>
<string name="remove">ازالة</string>
@@ -168,4 +174,190 @@
<string name="report_hateful_speech">أبلغ عن كلام يحض على الكراهية</string>
<string name="report_nudity_porn">الإبلاغ عن عُري / إباحي</string>
<string name="others">آخرون</string>
<string name="mark_all_known_as_read">ضع علامة على جميع الرسائل/الاشعارات المعروفة كمقروء</string>
<string name="mark_all_new_as_read">ضع علامة على جميع الرسائل/الاشعارات الجديدة كمقروء</string>
<string name="mark_all_as_read">ضع علامة على جميع الرسائل/الاشعارات كمقروء</string>
<string name="backup_keys">قم بعمل نسخة احتياطية للمفاتيح</string>
<string name="account_backup_tips_md" tools:ignore="Typos"> ## النصائح الرئيسية للنسخ الاحتياطي والسلامة
\n\n- يتم تأمين حسابك بمفتاح سري. المفتاح السري هو سلسلة عشوائية طويلة تبدأ بـ **nsec1**. أي شخص لديه حق الوصول إلى مفتاحك السري يمكنه نشر المحتوى باستخدام هويتك.
\n\n- لا تقم بوضع مفتاحك السري في أي موقع أو برنامج لا تثق به.
\n- مطوري Amethyst لن يطلبوا منك **أبدا** المفتاح السري الخاص بك.
\n- حافظ على نسخة احتياطية آمنة لمفتاحك السري لاسترداد الحساب. نوصي باستخدام برنامج لادارة كلمات المرور.
</string>
<string name="secret_key_copied_to_clipboard">مفتاحك السري (nsec) قد تم نسخه في الحافظة</string>
<string name="copy_my_secret_key">إنسخ مفتاحي السري</string>
<string name="biometric_authentication_failed">فشلت المصادقة</string>
<string name="biometric_error">خطأ</string>
<string name="badge_created_by">"تم إنشاؤه بواسطة %1$s"</string>
<string name="badge_award_image_for">"صورة شارة مكافئة ل %1$s"</string>
<string name="new_badge_award_notif">لقد حصلت على شارة جائزة جديدة</string>
<string name="copied_note_text_to_clipboard">تم نسخ نص الملاحظة/المنشور إلى الحافظة</string>
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">تم نسخ عنوان @npub المؤلف إلى الحافظة</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">تم نسخ رمز الملاحظة/المنشور (note ID) إلى الحافظة</string>
<string name="select_text_dialog_top">تحديد نص</string>
<string name="private_conversation_notification">"&lt;استحالة فك تشفير الرسالة الخاصة&gt;
\n\n أنت الآن في محادثة خاصة/مشفرة بين %1$s و %2$s ."</string>
<string name="account_switch_add_account_dialog_title">إضافة حساب جديد</string>
<string name="drawer_accounts">الحسابات</string>
<string name="account_switch_select_account">اختر حساب</string>
<string name="account_switch_add_account_btn">إضافة حساب جديد</string>
<string name="account_switch_active_account">تنشيط الحساب</string>
<string name="account_switch_has_private_key">يحتوي على مفتاح خاص</string>
<string name="account_switch_pubkey_only">قراءة فقط، لا يوجد مفتاح خاص</string>
<string name="back">رجوع</string>
<string name="quick_action_select">اختر</string>
<string name="quick_action_share_browser_link">شارك رابط المتصفح</string>
<string name="quick_action_share">مشاركة</string>
<string name="quick_action_copy_user_id">معرف المؤلف (ID)</string>
<string name="quick_action_copy_note_id">رمز/معرف الملاحظة (Note ID)</string>
<string name="quick_action_copy_text">نسخ النص</string>
<string name="quick_action_delete">حذف</string>
<string name="quick_action_unfollow">الغاء المتابعة</string>
<string name="quick_action_follow">متابعة</string>
<string name="quick_action_request_deletion_alert_title">طلب الحذف</string>
<string name="quick_action_request_deletion_alert_body">سيطلب Amethyst حذف ملاحظتك من الخوادم (relays) الذي أنت متصل به حاليا. لا يوجد ضمان لحذف ملاحظتك بشكل دائم من تلك الخوادم أو من الخوادم الأخرى التي ربما حفظت فيها.</string>
<string name="quick_action_block_dialog_btn">حظر </string>
<string name="quick_action_delete_dialog_btn">حذف</string>
<string name="quick_action_block">حظر</string>
<string name="quick_action_report">التبليغ</string>
<string name="quick_action_delete_button">حذف</string>
<string name="quick_action_dont_show_again_button">لا تظهر مرة أخرى</string>
<string name="report_dialog_illegal">تصرف غير قانوني</string>
<string name="report_dialog_blocking_a_user">حظر المستخدم سوف يخفي محتواه في التطبيق الخاص بك. ملاحظاتك لا تزال قابلة للعرض علناً، بما في ذلك للأشخاص الذين تقوم بحظرهم. يتم إدراج المستخدمين المحظورين على شاشة مرشحات الأمان (Security Filters screen).</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[حظر و إخفاء المستخدم]]></string>
<string name="report_dialog_reminder_public">ستكون جميع التقارير المنشورة مرئية للجمهور.</string>
<string name="report_dialog_additional_reason_placeholder">اختياريا تستطيع توفير سياق إضافي حول تقريرك…</string>
<string name="report_dialog_additional_reason_label">سياق إضافي</string>
<string name="report_dialog_select_reason_label">السبب</string>
<string name="report_dialog_select_reason_placeholder">حدد سببا</string>
<string name="report_dialog_post_report_btn">تبليغ المنشور (Report)</string>
<string name="report_dialog_title">حظر / تبليغ</string>
<string name="block_only">حظر </string>
<string name="bookmarks">المفضلة</string>
<string name="private_bookmarks">المفضلة الخاصة</string>
<string name="public_bookmarks">المفضلة العامة</string>
<string name="add_to_private_bookmarks">إضافة إلى المفضلات الخاصة</string>
<string name="add_to_public_bookmarks">إضافة إلى المفضلات العامة</string>
<string name="remove_from_private_bookmarks">إزالة من المفضلات الخاصة</string>
<string name="remove_from_public_bookmarks">إزالة من المفضلات العامة</string>
<string name="wallet_connect_service">خدمة Wallet Connect </string>
<string name="wallet_connect_service_relay">خادم Wallet Connect </string>
<string name="wallet_connect_service_secret">كلمة سر خدمة Wallet Connect</string>
<string name="wallet_connect_service_show_secret">إظهار كلمة السر</string>
<string name="wallet_connect_service_secret_placeholder">المفتاح السري بصيغة nsec أو hex</string>
<string name="post_poll">نشر الاستطلاع</string>
<string name="poll_heading_required">الحقول المطلوبة:</string>
<string name="poll_primary_description">وصف الاستطلاع الأساسي…</string>
<string name="poll_option_index">الخيار %s</string>
<string name="poll_option_description">وصف خيار الاستطلاع</string>
<string name="poll_heading_optional">حقل اختياري</string>
<string name="poll_zap_value_min">الحد الأدنى</string>
<string name="poll_zap_value_max">الحد الاقصى</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">إغلاق بعد</string>
<string name="poll_closing_time_days">أيام</string>
<string name="poll_is_closed">تم إغلاق الاستطلاع لأي تصويت جديد</string>
<string name="one_vote_per_user_on_atomic_votes">يسمح بصوت واحد فقط لكل مستخدم في هذا النوع من الاستطلاع</string>
<string name="looking_for_event">"البحث عن الحدث %1$s"</string>
<string name="custom_zaps_add_a_message">أضف رسالة عامة</string>
<string name="custom_zaps_add_a_message_private">أضف رسالة خاصة</string>
<string name="custom_zaps_add_a_message_nonzap">أضف رسالة مع الفاتورة</string>
<string name="custom_zaps_add_a_message_example">شكرا لك على كل مساهمتك!</string>
<string name="lightning_create_and_add_invoice">إنشاء وإضافة</string>
<string name="poll_author_no_vote">لا يستطيع مؤلف الاستطلاع التصويت في استطلاعه.</string>
<string name="hash_verification_passed">هذا المحتوى هو نفسه منذ النشر</string>
<string name="hash_verification_failed">لقد تغير هذا المحتوى. ربما لم يشاهد مؤلفه التغيير أو يوافق عليه بعد.</string>
<string name="content_description_add_image">أضف صورة</string>
<string name="content_description_add_video">اضف فيديو</string>
<string name="content_description_add_document">أضف مستند</string>
<string name="add_content">أضف إلى الرسالة</string>
<string name="content_description">وصف المحتوى/المحتويات</string>
<string name="content_description_example">A blue boat in a white sandy beach at sunset
(قارب أزرق في شاطئ رملي أبيض عند غروب الشمس)</string>
<string name="zap_type_public">عام</string>
<string name="zap_type_public_explainer">يستطيع الجميع رؤية المعاملة والرسالة</string>
<string name="zap_type_private">خاص</string>
<string name="zap_type_private_explainer">يمكن للمرسل والمستقبل رؤية بعضهما البعض وقراءة الرسالة</string>
<string name="zap_type_anonymous">مجهول الهوية</string>
<string name="zap_type_anonymous_explainer">المستقبل والجمهور لا يعرفان من الذي قام بإرسال الدفع</string>
<string name="file_server">خادم الملفات</string>
<string name="upload_server_imgur">imgur.com - موثوق به</string>
<string name="upload_server_imgur_explainer">يمكن لـ Imgur تعديل الملف</string>
<string name="upload_server_nostrimg">nostrimg.com - (موثوق به)</string>
<string name="upload_server_nostrimg_explainer">يمكن لـ NostrImg تعديل الملف</string>
<string name="upload_server_nostrbuild">nostr.build- (موثوق به)</string>
<string name="upload_server_nostrbuild_explainer">يمكن لـ Nostr.build تعديل الملف</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev - (موثوق به)</string>
<string name="upload_server_nostrfilesdev_explainer">يمكن لـ Nostrfiles.dev تعديل الملف</string>
<string name="upload_server_nostrcheckme">nostrcheck.me - (موثوق به)</string>
<string name="upload_server_nostrcheckme_explainer">يمكن لـ nostrcheck.me تعديل الملف</string>
<string name="upload_server_imgur_nip94_explainer">تحقق مما إذا قام Imgur بتعديل الملف.
NIP جديد: قد لا يراه العملاء الآخرون</string>
<string name="app_notification_zaps_channel_message_from">من طرف %1$s</string>
<string name="app_notification_zaps_channel_message_for">ل %1$s</string>
<string name="reply_notify">تنبيه: </string>
<string name="channel_list_join_conversation">الانضمام إلى المحادثة</string>
<string name="channel_list_user_or_group_id">معرف المستخدم أو المجموعة</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent أو hex</string>
<string name="channel_list_create_channel">إنشاء</string>
<string name="channel_list_join_channel">إنضمام</string>
<string name="today">اليوم</string>
<string name="content_warning">تحذير من المحتوى</string>
<string name="content_warning_hide_all_sensitive_content">إخفاء المحتوى الحساس دائماً</string>
<string name="content_warning_show_all_sensitive_content">إظهار المحتوى الحساس دائماً</string>
<string name="content_warning_see_warnings">إظهار تحذيرات المحتوى دائمًا</string>
<string name="recommended_apps">التطبيقات الموصى بها : </string>
<string name="filter_spam_from_strangers">تصفية الرسائل المزعجة من الغرباء</string>
<string name="warn_when_posts_have_reports_from_your_follows">تنبيه عندما تكون منشورتك قد تم التبليغ عنها </string>
<string name="new_reaction_symbol">رمز رد فعل جديد</string>
<string name="no_reaction_type_setup_long_press_to_change">لم يتم تحديد أي نوع من ردود الفعل. اضغط مطولاً للتغيير</string>
<string name="read_from_relay">القراءة من الخادم (Relay)</string>
<string name="write_to_relay">الكتابة للخادم</string>
<string name="an_error_occurred_trying_to_get_relay_information">حدث خطأ أثناء محاولة الحصول على معلومات الخادم من %1$s</string>
<string name="owner">المالك</string>
<string name="version">الاصدار</string>
<string name="software">البرنامج</string>
<string name="contact">جهة الاتصال</string>
<string name="supports">الNIPs المدعومة</string>
<string name="admission_fees">رسوم القبول</string>
<string name="payments_url">رابط الدفع</string>
<string name="limitations">الحدود</string>
<string name="countries">الدول</string>
<string name="languages">اللغات</string>
<string name="tags">الوسوم</string>
<string name="posting_policy">سياسات النشر</string>
<string name="message_length">طول الرسالة</string>
<string name="subscriptions">الإشتراكات</string>
<string name="filters">الفلاتر</string>
<string name="subscription_id_length">طول معرّف الاشتراك</string>
<string name="minimum_prefix">الحد الأدنى لل prefix </string>
<string name="maximum_event_tags">الحد الأقصى لوسوم الحدث</string>
<string name="content_length">طول المحتوى</string>
<string name="minimum_pow">الحد الأدنى لل PoW</string>
<string name="auth">المصادقة</string>
<string name="payment">الدفع</string>
<string name="cashu">Cashu Token</string>
<string name="geohash_title">عرض الموقع كـ </string>
<string name="new_feature_nip24_might_not_be_available_title">ميزة جديدة</string>
<string name="new_feature_nip24_activate">تفعيل</string>
<string name="messages_create_public_chat">عام</string>
<string name="messages_new_message">خاص</string>
<string name="messages_new_message_to">إلى</string>
<string name="messages_new_message_subject">الموضوع</string>
<string name="messages_new_message_subject_caption">موضوع المحادثة</string>
<string name="messages_new_message_to_caption">"\@User1, @User2, @User3"</string>
<string name="messages_group_descriptor">أعضاء هذه المجموعة</string>
<string name="messages_new_subject_message">التوضيح للأعضاء</string>
<string name="messages_new_subject_message_placeholder">تغيير اسم الأهداف الجديدة.</string>
<string name="language_description">من أجل واجهة التطبيق</string>
<string name="theme_description">مظهر غامق أو فاتح أو اوتوماتيكي</string>
<string name="automatically_load_images_gifs_description">تحميل الصور و الصور المتحركة تلقائياً</string>
<string name="automatically_play_videos_description">تشغيل الفيديوهات و الصور المتحركة تلقائياً</string>
<string name="automatically_show_url_preview_description">إظهار معاينات الروابط</string>
<string name="load_image_description">متى يتم تحميل الصور</string>
<string name="copy_url_to_clipboard">نسخ عنوان الرابط إلى الحافظة</string>
<string name="copy_the_note_id_to_the_clipboard">انسخ معرف النص (ID) إلى الحافظة</string>
<string name="created_at">أنشئ في</string>
<string name="rules">القواعد</string>
<string name="status_update">تحديث الحالة</string>
</resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"></resources>
+5 -76
View File
@@ -12,7 +12,6 @@
<string name="could_not_decrypt_the_message">Nepodařilo se dešifrovat zprávu</string>
<string name="group_picture">Obrázek skupiny</string>
<string name="explicit_content">Explicitní obsah</string>
<string name="spam">Spam</string>
<string name="impersonation">Zneužívání identity</string>
<string name="illegal_behavior">Nezákonné jednání</string>
<string name="unknown">Neznámý</string>
@@ -38,7 +37,6 @@
<string name="login_with_a_private_key_to_be_able_to_unfollow">Přihlaste se pomocí soukromého klíče, abyste mohli zrušit sledování</string>
<string name="zaps">Zapy</string>
<string name="view_count">Počet zobrazení</string>
<string name="boost">Boost</string>
<string name="boosted">boostováno</string>
<string name="quote">Citovat</string>
<string name="new_amount_in_sats">Nová částka v sats</string>
@@ -170,9 +168,6 @@
<string name="nip_05">Nostr Adresa</string>
<string name="never">nikdy</string>
<string name="now">nyní</string>
<string name="h">h</string>
<string name="m">m</string>
<string name="d">d</string>
<string name="nudity">Nahota</string>
<string name="profanity_hateful_speech">Neslušnost / Nenávistná řeč</string>
<string name="report_hateful_speech">Nahlásit nenávistnou řeč</string>
@@ -182,8 +177,7 @@
<string name="mark_all_new_as_read">Označit všechny nové jako přečtené</string>
<string name="mark_all_as_read">Označit všechny jako přečtené</string>
<string name="backup_keys">Zálohovat klíče</string>
<string name="account_backup_tips_md" tools:ignore="Typos">
## Tipy na zálohování a bezpečnost účtu
<string name="account_backup_tips_md" tools:ignore="Typos"> ## Tipy na zálohování a bezpečnost účtu
\n\nVáš účet je zabezpečen tajným klíčem. Klíč je dlouhý náhodný řetězec začínající **nsec1**. Každý, kdo má přístup k vašemu tajnému klíči, může publikovat obsah pod vaší identitou.
\n\n- **Nepoužívejte** svůj tajný klíč na webových stránkách nebo v softwaru, kterému nedůvěřujete.
\n- Vývojáři aplikace Amethyst vás **nikdy** nebudou žádat o váš tajný klíč.
@@ -193,8 +187,8 @@
<string name="copy_my_secret_key">Zkopírovat můj tajný klíč</string>
<string name="biometric_authentication_failed">Autentizace se nezdařila</string>
<string name="biometric_error">Chyba</string>
<string name="badge_created_by">Vytvořeno uživatelem %1$s</string>
<string name="badge_award_image_for">Obrázek ocenění od %1$s</string>
<string name="badge_created_by">"Vytvořeno uživatelem %1$s"</string>
<string name="badge_award_image_for">"Obrázek ocenění od %1$s"</string>
<string name="new_badge_award_notif">Obdrželi jste nové ocenění</string>
<string name="award_granted_to">Ocenění uděleno uživateli</string>
<string name="copied_note_text_to_clipboard">Text poznámky zkopírován do schránky</string>
@@ -250,7 +244,6 @@
<string name="add_to_public_bookmarks">Přidat do veřejných záložek</string>
<string name="remove_from_private_bookmarks">Odebrat ze soukromých záložek</string>
<string name="remove_from_public_bookmarks">Odebrat z veřejných záložek</string>
<string name="wallet_connect_service">Služba Wallet Connect</string>
<string name="wallet_connect_service_explainer">Autorizuje Nostr Secret k provedení platby zapsům, aniž byste opustili aplikaci. Uchovávejte tajemství v bezpečí a pokud je to možné, používejte soukromý přepojovač.</string>
<string name="wallet_connect_service_pubkey">Veřejný klíč Wallet Connect</string>
@@ -258,7 +251,6 @@
<string name="wallet_connect_service_secret">Tajemství Wallet Connect</string>
<string name="wallet_connect_service_show_secret">Zobrazit tajný klíč</string>
<string name="wallet_connect_service_secret_placeholder">nsec / hex soukromý klíč</string>
<string name="pledge_amount_in_sats">Částka záruky v sats</string>
<string name="post_poll">Zveřejnit hlasování</string>
<string name="poll_heading_required">Povinná pole:</string>
@@ -270,99 +262,69 @@
<string name="poll_zap_value_min">Minimální zaps</string>
<string name="poll_zap_value_max">Maximální zaps</string>
<string name="poll_consensus_threshold">Konsensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">Uzavřít po</string>
<string name="poll_closing_time_days">dnech</string>
<string name="poll_is_closed">Hlasování je uzavřeno pro nové hlasy</string>
<string name="poll_zap_amount">Částka zapsu</string>
<string name="one_vote_per_user_on_atomic_votes">U tohoto typu hlasování je povolen pouze jeden hlas na uživatele</string>
<string name="looking_for_event">"Hledá se událost %1$s"</string>
<string name="custom_zaps_add_a_message">Přidat veřejnou zprávu</string>
<string name="custom_zaps_add_a_message_private">Přidat soukromou zprávu</string>
<string name="custom_zaps_add_a_message_nonzap">Přidat fakturu</string>
<string name="custom_zaps_add_a_message_example">Děkuji za veškerou práci!</string>
<string name="lightning_create_and_add_invoice">Vytvořit a přidat</string>
<string name="poll_author_no_vote">Autoři hlasování nemohou hlasovat ve vlastních hlasováních.</string>
<string name="hash_verification_passed">Obrázek je stejný jako v původním příspěvku</string>
<string name="hash_verification_failed">Obrázek se změnil. Autor možná změnu nezaznamenal</string>
<string name="content_description_add_image">Přidat obrázek</string>
<string name="content_description_add_video">Přidat video</string>
<string name="content_description_add_document">Přidat dokument</string>
<string name="add_content">Přidat do zprávy</string>
<string name="content_description">Popis obsahu</string>
<string name="content_description_example">Modrá loď na bílé písčité pláži při západu slunce</string>
<string name="zap_type">Typ zapsu</string>
<string name="zap_type_explainer">Typ zapsu pro všechny možnosti</string>
<string name="zap_type_public">Veřejný</string>
<string name="zap_type_public_explainer">Všichni mohou vidět transakci a zprávu</string>
<string name="zap_type_private">Soukromý</string>
<string name="zap_type_private_explainer">Odesílatel a příjemce si mohou navzájem vidět a číst zprávu</string>
<string name="zap_type_anonymous">Anonymní</string>
<string name="zap_type_anonymous_explainer">Příjemce a veřejnost neví, kdo platbu poslal</string>
<string name="zap_type_nonzap">Non-Zap</string>
<string name="zap_type_nonzap_explainer">Žádná stopa v Nostr, pouze v Lightning</string>
<string name="file_server">Souborový server</string>
<string name="zap_forward_lnAddress">LnAddress nebo @Uživatel</string>
<string name="upload_server_imgur">imgur.com důvěryhodný</string>
<string name="upload_server_imgur_explainer">Imgur může upravit soubor</string>
<string name="upload_server_nostrimg">nostrimg.com důvěryhodný</string>
<string name="upload_server_nostrimg_explainer">NostrImg může upravit soubor</string>
<string name="upload_server_nostrbuild">nostr.build důvěryhodný</string>
<string name="upload_server_nostrbuild_explainer">Nostr.build může upravit soubor</string>
<string name="upload_server_nostrfilesdev">nostrfiles.dev důvěryhodný</string>
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev může upravit soubor</string>
<string name="upload_server_nostrcheckme">nostrcheck.me - důvěryhodný</string>
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me může upravit soubor</string>
<string name="upload_server_imgur_nip94">Ověřitelný Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Ověří, zda Imgur upravil soubor. Nový NIP: jiní klienti ho možná neuvidí</string>
<string name="upload_server_nostrimg_nip94">Ověřitelný NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">Ověří, zda NostrImg upravil soubor. Nový NIP: jiní klienti ho možná neuvidí</string>
<string name="upload_server_nostrbuild_nip94">Ověřitelný Nostr.build (NIP-94)</string>
<string name="upload_server_nostrbuild_nip94_explainer">Ověří, zda Nostr.build upravil soubor. Nový NIP: jiní klienti ho možná neuvidí</string>
<string name="upload_server_nostrfilesdev_nip94">Ověřitelný Nostrfiles.dev (NIP-94)</string>
<string name="upload_server_nostrfilesdev_nip94_explainer">Ověří, zda Nostrfiles.dev upravil soubor. Nový NIP: jiní klienti ho možná neuvidí</string>
<string name="upload_server_nostrcheckme_nip94">Ověřitelný Nostrcheck.me (NIP-94)</string>
<string name="upload_server_nostrcheckme_nip94_explainer">Ověří, zda Nostrcheck.me upravil soubor. Nový NIP: jiní klienti ho možná neuvidí</string>
<string name="upload_server_relays_nip95">Vaše relé (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Soubory jsou hostovány vašimi relé. Nový NIP: zkontrolujte, zda je podporují</string>
<string name="connect_via_tor_short">Nastavení Tor/Orbot</string>
<string name="connect_via_tor">Připojit se přes váš Orbot</string>
<string name="do_you_really_want_to_disable_tor_title">Odpojit se od Orbotu/Toru?</string>
<string name="do_you_really_want_to_disable_tor_text">Vaše data budou okamžitě přenesena do běžné sítě</string>
<string name="yes">Ano</string>
<string name="no">Ne</string>
<string name="follow_list_selection">Seznam sledovaných</string>
<string name="follow_list_kind3follows">Všechna sledování</string>
<string name="follow_list_global">Globální</string>
<string name="connect_through_your_orbot_setup_markdown">
## Připojte se přes Tor s Orbotem
<string name="connect_through_your_orbot_setup_markdown"> ## Připojte se přes Tor s Orbotem
\n\n1. Nainstalujte [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
\n2. Spusťte Orbot
\n3. V Orbotu zkontrolujte port pro Socks. Výchozí hodnota je 9050
@@ -370,54 +332,40 @@
\n5. Nastavte port Socks v této obrazovce
\n6. Stiskněte tlačítko Aktivovat Tor
</string>
<string name="orbot_socks_port">Orbot Socks Port</string>
<string name="invalid_port_number">Neplatné číslo portu</string>
<string name="use_orbot">Použít Orbot</string>
<string name="disconnect_from_your_orbot_setup">Odpojit Tor/Orbot</string>
<string name="app_notification_dms_channel_name">Soukromé zprávy</string>
<string name="app_notification_dms_channel_description">Upozorní vás, když dorazí soukromá zpráva</string>
<string name="app_notification_zaps_channel_name">Přijaté zapsy</string>
<string name="app_notification_zaps_channel_description">Upozorní vás, když vás někdo zapsne</string>
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">Od %1$s</string>
<string name="app_notification_zaps_channel_message_for">pro %1$s</string>
<string name="reply_notify">Upozornit: </string>
<string name="channel_list_join_conversation">Připojit se ke konverzaci</string>
<string name="channel_list_user_or_group_id">ID uživatele nebo skupiny</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent nebo hex</string>
<string name="channel_list_create_channel">Vytvořit</string>
<string name="channel_list_join_channel">Připojit se</string>
<string name="today">Dnes</string>
<string name="content_warning">Upozornění na obsah</string>
<string name="content_warning_explanation">Tento příspěvek obsahuje citlivý obsah, který může být pro některé lidi urážlivý nebo rušivý</string>
<string name="content_warning_hide_all_sensitive_content">Vždy skrýt citlivý obsah</string>
<string name="content_warning_show_all_sensitive_content">Vždy zobrazit citlivý obsah</string>
<string name="content_warning_see_warnings">Vždy zobrazovat upozornění na obsah</string>
<string name="recommended_apps">Doporučuje: </string>
<string name="filter_spam_from_strangers">Filtrovat spam od cizinců</string>
<string name="warn_when_posts_have_reports_from_your_follows">Varovat, když příspěvky obsahují hlášení od vašich sledovaných osob</string>
<string name="new_reaction_symbol">Nový symbol reakce</string>
<string name="no_reaction_type_setup_long_press_to_change">Nebyly vybrány žádné typy reakcí. Dlouhým stiskem změňte</string>
<string name="zapraiser">Zapraiser</string>
<string name="zapraiser_explainer">Přidá cílovou částku sats pro tento příspěvek. Podporované klienty ji mohou zobrazovat jako ukazatel postupu k podněcování darování</string>
<string name="zapraiser_target_amount_in_sats">Cílová částka v sats</string>
<string name="sats_to_complete">Zapraiser na %1$s. Do cíle zbývá %2$s sats</string>
<string name="read_from_relay">Číst z Relay</string>
<string name="write_to_relay">Zapisovat do Relay</string>
<string name="an_error_occurred_trying_to_get_relay_information">Při pokusu o získání informací z Relay se vyskytla chyba z %1$s</string>
<string name="owner">Vlastník</string>
<string name="version">Verze</string>
<string name="software">Software</string>
<string name="contact">Kontakt</string>
<string name="supports">Podporované NIPs</string>
<string name="admission_fees">Vstupné</string>
@@ -437,38 +385,29 @@
<string name="minimum_pow">Minimální PoW</string>
<string name="auth">Ověření</string>
<string name="payment">Platba</string>
<string name="cashu">Cashu Token</string>
<string name="cashu_redeem">Vyměnit</string>
<string name="no_lightning_address_set">Není nastavena žádná Lightning adresa</string>
<string name="copied_token_to_clipboard">Token zkopírován do schránky</string>
<string name="live_stream_live_tag">ŽIVĚ</string>
<string name="live_stream_offline_tag">OFFLINE</string>
<string name="live_stream_ended_tag">UKONČENO</string>
<string name="live_stream_planned_tag">PLÁNOVÁNO</string>
<string name="live_stream_is_offline">Živé vysílání je offline</string>
<string name="live_stream_has_ended">Živé vysílání ukončeno</string>
<string name="are_you_sure_you_want_to_log_out">Odhlášení vymaže všechny vaše místní informace. Ujistěte se, že máte zálohované své privátní klíče, abyste se vyhnuli ztrátě účtu. Chcete pokračovat?</string>
<string name="followed_tags">Sledované značky</string>
<string name="relay_setup">Rele</string>
<string name="discover_live">Živě</string>
<string name="discover_community">Komunita</string>
<string name="discover_chat">Chaty</string>
<string name="community_approved_posts">Schválené příspěvky</string>
<string name="groups_no_descriptor">Tato skupina nemá popis ani pravidla. Promluvte si s majitelem, aby je přidal/a.</string>
<string name="community_no_descriptor">Tato komunita nemá popis. Promluvte si s majitelem, aby ho přidal/a.</string>
<string name="add_sensitive_content_label">Citlivý obsah</string>
<string name="add_sensitive_content_description">Před zobrazením tohoto obsahu přidá upozornění na citlivý obsah.</string>
<string name="settings">Nastavení</string>
<string name="connectivity_type_always">Vždy</string>
<string name="connectivity_type_wifi_only">Pouze Wi-Fi</string>
<string name="connectivity_type_never">Nikdy</string>
<string name="system">Systém</string>
<string name="light">Světlý</string>
<string name="dark">Tmavý</string>
@@ -479,43 +418,33 @@
<string name="automatically_play_videos">Automaticky přehrávat videa</string>
<string name="automatically_show_url_preview">Automaticky zobrazit náhled URL</string>
<string name="load_image">Načíst obrázek</string>
<string name="spamming_users">Spamovací uživatelé</string>
<string name="muted_button">Ztlumené. Klikněte pro odztlumení</string>
<string name="mute_button">Zvuk zapnutý. Klikněte pro ztlumení</string>
<string name="search_button">Hledat lokální a vzdálené záznamy</string>
<string name="nip05_verified">Adresa Nostr byla ověřena</string>
<string name="nip05_failed">Ověření adresy Nostr se nezdařilo</string>
<string name="nip05_checking">Kontrola adresy Nostr</string>
<string name="select_deselect_all">Vybrat/Zrušit vše</string>
<string name="default_relays">Výchozí</string>
<string name="select_a_relay_to_continue">Vyberte relé pro pokračování</string>
<string name="zap_forward_title">Přeposílat Zapy na:</string>
<string name="zap_forward_explainer">Podporující klienti budou přeposílat Zapy na níže uvedenou LN adresu nebo uživatelský profil místo vaší</string>
<string name="geohash_title">Zveřejnit polohu jako </string>
<string name="geohash_explainer">Přidá Geohash vaší polohy do příspěvku. Veřejnost bude vědět, že se nacházíte do 5 km od aktuální polohy</string>
<string name="add_sensitive_content_explainer">Přidat varování o citlivém obsahu před zobrazením vašeho obsahu. Toto je ideální pro obsah NSFW (nebezpečné pro práci) nebo obsah, který někteří lidé mohou považovat za urážlivý nebo znepokojující</string>
<string name="new_feature_nip24_might_not_be_available_title">Nová funkce</string>
<string name="new_feature_nip24_might_not_be_available_description">Aktivace tohoto režimu vyžaduje od Amethystu odeslání zprávy NIP-24 (GiftWrapped, Zapečetěné přímé a skupinové zprávy). NIP-24 je nový a většina klientů ho zatím neimplementovala. Ujistěte se, že příjemce používá kompatibilního klienta.</string>
<string name="new_feature_nip24_activate">Aktivovat</string>
<string name="messages_create_public_chat">Veřejné</string>
<string name="messages_new_message">Soukromé</string>
<string name="messages_new_message_to">Pro</string>
<string name="messages_new_message_subject">Předmět</string>
<string name="messages_new_message_subject_caption">Téma konverzace</string>
<string name="messages_new_message_to_caption">"@Uživatel1, @Uživatel2, @Uživatel3"</string>
<string name="messages_new_message_to_caption">"\@Uživatel1, @Uživatel2, @Uživatel3"</string>
<string name="messages_group_descriptor">Členové této skupiny</string>
<string name="messages_new_subject_message">Vysvětlení členům</string>
<string name="messages_new_subject_message_placeholder">Změna názvu pro nové cíle.</string>
<string name="language_description">Pro rozhraní aplikace</string>
<string name="theme_description">Tmavé, světlé nebo systémové téma</string>
<string name="automatically_load_images_gifs_description">Automaticky načítat obrázky a GIFy</string>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"></resources>

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