Compare commits

..
127 Commits
Author SHA1 Message Date
Vitor Pamplona e887232132 v0.89.9 2024-08-07 18:39:02 -04:00
Vitor Pamplona 7bcf4e3750 Removes logs from previous commit 2024-08-07 18:34:38 -04:00
Vitor Pamplona ed0676a5f5 Fixes the order of bookmarks (keeps the order of the event, instead of the created at) 2024-08-07 18:24:35 -04:00
Vitor Pamplona f731c654b0 Improves the async rendering of Base64 content 2024-08-07 18:24:14 -04:00
Vitor Pamplona b026bffe4a Moves discovery and video lists to Outbox when Follows or relay lists are selected 2024-08-07 16:39:24 -04:00
Vitor Pamplona a1aaec0b7d Adds support for selecting authors based on their Outbox relays when searching for notes authored by them 2024-08-07 15:46:27 -04:00
Vitor Pamplona 6f59097ac0 Aligns default note comparator to NIP-01's created at descending and then by id ascending 2024-08-07 15:44:35 -04:00
Vitor Pamplona 263df0c895 Renames PerRelayFilter to SincePerRelay
Creates PerRelay interface to allow other types of filters
2024-08-07 15:44:03 -04:00
Vitor PamplonaandGitHub 1ca5713494 Merge pull request #1008 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-07 12:57:34 -04:00
Crowdin Bot b706c4e871 New Crowdin translations by GitHub Action 2024-08-07 16:04:54 +00:00
Vitor Pamplona e8574c10bb Keep them public to allow testing in these particular functions 2024-08-07 12:02:48 -04:00
Vitor Pamplona 7cb87ea9c5 Refactors to use native contains instead of custom lambdas on Ammolite's Filter 2024-08-07 11:58:00 -04:00
Vitor Pamplona 07e5132943 Refactors Ammolite Filters to be regular ones and creates a PerRelayFilter for the use on Amethyst. 2024-08-07 11:40:01 -04:00
Vitor Pamplona 2b1e3cfc93 Renames the MinimumRelayList to RecommendationProcessor 2024-08-07 11:39:14 -04:00
Vitor Pamplona c17e1f0e1f removes logs from previous commit 2024-08-06 17:14:22 -04:00
Vitor Pamplona e88d1d0d00 Adds haptic feedback to draft deletion swipe 2024-08-06 17:13:06 -04:00
Vitor Pamplona 5fdff97cf8 Moves the ContactList cache lists to AccountViewModel, where it can be disposed more efficiently. 2024-08-06 15:36:04 -04:00
Vitor Pamplona e5328d7975 Improves the accuracy of the Event memory counter. 2024-08-06 15:35:14 -04:00
Vitor Pamplona 71b45b96fb Adds event factory performance test 2024-08-06 11:04:30 -04:00
Vitor PamplonaandGitHub dee28f3aa8 Merge pull request #1006 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-05 17:26:13 -04:00
Crowdin Bot 3a118bb939 New Crowdin translations by GitHub Action 2024-08-05 20:59:07 +00:00
Vitor Pamplona 971c92b27a Adds extension possibility to Quartz's event factory 2024-08-05 16:57:21 -04:00
Vitor Pamplona 4c70065843 Moves DataSource dispatcher from IO to Default 2024-08-05 16:02:35 -04:00
Vitor Pamplona eab69337ff Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-05 16:02:19 -04:00
Vitor Pamplona ab772eb65e Makes stringRes Stable for compose 2024-08-05 15:48:06 -04:00
Vitor PamplonaandGitHub 7a6720a7ab Merge pull request #1005 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-05 15:31:47 -04:00
Crowdin Bot 0f3dc18f7a New Crowdin translations by GitHub Action 2024-08-05 19:16:01 +00:00
Vitor Pamplona 84df86f4b6 Removes mutiny NWC button :( 2024-08-05 15:13:35 -04:00
Vitor Pamplona a0289a0cb4 Moves Relay viewModels to Default thread 2024-08-05 15:08:08 -04:00
Vitor Pamplona 3c034ca8af v0.89.8 2024-08-02 16:34:51 -04:00
Vitor PamplonaandGitHub 0c8c3003aa Merge pull request #1004 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-02 13:37:05 -04:00
Crowdin Bot 919dabfefa New Crowdin translations by GitHub Action 2024-08-02 16:39:19 +00:00
Vitor Pamplona fd06193e93 Notifies relay failures after marking them as closed. 2024-08-02 12:36:54 -04:00
Vitor Pamplona b02fa43628 Print relay writes on benchmark as well. 2024-08-02 12:35:52 -04:00
Vitor Pamplona 7fdf38d17b Normalizes logs for the DataSources 2024-08-02 12:35:25 -04:00
Vitor Pamplona 63e036a9bb Minimizes relay updates when rotating status events for each user. 2024-08-02 12:32:44 -04:00
Vitor Pamplona fc98442f8b Adds support for CashuB tokens using CBOR 2024-07-31 17:36:48 -04:00
Vitor Pamplona 3bbb780d2b Simplifies the drawing of gallery entires without e tags. 2024-07-31 16:02:18 -04:00
Vitor Pamplona 42ceb0c404 Fixes test case for ImageUploaders 2024-07-31 15:59:07 -04:00
Vitor PamplonaandGitHub 046bd7a0e9 Merge pull request #1003 from greenart7c3/main
Add delete all drafts button
2024-07-31 14:58:26 -04:00
Vitor Pamplona 0af0f745bf Fixes the need for main thread note loading for galleries 2024-07-31 14:57:44 -04:00
Vitor Pamplona b24999d78c Basic refactoring to minimize the need for user-space params in the VideoView 2024-07-31 14:43:10 -04:00
Vitor Pamplona d1278a4477 Better rendering for live streams that are planned but already finished. 2024-07-31 14:16:44 -04:00
greenart7c3 609ccaa635 Add delete all drafts button 2024-07-31 13:51:02 -03:00
Vitor PamplonaandGitHub 4bae04ad31 Merge pull request #1001 from believethehype/better_gallery_view
Gallery: use FileHeader Info for rendering / faster loading
2024-07-31 10:41:19 -04:00
Believethehype 8bca729edd Merge remote-tracking branch 'upstream/main' into better_gallery_view 2024-07-31 16:36:25 +02:00
Vitor Pamplona 2b2f04f724 Adds the callbackUri to the display uncited hashtags so that it can reuse the previously parsed post. 2024-07-31 10:21:13 -04:00
Vitor Pamplona 231af1d3d8 Fixes #993 2024-07-31 10:17:19 -04:00
Believethehype d1b44c0ac0 remove print 2024-07-31 12:59:23 +02:00
Believethehype f06c3f7376 massive speed up of rendering the gallery 2024-07-31 12:55:02 +02:00
Believethehype b8e10b040d cleanup 2024-07-31 12:02:39 +02:00
Believethehype 1a4396dbe3 removes unneded variable 2024-07-31 11:51:44 +02:00
Believethehype 856eb0da05 create GalleryFeed earlier 2024-07-31 11:40:59 +02:00
Believethehype ceaeb8e0b9 use GalleryContentView instead of 2024-07-31 11:24:05 +02:00
Vitor Pamplona b29ccd9746 Refactors the lambda used for content resolvers. 2024-07-30 18:16:28 -04:00
Vitor Pamplona f82364939f Adds icons in SVG to the docs 2024-07-30 18:16:06 -04:00
Vitor Pamplona 286583bba2 fixing the need for a new coroutine startup 2024-07-30 13:50:08 -04:00
Vitor PamplonaandGitHub bc8e5aa458 Merge pull request #997 from VASHvic/fix-clipboard-copy-edited-notes
Fix copying to clipboard edited notes
2024-07-30 13:47:49 -04:00
Vitor PamplonaandGitHub b393fb042f Merge pull request #999 from believethehype/nutrendering
Show mint in cashu token rendering
2024-07-30 13:47:14 -04:00
Vitor Pamplona 0258c5aac2 Releasing Flows if not subscribed anymore. 2024-07-30 13:22:48 -04:00
Vitor Pamplona a03a11c4cc Updates depedencies 2024-07-30 13:17:53 -04:00
Vitor PamplonaandGitHub 3896bbab34 Merge pull request #1000 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-30 09:17:19 -04:00
Crowdin Bot 5459f31000 New Crowdin translations by GitHub Action 2024-07-29 23:24:58 +00:00
Vitor Pamplona 143c3a1444 Testing that both 02 and 03 compressed keys are valid Nostr keys and can encrypt and decrypt to each other. 2024-07-29 19:23:22 -04:00
Believethehype bfbcf11fa3 Update CashuRedeem.kt 2024-07-29 20:42:17 +02:00
VASH 25823b1f39 Fix copying to clipboard edited notes 2024-07-27 13:05:20 +02:00
Believethehype cb02955ac6 fix order of Share ImageAction 2024-07-24 07:49:44 +02:00
Vitor Pamplona b440661e0d Adds the highlight to the base URL so that when the user opens the link, it highlights on the page. 2024-07-23 12:56:53 -04:00
Vitor Pamplona 38ba456a22 Signs for just one auth event to register with the push notification service instead of the dozens of events, one per relay. 2024-07-23 10:15:36 -04:00
Vitor Pamplona bca941e045 enables crossfading between images 2024-07-23 08:47:20 -04:00
Vitor Pamplona dbaadb4595 v0.89.7 2024-07-22 18:10:15 -04:00
Vitor Pamplona 231ca44e1c Avoids recomposition of the entire line when changing status. 2024-07-22 18:07:44 -04:00
Vitor Pamplona ca00c87a6e Prints memory use in descending order by size 2024-07-22 16:59:58 -04:00
Vitor Pamplona 2a28111583 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-07-22 14:27:23 -04:00
Vitor Pamplona 4def80c914 updates the EOSE of users even if no user is found in the relay. 2024-07-22 14:27:10 -04:00
Vitor PamplonaandGitHub 916ece1edb Merge pull request #992 from greenart7c3/main
Remove focus from status text field when closing the drawer
2024-07-22 14:00:24 -04:00
Vitor Pamplona 3c1f57fac0 Sorts keys before making the filter. 2024-07-22 13:57:31 -04:00
greenart7c3 e166c36731 Remove focus from status text field when closing the drawer 2024-07-22 14:45:39 -03:00
Vitor Pamplona a636f4a786 updates coil and jackson libs 2024-07-22 11:31:57 -04:00
Vitor PamplonaandGitHub bb127506c7 Merge pull request #985 from believethehype/main
Store additional File Metadata in Gallery Entries
2024-07-22 08:10:26 -04:00
Vitor PamplonaandGitHub 92c704d227 Merge pull request #986 from greenart7c3/main
Add sendAndWaitForResponse to Client.kt
2024-07-22 08:10:11 -04:00
greenart7c3 e764ebbcb2 if the relay just connected send the event 2024-07-22 07:49:54 -03:00
greenart7c3andGitHub 68fb34a8ad Merge branch 'main' into main 2024-07-22 07:36:43 -03:00
greenart7c3 02683ba829 Merge remote-tracking branch 'origin/main' 2024-07-22 07:34:36 -03:00
greenart7c3 754a1b3a87 change the log of onRelayStateChange 2024-07-22 07:33:21 -03:00
Vitor PamplonaandGitHub 4b58da7be7 Merge pull request #991 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-21 15:22:31 -04:00
Crowdin Bot 1ac9c850e4 New Crowdin translations by GitHub Action 2024-07-21 18:32:48 +00:00
Vitor PamplonaandGitHub 3cc4dc1414 Merge pull request #990 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-21 14:31:54 -04:00
Vitor Pamplona 62ae43e863 v0.89.6 2024-07-21 14:31:17 -04:00
Crowdin Bot 8149a93f2f New Crowdin translations by GitHub Action 2024-07-21 18:31:01 +00:00
Vitor Pamplona 5a60c3a595 Fixes stringResource cache 2024-07-21 14:29:05 -04:00
Vitor Pamplona 2ef2f0d1a5 adds the key to avoid applying to a separate navcontroller 2024-07-21 11:32:08 -04:00
Vitor Pamplona 52fef33662 run faster animations 2024-07-21 11:18:56 -04:00
Vitor Pamplona a5c6f86b35 v0.89.5 2024-07-20 16:27:27 -04:00
Vitor Pamplona b80008d695 Adds more memory stat dumping for production apps 2024-07-20 16:27:19 -04:00
Vitor Pamplona fa06aefbf1 increases disk cache to 1GB and adds a memory cache policy. 2024-07-20 15:32:34 -04:00
Vitor PamplonaandGitHub ab9ede1e26 Merge pull request #987 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-20 15:08:00 -04:00
Crowdin Bot 479b1ab20a New Crowdin translations by GitHub Action 2024-07-20 19:04:48 +00:00
Vitor Pamplona 8ed3f84cb7 Fixes screen rendering below the 3-button navigation 2024-07-20 15:02:57 -04:00
Vitor Pamplona c128e43fe5 v0.89.4 2024-07-19 17:10:22 -04:00
Vitor Pamplona f9ddf76686 action bar fix 2024-07-19 17:09:56 -04:00
Vitor Pamplona b6cb839fe7 v0.89.3 2024-07-19 16:44:30 -04:00
Vitor Pamplona 2b6a48f391 Fixes issue with super large follow lists. 2024-07-19 16:40:46 -04:00
Vitor Pamplona d1da863d3c Making sure an empty relay list uses the default list from Amethyst 2024-07-19 16:14:14 -04:00
greenart7c3andGitHub 99b7a8005a Merge branch 'main' into main 2024-07-19 14:13:36 -03:00
greenart7c3 3f3367258f return true if at least one relay replied with success 2024-07-19 13:29:09 -03:00
Vitor Pamplona 15ab955a7d Fixes alt of NIP-96 server lists 2024-07-19 11:55:46 -04:00
greenart7c3 5cf8b5aa12 change proguard to see if now the client.listener works 2024-07-19 12:49:04 -03:00
greenart7c3 eba6d03b53 check if listeners work now 2024-07-19 12:17:26 -03:00
greenart7c3 de85239046 check if listeners work now 2024-07-19 11:56:30 -03:00
Vitor Pamplona 9176151971 Fixes auth infinite loop with nostr.wine 2024-07-19 10:45:06 -04:00
greenart7c3 cbd8acbd01 check eose too 2024-07-19 11:14:31 -03:00
greenart7c3 19c9bfa819 Merge remote-tracking branch 'origin/main' 2024-07-19 11:14:18 -03:00
greenart7c3andgreenart7c3 80c369a6ae change default timeout to 15 seconds 2024-07-19 10:56:35 -03:00
greenart7c3andgreenart7c3 d0190c87a2 add more logs, add optional listener, fix await call 2024-07-19 10:56:35 -03:00
greenart7c3andgreenart7c3 62a2cba18b fix lint 2024-07-19 10:56:35 -03:00
greenart7c3andgreenart7c3 59b3ff1b94 Added sendAndWaitForResponse to Client.kt 2024-07-19 10:56:35 -03:00
greenart7c3 35423e1bb6 change default timeout to 15 seconds 2024-07-19 10:52:36 -03:00
greenart7c3 e125690000 add more logs, add optional listener, fix await call 2024-07-19 10:50:59 -03:00
Vitor Pamplona eba5ecfd91 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-07-19 09:47:04 -04:00
Vitor Pamplona 3e43a75acd Fixes the keyboard overriding parts of the screen 2024-07-19 09:46:58 -04:00
greenart7c3 04bf9c07ac fix lint 2024-07-19 08:05:04 -03:00
greenart7c3 ac58f99560 Added sendAndWaitForResponse to Client.kt 2024-07-19 07:58:12 -03:00
Believethehype df328ff139 Store additional File Metadata in Gallery Entries 2024-07-18 17:53:37 +02:00
Vitor PamplonaandGitHub 60ba7786a0 Merge pull request #984 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-07-18 10:54:01 -04:00
Crowdin Bot 1773ad5a88 New Crowdin translations by GitHub Action 2024-07-18 14:30:23 +00:00
Vitor Pamplona 26faa2bc69 Migrating to edge to edge in preparation for Android 15 2024-07-18 10:28:19 -04:00
154 changed files with 2637 additions and 1243 deletions
+6 -2
View File
@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
}
android {
@@ -15,8 +16,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk libs.versions.android.minSdk.get().toInteger()
targetSdk libs.versions.android.targetSdk.get().toInteger()
versionCode 388
versionName "0.89.2"
versionCode 395
versionName "0.89.9"
buildConfigField "String", "RELEASE_NOTES_ID", "\"1130badb252bdf62aaf93cb65bfa2bb09e7600c7d6764894f2954555b3b73018\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -282,6 +283,9 @@ dependencies {
// Image compression lib
implementation libs.zelory.video.compressor
// Cbor for cashuB format
implementation libs.kotlinx.serialization.cbor
testImplementation libs.junit
testImplementation libs.mockk
@@ -0,0 +1,102 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.CashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CashuBTest {
val cashuTokenA = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"
val cashuTokenB1 = "cashuBo2F0gqJhaUgA_9SLj17PgGFwgaNhYQFhc3hAYWNjMTI0MzVlN2I4NDg0YzNjZjE4NTAxNDkyMThhZjkwZjcxNmE1MmJmNGE1ZWQzNDdlNDhlY2MxM2Y3NzM4OGFjWCECRFODGd5IXVW-07KaZCvuWHk3WrnnpiDhHki6SCQh88-iYWlIAK0mjE0fWCZhcIKjYWECYXN4QDEzMjNkM2Q0NzA3YTU4YWQyZTIzYWRhNGU5ZjFmNDlmNWE1YjRhYzdiNzA4ZWIwZDYxZjczOGY0ODMwN2U4ZWVhY1ghAjRWqhENhLSsdHrr2Cw7AFrKUL9Ffr1XN6RBT6w659lNo2FhAWFzeEA1NmJjYmNiYjdjYzY0MDZiM2ZhNWQ1N2QyMTc0ZjRlZmY4YjQ0MDJiMTc2OTI2ZDNhNTdkM2MzZGNiYjU5ZDU3YWNYIQJzEpxXGeWZN5qXSmJjY8MzxWyvwObQGr5G1YCCgHicY2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdA"
val cashuTokenB2 = "cashuBpGFkb01pbmliaXRzIHJ1bGVzIWFteEpodHRwOi8vbGJ1dGxoNWxmZ2dxNXI3eHBpd2hyYWpkbDdzeHB1cGdhZ2F6eGw2NXc0YzVjZzcyd3RvZmFzYWQub25pb246MzMzOGF0gaJhaUgAm7I9OpEuTmFwg6NhYRhAYWNYIQPfWR0mG80XbGnj6DO8q1NIyjHSGGIEkoWTA6H16HTpx2FzeEA3YThkY2Y5YjNlOGEyNDdjZTMzOWU3MzY5ZTliNGExOWYzMWVhY2I2OWQ4YjBjNjVkYWFlYjcyZDFhY2I5YWQzo2FhGCBhY1ghA55SwCFBc46dwnjbkb87Mzo30T2EE9Ws_nemuFneDegGYXN4QDlkODFjMWEyNjE2ODUzYWQ4MDQ5Y2JjZDFjN2MyNDdhZGQ4M2IzNzM4Mjg2MjBiYWMyZmQ3ZjNlNWE1OGFjZWKjYWEEYWNYIQM-yAQQTR2t6pIAmfmGM8Wxy7ajKVLOaUg7TrV8o-EdVWFzeEBmNmViMTI4ZmJlMDM3MTEzZTkzZjM3NjllYTYwMTk1NmY1N2NkZWNhNTYwOGY0NWUzMDhhZDU0ZmQ4YTQxNWVhYXVjc2F0"
@Test()
fun parseCashuA() {
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenA) as GenericLoadable.Loaded<List<CashuToken>>).loaded[0]
assertEquals(cashuTokenA, parsed.token)
assertEquals("https://8333.space:3338", parsed.mint)
assertEquals(10, parsed.totalAmount)
assertEquals(2, parsed.proofs[0].amount)
assertEquals("407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837", parsed.proofs[0].secret)
assertEquals("009a1f293253e41e", parsed.proofs[0].id)
assertEquals("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea", parsed.proofs[0].C)
assertEquals(8, parsed.proofs[1].amount)
assertEquals("fe15109314e61d7756b0f8ee0f23a624acaa3f4e042f61433c728c7057b931be", parsed.proofs[1].secret)
assertEquals("009a1f293253e41e", parsed.proofs[1].id)
assertEquals("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059", parsed.proofs[1].C)
}
}
@Test()
fun parseCashuB() =
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenB1) as GenericLoadable.Loaded<List<CashuToken>>).loaded
assertEquals(cashuTokenB1, parsed[0].token)
assertEquals("http://localhost:3338", parsed[0].mint)
assertEquals(1, parsed[0].totalAmount)
assertEquals(1, parsed[0].proofs[0].amount)
assertEquals("acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388", parsed[0].proofs[0].secret)
assertEquals("00ffd48b8f5ecf80", parsed[0].proofs[0].id)
assertEquals("0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf", parsed[0].proofs[0].C)
assertEquals(3, parsed[1].totalAmount)
assertEquals(2, parsed[1].proofs[0].amount)
assertEquals("1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee", parsed[1].proofs[0].secret)
assertEquals("00ad268c4d1f5826", parsed[1].proofs[0].id)
assertEquals("023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d", parsed[1].proofs[0].C)
assertEquals(1, parsed[1].proofs[1].amount)
assertEquals("56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57", parsed[1].proofs[1].secret)
assertEquals("00ad268c4d1f5826", parsed[1].proofs[1].id)
assertEquals("0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63", parsed[1].proofs[1].C)
}
@Test()
fun parseCashuB2() =
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenB2) as GenericLoadable.Loaded<List<CashuToken>>).loaded
assertEquals(cashuTokenB2, parsed[0].token)
assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint)
assertEquals(100, parsed[0].totalAmount)
assertEquals(64, parsed[0].proofs[0].amount)
assertEquals("7a8dcf9b3e8a247ce339e7369e9b4a19f31eacb69d8b0c65daaeb72d1acb9ad3", parsed[0].proofs[0].secret)
assertEquals("009bb23d3a912e4e", parsed[0].proofs[0].id)
assertEquals("03df591d261bcd176c69e3e833bcab5348ca31d218620492859303a1f5e874e9c7", parsed[0].proofs[0].C)
assertEquals(32, parsed[0].proofs[1].amount)
assertEquals("9d81c1a2616853ad8049cbcd1c7c247add83b373828620bac2fd7f3e5a58aceb", parsed[0].proofs[1].secret)
assertEquals("009bb23d3a912e4e", parsed[0].proofs[1].id)
assertEquals("039e52c02141738e9dc278db91bf3b333a37d13d8413d5acfe77a6b859de0de806", parsed[0].proofs[1].C)
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst
import android.graphics.Bitmap
import android.graphics.Color
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
@@ -72,6 +73,7 @@ class ImageUploadTesting {
sensitiveContent = null,
serverInfo,
onProgress = {},
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
val url = result.tags!!.first { it[0] == "url" }.get(1)
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst
import android.app.Application
import android.content.ContentResolver
import android.content.Context
import android.os.Looper
import android.os.StrictMode
@@ -30,6 +31,7 @@ import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import coil.ImageLoader
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
import com.vitorpamplona.amethyst.service.playback.VideoCache
@@ -66,10 +68,19 @@ class Amethyst : Application() {
.Builder()
.directory(applicationContext.safeCacheDir.resolve("image_cache"))
.maxSizePercent(0.2)
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
.maximumMaxSizeBytes(1024 * 1024 * 1024) // 1GB
.build()
}
val coilMemCache: MemoryCache by lazy {
MemoryCache
.Builder(this)
.maxSizePercent(0.40) // memory heavy app due to profile pics and videos.
.build()
}
fun contentResolverFn(): ContentResolver = contentResolver
override fun onCreate() {
super.onCreate()
@@ -106,7 +117,12 @@ class Amethyst : Application() {
}
}
fun imageLoaderBuilder(): ImageLoader.Builder = ImageLoader.Builder(applicationContext).diskCache { coilCache }
fun imageLoaderBuilder(): ImageLoader.Builder =
ImageLoader
.Builder(this)
.diskCache { coilCache }
.memoryCache { coilMemCache }
.crossfade(true)
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(instance, npub)
@@ -30,6 +30,7 @@ import coil.decode.SvgDecoder
import coil.size.Precision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.Base64Fetcher
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@@ -98,6 +99,7 @@ class ServiceManager {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
add(Base64Fetcher.Factory)
} // .logger(DebugLogger())
.okHttpClient { HttpClientManager.getHttpClient() }
.precision(Precision.INEXACT)
@@ -39,10 +39,10 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.Client
import com.vitorpamplona.ammolite.relays.Constants
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
@@ -108,7 +108,6 @@ import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.MinimumRelayListProcessor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -121,7 +120,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
@@ -225,6 +223,7 @@ class Account(
@Immutable
class LiveFollowLists(
val users: Set<String> = emptySet(),
val usersPlusMe: Set<String>,
val hashtags: Set<String> = emptySet(),
val geotags: Set<String> = emptySet(),
val communities: Set<String> = emptySet(),
@@ -423,17 +422,29 @@ class Account(
val liveKind3FollowsFlow: Flow<LiveFollowLists> =
userProfile().flow().follows.stateFlow.transformLatest {
checkNotInMainThread()
// makes sure the output include only valid p tags
val verifiedFollowingUsers = it.user.latestContactList?.verifiedFollowKeySet() ?: emptySet()
emit(
LiveFollowLists(
it.user.cachedFollowingKeySet(),
it.user.cachedFollowingTagSet(),
it.user.cachedFollowingGeohashSet(),
it.user.cachedFollowingCommunitiesSet(),
verifiedFollowingUsers,
verifiedFollowingUsers + keyPair.pubKeyHex,
it.user.latestContactList
?.unverifiedFollowTagSet()
?.map { it.lowercase() }
?.toSet() ?: emptySet(),
it.user.latestContactList
?.unverifiedFollowGeohashSet()
?.toSet() ?: emptySet(),
it.user.latestContactList
?.verifiedFollowAddressSet()
?.toSet() ?: emptySet(),
),
)
}
val liveKind3Follows = liveKind3FollowsFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
val liveKind3Follows = liveKind3FollowsFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex)))
@OptIn(ExperimentalCoroutinesApi::class)
private val liveHomeList: Flow<ListNameNotePair> by lazy {
@@ -465,11 +476,11 @@ class Account(
} else if (peopleListFollows.listName == KIND3_FOLLOWS) {
emit(kind3Follows)
} else if (peopleListFollows.event == null) {
emit(LiveFollowLists())
emit(LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex)))
} else {
val result = waitToDecrypt(peopleListFollows.event)
if (result == null) {
emit(LiveFollowLists())
emit(LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex)))
} else {
emit(result)
}
@@ -481,54 +492,98 @@ class Account(
}
val liveHomeFollowLists: StateFlow<LiveFollowLists?> by lazy {
liveHomeFollowListFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
liveHomeFollowListFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex)))
}
fun relaysFromPeopleListFlows(
currentFollowList: LiveFollowLists,
relayUrlsToIgnore: Set<String>,
): Flow<List<MinimumRelayListProcessor.RelayRecommendation>> =
/**
* filter onion and local host from write relays
* for each user pubkey, a list of valid relays.
*/
private fun assembleAuthorsPerWriteRelay(
userList: Map<HexKey, List<String>>,
hasOnionConnection: Boolean = false,
): Map<String, List<HexKey>> {
checkNotInMainThread()
val authorsPerRelayUrl = mutableMapOf<String, MutableSet<HexKey>>()
val relayUrlsPerAuthor = mutableMapOf<HexKey, MutableSet<String>>()
userList.forEach { userWriteRelayListPair ->
userWriteRelayListPair.value.forEach { relayUrl ->
if (!RelayUrlFormatter.isLocalHost(relayUrl) && (hasOnionConnection || !RelayUrlFormatter.isOnion(relayUrl))) {
RelayUrlFormatter.normalizeOrNull(relayUrl)?.let { normRelayUrl ->
val userSet = authorsPerRelayUrl[normRelayUrl]
if (userSet != null) {
userSet.add(userWriteRelayListPair.key)
} else {
authorsPerRelayUrl[normRelayUrl] = mutableSetOf(userWriteRelayListPair.key)
}
val relaySet = authorsPerRelayUrl[userWriteRelayListPair.key]
if (relaySet != null) {
relaySet.add(normRelayUrl)
} else {
relayUrlsPerAuthor[userWriteRelayListPair.key] = mutableSetOf(normRelayUrl)
}
}
}
}
}
// for each relay, authors that only use this relay go first.
// then keeps order by pubkey asc
val comparator = compareByDescending<HexKey> { relayUrlsPerAuthor[it]?.size ?: 0 }.thenBy { it }
return authorsPerRelayUrl.mapValues {
it.value.sortedWith(comparator)
}
}
fun authorsPerRelay(
pubkeyList: Set<HexKey>,
defaultRelayList: List<String>,
): Flow<Map<String, List<String>>> =
combine(
currentFollowList.users.map {
pubkeyList.map {
getNIP65RelayListFlow(it)
},
) { followsNIP65RelayLists ->
MinimumRelayListProcessor
.reliableRelaySetFor(
followsNIP65RelayLists.mapNotNull {
(it.note.event as? AdvertisedRelayListEvent)
},
relayUrlsToIgnore,
hasOnionConnection = proxy != null,
).sortedByDescending { it.users.size }
assembleAuthorsPerWriteRelay(
followsNIP65RelayLists
.mapNotNull {
val author = (it.note as? AddressableNote)?.address?.pubKeyHex
val event = (it.note.event as? AdvertisedRelayListEvent)
if (event != null) {
event.pubKey to event.writeRelays()
} else {
if (author != null) {
author to defaultRelayList
} else {
Log.e("Account", "This author should NEVER be null. Note: ${it.note.idHex}")
null
}
}
}.toMap(),
hasOnionConnection = proxy != null,
)
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveHomeFollowRelayFlow: Flow<List<MinimumRelayListProcessor.RelayRecommendation>> by lazy {
val liveHomeListAuthorsPerRelayFlow: Flow<Map<String, List<String>>?> by lazy {
combineTransform(liveHomeFollowListFlow, connectToRelaysFlow) { followList, existing ->
if (followList != null) {
emit(
relaysFromPeopleListFlows(
followList,
existing.mapNotNullTo(HashSet()) {
if (it.read && FeedType.FOLLOWS in it.feedTypes) {
it.url
} else {
null
}
},
),
)
emit(authorsPerRelay(followList.usersPlusMe, existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }))
} else {
emit(MutableStateFlow(emptyList()))
emit(MutableStateFlow(null))
}
}.flatMapLatest {
it
}
}
val liveHomeFollowRelays: StateFlow<List<MinimumRelayListProcessor.RelayRecommendation>> by lazy {
liveHomeFollowRelayFlow.stateIn(scope, SharingStarted.Eagerly, emptyList())
val liveHomeListAuthorsPerRelay: StateFlow<Map<String, List<String>>?> by lazy {
liveHomeListAuthorsPerRelayFlow.stateIn(scope, SharingStarted.Eagerly, emptyMap())
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -540,7 +595,7 @@ class Account(
val liveNotificationFollowLists: StateFlow<LiveFollowLists?> by lazy {
combinePeopleListFlows(liveKind3FollowsFlow, liveNotificationList)
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex)))
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -552,7 +607,24 @@ class Account(
val liveStoriesFollowLists: StateFlow<LiveFollowLists?> by lazy {
combinePeopleListFlows(liveKind3FollowsFlow, liveStoriesList)
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex)))
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveStoriesListAuthorsPerRelayFlow: Flow<Map<String, List<String>>?> by lazy {
combineTransform(liveStoriesFollowLists, connectToRelaysFlow) { followList, existing ->
if (followList != null) {
emit(authorsPerRelay(followList.usersPlusMe, existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }))
} else {
emit(MutableStateFlow(null))
}
}.flatMapLatest {
it
}
}
val liveStoriesListAuthorsPerRelay: StateFlow<Map<String, List<String>>?> by lazy {
liveStoriesListAuthorsPerRelayFlow.stateIn(scope, SharingStarted.Eagerly, emptyMap())
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -564,7 +636,24 @@ class Account(
val liveDiscoveryFollowLists: StateFlow<LiveFollowLists?> by lazy {
combinePeopleListFlows(liveKind3FollowsFlow, liveDiscoveryList)
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex)))
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveDiscoveryListAuthorsPerRelayFlow: Flow<Map<String, List<String>>?> by lazy {
combineTransform(liveDiscoveryFollowLists, connectToRelaysFlow) { followList, existing ->
if (followList != null) {
emit(authorsPerRelay(followList.usersPlusMe, existing.filter { it.read }.map { it.url }))
} else {
emit(MutableStateFlow(null))
}
}.flatMapLatest {
it
}
}
val liveDiscoveryListAuthorsPerRelay: StateFlow<Map<String, List<String>>?> by lazy {
liveDiscoveryListAuthorsPerRelayFlow.stateIn(scope, SharingStarted.Eagerly, emptyMap())
}
private fun decryptLiveFollows(
@@ -572,10 +661,11 @@ class Account(
onReady: (LiveFollowLists) -> Unit,
) {
listEvent.privateTags(signer) { privateTagList ->
val users = (listEvent.bookmarkedPeople() + listEvent.filterUsers(privateTagList)).toSet()
onReady(
LiveFollowLists(
users =
(listEvent.bookmarkedPeople() + listEvent.filterUsers(privateTagList)).toSet(),
users = users,
usersPlusMe = users + userProfile().pubkeyHex,
hashtags =
(listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toSet(),
geotags =
@@ -759,6 +849,10 @@ class Account(
}
}
suspend fun countFollowersOf(pubkey: HexKey): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkey) ?: false }
suspend fun followerCount(): Int = countFollowersOf(keyPair.pubKeyHex)
fun sendNewUserMetadata(
name: String? = null,
picture: String? = null,
@@ -1109,7 +1203,7 @@ class Account(
listOf(
TypedFilter(
setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL),
Filter(
SincePerRelayFilter(
ids = listOf(it.id),
),
),
@@ -2251,21 +2345,25 @@ class Account(
idHex: String,
url: String,
relay: String?,
blurhash: String?,
dim: String?,
hash: String?,
mimeType: String?,
) {
if (!isWriteable()) return
ProfileGalleryEntryEvent.create(
url = url,
eventid = idHex,
relayhint = relay,
blurhash = blurhash,
hash = hash,
dimensions = dim,
mimeType = mimeType,
/*magnetUri = magnetUri,
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
alt = alt,
originalHash = originalHash,
sensitiveContent = sensitiveContent, */
originalHash = originalHash, */
signer = signer,
) { event ->
Client.send(event)
@@ -2354,6 +2452,16 @@ class Account(
RelayAuthEvent.create(relayUrl, challenge, signer, onReady = onReady)
}
fun createAuthEvent(
relayUrls: List<String>,
challenge: String,
onReady: (RelayAuthEvent) -> Unit,
) {
if (!isWriteable()) return
RelayAuthEvent.create(relayUrls, challenge, signer, onReady = onReady)
}
fun isInPrivateBookmarks(
note: Note,
onReady: (Boolean) -> Unit,
@@ -2761,19 +2869,22 @@ class Account(
// Takes a User's relay list and adds the types of feeds they are active for.
fun activeRelays(): Array<RelaySetupInfo>? {
val usersRelayList =
userProfile().latestContactList?.relays()?.map {
val url = RelayUrlFormatter.normalize(it.key)
userProfile()
.latestContactList
?.relays()
?.map {
val url = RelayUrlFormatter.normalize(it.key)
val localFeedTypes =
localRelays.firstOrNull { localRelay -> RelayUrlFormatter.normalize(localRelay.url) == url }?.feedTypes?.minus(setOf(FeedType.SEARCH, FeedType.WALLET_CONNECT))
?: Constants.defaultRelays
.filter { defaultRelay -> defaultRelay.url == url }
.firstOrNull()
?.feedTypes
?: Constants.activeTypesGlobalChats
val localFeedTypes =
localRelays.firstOrNull { localRelay -> RelayUrlFormatter.normalize(localRelay.url) == url }?.feedTypes?.minus(setOf(FeedType.SEARCH, FeedType.WALLET_CONNECT))
?: Constants.defaultRelays
.filter { defaultRelay -> defaultRelay.url == url }
.firstOrNull()
?.feedTypes
?: Constants.activeTypesGlobalChats
RelaySetupInfo(url, it.value.read, it.value.write, localFeedTypes)
} ?: return null
RelaySetupInfo(url, it.value.read, it.value.write, localFeedTypes)
}?.ifEmpty { null } ?: return null
return usersRelayList.toTypedArray()
}
@@ -2796,9 +2907,7 @@ class Account(
flowHiddenUsers.value.hiddenUsers.contains(userHex) ||
flowHiddenUsers.value.spammers.contains(userHex)
fun followingKeySet(): Set<HexKey> = userProfile().cachedFollowingKeySet()
fun followingTagSet(): Set<HexKey> = userProfile().cachedFollowingTagSet()
fun followingKeySet(): Set<HexKey> = liveKind3Follows.value.users
fun isAcceptable(user: User): Boolean {
if (userProfile().pubkeyHex == user.pubkeyHex) {
@@ -2848,8 +2957,6 @@ class Account(
}
fun getRelevantReports(note: Note): Set<Note> {
val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet()
val innerReports =
if (note.event is RepostEvent || note.event is GenericRepostEvent) {
note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList()
@@ -2858,8 +2965,8 @@ class Account(
}
return (
note.reportsBy(followsPlusMe) +
(note.author?.reportsBy(followsPlusMe) ?: emptyList()) +
note.reportsBy(liveKind3Follows.value.usersPlusMe) +
(note.author?.reportsBy(liveKind3Follows.value.usersPlusMe) ?: emptyList()) +
innerReports
).toSet()
}
@@ -32,9 +32,9 @@ import com.vitorpamplona.amethyst.ui.actions.relays.updated
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
@@ -139,8 +139,6 @@ open class Note(
var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
private set
var associatedNote: Note? = null
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
fun id() = Hex.decode(idHex)
@@ -29,8 +29,8 @@ import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Lud06
@@ -336,52 +336,24 @@ class User(
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
fun isFollowingHashtag(tag: String): Boolean = latestContactList?.isTaggedHash(tag) ?: false
fun isFollowingHashtagCached(tag: String): Boolean {
return latestContactList?.verifiedFollowTagSet?.let {
fun isFollowingHashtag(tag: String): Boolean {
return latestContactList?.unverifiedFollowTagSet()?.map { it.lowercase() }?.toSet()?.let {
return tag.lowercase() in it
}
?: false
}
fun isFollowingGeohashCached(geoTag: String): Boolean {
return latestContactList?.verifiedFollowGeohashSet?.let {
fun isFollowingGeohash(geoTag: String): Boolean {
return latestContactList?.unverifiedFollowAddressSet()?.toSet()?.let {
return geoTag.lowercase() in it
}
?: false
}
fun isFollowingCached(user: User): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return user.pubkeyHex in it
}
?: false
}
fun isFollowingCached(userHex: String): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return userHex in it
}
?: false
}
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
fun cachedFollowingKeySet(): Set<HexKey> = latestContactList?.verifiedFollowKeySet ?: emptySet()
fun cachedFollowingTagSet(): Set<String> = latestContactList?.verifiedFollowTagSet ?: emptySet()
fun cachedFollowingGeohashSet(): Set<HexKey> = latestContactList?.verifiedFollowGeohashSet ?: emptySet()
fun cachedFollowingCommunitiesSet(): Set<HexKey> = latestContactList?.verifiedFollowCommunitySet ?: emptySet()
fun cachedFollowCount(): Int? = latestContactList?.verifiedFollowKeySet?.size
suspend fun cachedFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
val messagesToUser = privateChatrooms[key] ?: return false
@@ -0,0 +1,89 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern
import java.util.Base64
@Stable
class Base64Fetcher(
private val options: Options,
private val data: Uri,
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val matcher = base64contentPattern.matcher(data.toString())
if (matcher.find()) {
val base64String = matcher.group(2)
val byteArray = Base64.getDecoder().decode(base64String)
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) ?: throw Exception("Unable to load base64 $base64String")
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY,
)
} else {
throw Exception("Unable to load base64 $data")
}
}
object Factory : Fetcher.Factory<Uri> {
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher? {
return if (base64contentPattern.matcher(data.toString()).find()) {
return Base64Fetcher(options, data)
} else {
null
}
}
}
}
object Base64Requester {
fun imageRequest(
context: Context,
message: String,
): ImageRequest =
ImageRequest
.Builder(context)
.data(message)
.fetcherFactory(Base64Fetcher.Factory)
.build()
}
@@ -26,21 +26,35 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.quartz.events.ImmutableListOfLists
object CachedRichTextParser {
val richTextCache = LruCache<String, RichTextViewerState>(50)
val richTextCache = LruCache<Int, RichTextViewerState>(50)
fun getCached(content: String): RichTextViewerState? = richTextCache[content]
// fun getCached(content: String): RichTextViewerState? = richTextCache[content]
fun hashCodeCache(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String?,
): Int {
var result = content.hashCode()
result = 31 * result + tags.lists.hashCode()
if (callbackUri != null) {
result = 31 * result + callbackUri.hashCode()
}
return result
}
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
): RichTextViewerState {
val cached = richTextCache[content]
val key = hashCodeCache(content, tags, callbackUri)
val cached = richTextCache[key]
return if (cached != null) {
cached
} else {
val newUrls = RichTextParser().parseText(content, tags, callbackUri)
richTextCache.put(content, newUrls)
richTextCache.put(key, newUrls)
newUrls
}
}
@@ -23,14 +23,20 @@ package com.vitorpamplona.amethyst.service
import android.content.Context
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.Event
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -42,16 +48,26 @@ data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val proofs: JsonNode,
val proofs: List<Proof>,
)
@Serializable
@Immutable
class Proof(
val amount: Int,
val id: String,
val secret: String,
val C: String,
)
object CachedCashuProcessor {
val cashuCache = LruCache<String, GenericLoadable<CashuToken>>(20)
val cashuCache = LruCache<String, GenericLoadable<List<CashuToken>>>(20)
fun cached(token: String): GenericLoadable<CashuToken> = cashuCache[token] ?: GenericLoadable.Loading()
fun cached(token: String): GenericLoadable<List<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()
fun parse(token: String): GenericLoadable<CashuToken> {
fun parse(token: String): GenericLoadable<List<CashuToken>> {
if (cashuCache[token] !is GenericLoadable.Loaded) {
checkNotInMainThread()
val newCachuData = CashuProcessor().parse(token)
cashuCache.put(token, newCachuData)
@@ -62,25 +78,138 @@ object CachedCashuProcessor {
}
class CashuProcessor {
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
@Serializable
class V3Token(
val unit: String?, // unit
val memo: String?, // memo
val token: List<V3T>?,
)
@Serializable
class V3T(
val mint: String,
val proofs: List<Proof>,
)
fun parse(cashuToken: String): GenericLoadable<List<CashuToken>> {
checkNotInMainThread()
if (cashuToken.startsWith("cashuA")) {
return parseCashuA(cashuToken)
}
if (cashuToken.startsWith("cashuB")) {
return parseCashuB(cashuToken)
}
return GenericLoadable.Error("Could not parse this cashu token")
}
fun parseCashuA(cashuToken: String): GenericLoadable<List<CashuToken>> {
checkNotInMainThread()
try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token)))
val token = cashu.get("token").get(0)
val proofs = token.get("proofs")
val mint = token.get("mint").asText()
val cashu = jacksonObjectMapper().readValue<V3Token>(String(Base64.getDecoder().decode(base64token)))
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.get("amount").asLong()
if (cashu.token == null) {
return GenericLoadable.Error("No token found")
}
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
val converted =
cashu.token.map { token ->
val proofs = token.proofs
val mint = token.mint
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.amount
}
CashuToken(cashuToken, mint, totalAmount, proofs)
}
return GenericLoadable.Loaded(converted)
} catch (e: Exception) {
if (e is CancellationException) throw e
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
return GenericLoadable.Error<List<CashuToken>>("Could not parse this cashu token")
}
}
@Serializable
class V4Token(
val m: String, // mint
val u: String, // unit
val d: String? = null, // memo
val t: Array<V4T>?,
)
@Serializable
class V4T(
@ByteString
val i: ByteArray, // identifier
val p: Array<V4Proof>,
)
@Serializable
class V4Proof(
val a: Int, // amount
val s: String, // secret
@ByteString
val c: ByteArray, // signature
val d: V4DleqProof? = null, // no idea what this is
val w: String? = null, // witness
)
@Serializable
class V4DleqProof(
@ByteString
val e: ByteArray,
@ByteString
val s: ByteArray,
@ByteString
val r: ByteArray,
)
@OptIn(ExperimentalSerializationApi::class)
fun parseCashuB(cashuToken: String): GenericLoadable<List<CashuToken>> {
checkNotInMainThread()
try {
val base64token = cashuToken.replace("cashuB", "")
val parser = Cbor { ignoreUnknownKeys = true }
val v4Token = parser.decodeFromByteArray<V4Token>(Base64.getUrlDecoder().decode(base64token))
val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found")
val converted =
v4proofs.map { id ->
val proofs =
id.p.map {
Proof(
it.a,
id.i.toHexKey(),
it.s,
it.c.toHexKey(),
)
}
val mint = v4Token.m
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.amount
}
CashuToken(cashuToken, mint, totalAmount, proofs)
}
return GenericLoadable.Loaded(converted)
} catch (e: Exception) {
e.printStackTrace()
if (e is CancellationException) throw e
return GenericLoadable.Error("Could not parse this cashu token")
}
}
@@ -209,7 +338,21 @@ class CashuProcessor {
val factory = Event.mapper.nodeFactory
val jsonObject = factory.objectNode()
jsonObject.put("proofs", token.proofs)
jsonObject.replace(
"proofs",
factory.arrayNode(token.proofs.size).apply {
token.proofs.forEach {
addObject().apply {
put("amount", it.amount)
put("id", it.id)
put("secret", it.secret)
put("C", it.C)
}
}
},
)
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
@@ -26,11 +26,11 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Client
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
@@ -81,7 +81,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
@@ -92,7 +92,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
@@ -103,7 +103,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
StatusEvent.KIND,
@@ -124,7 +124,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
return TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
@@ -146,7 +146,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND, BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100,
@@ -157,7 +157,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(DraftEvent.KIND, ReportEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
@@ -172,7 +172,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
authors = listOf(account.userProfile().pubkeyHex),
limit = 400,
),
@@ -189,7 +189,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -222,7 +222,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
GitReplyEvent.KIND,
@@ -244,7 +244,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
@@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
@@ -58,7 +58,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
@@ -71,7 +71,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
@@ -86,7 +86,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
limit = 200,
@@ -96,7 +96,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200,
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.PrivateDmEvent
@@ -50,7 +50,7 @@ object NostrChatroomDataSource : AmethystNostrDataSource("ChatroomFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = myPeer.users.toList(),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
@@ -73,7 +73,7 @@ object NostrChatroomDataSource : AmethystNostrDataSource("ChatroomFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to myPeer.users.map { it }),
@@ -24,8 +24,8 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
@@ -41,7 +41,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
@@ -56,7 +56,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
@@ -71,7 +71,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
@@ -91,7 +91,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
// Metadata comes from any relay
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = followingEvents.toList(),
since =
@@ -113,7 +113,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
// Metadata comes from any relay
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to listOf(it)),
limit = 1,
@@ -131,7 +131,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOf(it)),
since =
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
@@ -38,7 +38,7 @@ object NostrCommunityDataSource : AmethystNostrDataSource("SingleCommunityFeed")
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
authors =
community
.moderators()
@@ -24,8 +24,9 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
@@ -66,10 +67,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
}
fun createMarketplaceFilter(): List<TypedFilter> {
val follows =
account.liveDiscoveryFollowLists.value
?.users
?.toList()
val follows = account.liveDiscoveryListAuthorsPerRelay.value
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.hashtags
@@ -81,9 +79,9 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
Filter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(ClassifiedsEvent.KIND),
limit = 300,
@@ -98,7 +96,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
@@ -120,7 +118,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
@@ -146,7 +144,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(AppDefinitionEvent.KIND),
limit = 300,
tags = mapOf("k" to kTags),
@@ -165,12 +163,14 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
?.users
?.toList()
val followsRelays = account.liveDiscoveryListAuthorsPerRelay.value
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
authors = follows,
SinceAuthorPerRelayFilter(
authors = followsRelays,
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
limit = 300,
since =
@@ -184,7 +184,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
tags = mapOf("p" to it),
kinds = listOf(LiveActivitiesEvent.KIND),
limit = 100,
@@ -200,17 +200,14 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
}
fun createPublicChatFilter(): List<TypedFilter> {
val follows =
account.liveDiscoveryFollowLists.value
?.users
?.toList()
val follows = account.liveDiscoveryListAuthorsPerRelay.value
val followChats = account.selectedChatsFollowList().toList()
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(ChannelMessageEvent.KIND),
limit = 500,
@@ -225,7 +222,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.KIND, ChannelMessageEvent.KIND),
limit = 300,
@@ -243,15 +240,12 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
}
fun createCommunitiesFilter(): TypedFilter {
val follows =
account.liveDiscoveryFollowLists.value
?.users
?.toList()
val follows = account.liveDiscoveryListAuthorsPerRelay.value
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
limit = 300,
@@ -275,7 +269,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
@@ -305,7 +299,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
@@ -335,7 +329,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
@@ -366,7 +360,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
@@ -397,7 +391,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
@@ -427,7 +421,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
@@ -43,7 +43,7 @@ object NostrGeohashDataSource : AmethystNostrDataSource("SingleGeoHashFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
tags =
mapOf(
"g" to
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
@@ -43,7 +43,7 @@ object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
tags =
mapOf(
"t" to
@@ -24,8 +24,9 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
@@ -76,13 +77,13 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.liveHomeFollowLists.value?.users
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
val follows =
account.liveHomeListAuthorsPerRelay.value
return TypedFilter(
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
filter =
Filter(
SinceAuthorPerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -99,7 +100,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
LiveActivitiesEvent.KIND,
WikiNoteEvent.KIND,
),
authors = followSet,
authors = follows,
limit = 400,
since =
latestEOSEs.users[account.userProfile()]
@@ -111,20 +112,19 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
}
fun createFollowMetadataAndReleaseFilter(): TypedFilter? {
val follows = account.liveHomeFollowLists.value?.users
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
val follows = account.liveHomeListAuthorsPerRelay.value
return if (followSet != null) {
return if (follows != null) {
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SinceAuthorPerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
AdvertisedRelayListEvent.KIND,
),
authors = followSet,
authors = follows,
since =
latestEOSEs.users[account.userProfile()]
?.followList
@@ -145,7 +145,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -184,7 +184,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -223,7 +223,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.signers.NostrSigner
@@ -39,7 +39,7 @@ class NostrLnZapPaymentResponseDataSource(
return TypedFilter(
types = feedTypes,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = listOf(fromServiceHex),
tags =
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
@@ -95,7 +95,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, aTag.kind),
authors = listOfNotNull(aTag.pubKeyHex),
// just to be sure
@@ -110,7 +110,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
ids = listOfNotNull(hexToWatch),
),
),
@@ -118,7 +118,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(hexToWatch),
// just to be sure
@@ -135,7 +135,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
search = mySearchString,
limit = 1000,
@@ -144,7 +144,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -165,7 +165,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
ChannelMetadataEvent.KIND,
@@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
@@ -44,7 +44,7 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to reactionsToWatch),
),
@@ -65,7 +65,7 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
return TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = interestedEvents.toList(),
),
@@ -88,7 +88,7 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
@@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
@@ -67,7 +67,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -89,7 +89,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
DeletionEvent.KIND,
@@ -117,7 +117,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex),
limit = 5,
@@ -127,7 +127,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
@@ -150,7 +150,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -173,7 +173,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
DeletionEvent.KIND,
@@ -200,7 +200,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(TextNoteEvent.KIND),
tags = mapOf("q" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
@@ -232,7 +232,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
ids = interestedEvents.toList(),
),
),
@@ -319,6 +319,9 @@ fun groupByEOSEPresence(notes: Set<Note>): Collection<List<Note>> =
.sorted()
.joinToString(",")
}.values
.map {
it.sortedBy { it.idHex } // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again
}
fun groupByEOSEPresence(users: Iterable<User>): Collection<List<User>> =
users
@@ -327,6 +330,9 @@ fun groupByEOSEPresence(users: Iterable<User>): Collection<List<User>> =
.sorted()
.joinToString(",")
}.values
.map {
it.sortedBy { it.pubkeyHex } // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again
}
fun findMinimumEOSEs(notes: List<Note>): Map<String, EOSETime> {
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
@@ -21,10 +21,10 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.MetadataEvent
@@ -45,7 +45,7 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND),
authors = firstTimers,
),
@@ -64,11 +64,12 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
.map { group ->
val groupIds = group.map { it.pubkeyHex }
val minEOSEs = findMinimumEOSEsForUsers(group)
listOf(
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND),
authors = groupIds,
since = minEOSEs,
@@ -77,7 +78,7 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ReportEvent.KIND),
tags = mapOf("p" to groupIds),
since = minEOSEs,
@@ -92,13 +93,11 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
checkNotInMainThread()
usersToWatch.forEach {
if (it.latestMetadata != null) {
val eose = it.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
val eose = it.latestEOSEs[relayUrl]
if (eose == null) {
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
} else {
eose.time = time
}
}
}
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
private var eventToWatch: String? = null
@@ -45,7 +45,7 @@ object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
ids = eventsToLoad.toList(),
),
)
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AppRecommendationEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
@@ -56,7 +56,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -69,7 +69,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -94,7 +94,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LnZapEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 200,
@@ -107,7 +107,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -120,7 +120,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
),
@@ -132,7 +132,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(BadgeProfilesEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -145,7 +145,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND),
authors = listOf(it.pubkeyHex),
@@ -159,7 +159,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(ProfileGalleryEntryEvent.KIND),
authors = listOf(it.pubkeyHex),
@@ -173,7 +173,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(BadgeAwardEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 20,
@@ -24,8 +24,9 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
@@ -64,15 +65,12 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
}
fun createContextualFilter(): TypedFilter {
val follows =
account.liveStoriesFollowLists.value
?.users
?.toList()
val follows = account.liveStoriesListAuthorsPerRelay.value
return TypedFilter(
types = setOf(FeedType.GLOBAL),
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
Filter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
limit = 200,
@@ -97,7 +95,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
@@ -128,7 +126,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
@@ -55,7 +55,7 @@ class RegisterAccounts(
private suspend fun signAllAuths(
notificationToken: String,
remainingTos: List<Pair<Account, String>>,
remainingTos: List<Pair<Account, List<String>>>,
output: MutableList<RelayAuthEvent>,
onReady: (List<RelayAuthEvent>) -> Unit,
) {
@@ -100,7 +100,7 @@ class RegisterAccounts(
) {
val readyToSend =
accounts
.map {
.mapNotNull {
Log.d(tag, "Register Account ${it.npub}")
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
@@ -131,13 +131,17 @@ class RegisterAccounts(
Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} Kind3 Reads ${readKind3Relays.joinToString(", ")}")
(nip65Read + nip17Read + readKind3Relays).map {
Pair(acc, it)
val relays = (nip65Read + nip17Read + readKind3Relays)
if (relays.isNotEmpty()) {
Pair(acc, relays)
} else {
null
}
} else {
emptyList<Pair<Account, String>>()
null
}
}.flatten()
}
val listOfAuthEvents = mutableListOf<RelayAuthEvent>()
signAllAuths(
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class EOSERelayList(
var relayList: Map<String, EOSETime> = emptyMap(),
@@ -30,6 +30,7 @@ import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
@@ -72,6 +73,7 @@ class MainActivity : AppCompatActivity() {
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
Log.d("Lifetime Event", "MainActivity.onCreate")
@@ -49,14 +49,26 @@ fun stringRes(id: Int): String = resourceCache.get(id) ?: stringResource(id).als
@Composable
fun stringRes(
id: Int,
vararg args: Any,
vararg args: String,
): String =
String
.format(
LocalConfiguration.current.locales.get(0),
resourceCache.get(id) ?: stringResource(id),
resourceCache.get(id) ?: stringResource(id).also { resourceCache.put(id, it) },
*args,
).also { resourceCache.put(id, it) }
)
@Composable
fun stringRes(
id: Int,
vararg args: Int?,
): String =
String
.format(
LocalConfiguration.current.locales.get(0),
resourceCache.get(id) ?: stringResource(id).also { resourceCache.put(id, it) },
*args,
)
fun stringRes(
ctx: Context,
@@ -66,7 +78,22 @@ fun stringRes(
fun stringRes(
ctx: Context,
id: Int,
vararg args: Any?,
vararg args: String?,
): String {
val res = ctx.resources
return String
.format(
res.configuration.locales.get(0),
resourceCache.get(id) ?: res.getString(id).also { resourceCache.put(id, it) },
*args,
)
}
fun stringRes(
ctx: Context,
id: Int,
vararg args: Int?,
): String {
val res = ctx.resources
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
@@ -30,7 +31,7 @@ import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.ammolite.relays.RelayStats
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.utils.MinimumRelayListProcessor
import com.vitorpamplona.quartz.utils.RelayListRecommendationProcessor
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -38,6 +39,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@Stable
class Kind3RelayListViewModel : ViewModel() {
private lateinit var account: Account
@@ -138,7 +140,7 @@ class Kind3RelayListViewModel : ViewModel() {
private fun refreshProposals() {
_proposedRelays.update {
val proposed =
MinimumRelayListProcessor
RelayListRecommendationProcessor
.reliableRelaySetFor(
account.liveKind3Follows.value.users.mapNotNull {
account.getNIP65RelayList(it)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
@@ -33,6 +34,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@Stable
class Nip65RelayListViewModel : ViewModel() {
private lateinit var account: Account
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -31,11 +30,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
@@ -53,14 +49,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.viewmodel.compose.viewModel
import com.fasterxml.jackson.databind.node.TextNode
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
@@ -69,7 +63,6 @@ import com.vitorpamplona.amethyst.service.CachedCashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.CashuIcon
import com.vitorpamplona.amethyst.ui.note.CopyIcon
import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon
import com.vitorpamplona.amethyst.ui.note.ZapIcon
@@ -77,14 +70,11 @@ import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -108,8 +98,8 @@ fun CashuPreview(
CrossfadeIfEnabled(targetState = cashuData, label = "CashuPreview", accountViewModel = accountViewModel) {
when (it) {
is GenericLoadable.Loaded<CashuToken> -> CashuPreview(it.loaded, accountViewModel)
is GenericLoadable.Error<CashuToken> ->
is GenericLoadable.Loaded<List<CashuToken>> -> CashuPreview(it.loaded, accountViewModel)
is GenericLoadable.Error<List<CashuToken>> ->
Text(
text = "$cashutoken ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
@@ -121,10 +111,12 @@ fun CashuPreview(
@Composable
fun CashuPreview(
token: CashuToken,
tokens: List<CashuToken>,
accountViewModel: AccountViewModel,
) {
CashuPreviewNew(token, accountViewModel::meltCashu, accountViewModel::toast)
tokens.forEach {
CashuPreviewNew(it, accountViewModel::meltCashu, accountViewModel::toast)
}
}
@Composable
@@ -137,14 +129,8 @@ fun CashuPreviewPreview() {
AmethystTheme(sharedPrefsViewModel = sharedPreferencesViewModel) {
Column {
CashuPreview(
token = CashuToken("token", "mint", 32400, TextNode("")),
melt = { token, context, onDone -> },
toast = { title, message -> },
)
CashuPreviewNew(
token = CashuToken("token", "mint", 32400, TextNode("")),
token = CashuToken("token", "mint", 32400, listOf()),
melt = { token, context, onDone -> },
toast = { title, message -> },
)
@@ -152,148 +138,6 @@ fun CashuPreviewPreview() {
}
}
@Composable
fun CashuPreview(
token: CashuToken,
melt: (CashuToken, Context, (String, String) -> Unit) -> Unit,
toast: (String, String) -> Unit,
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(20.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
) {
Icon(
imageVector = CustomHashTagIcons.Cashu,
null,
modifier = Size20Modifier,
tint = Color.Unspecified,
)
Text(
text = stringRes(R.string.cashu),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp),
)
}
HorizontalDivider(thickness = DividerThickness)
Text(
text = "${token.totalAmount} ${stringRes(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
)
Row(
modifier =
Modifier
.padding(top = 5.dp)
.fillMaxWidth(),
) {
var isRedeeming by remember { mutableStateOf(false) }
Button(
onClick = {
isRedeeming = true
melt(token, context) { title, message ->
toast(title, message)
isRedeeming = false
}
},
shape = QuoteBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
if (isRedeeming) {
LoadingAnimation()
} else {
ZapIcon(Size20Modifier, tint = Color.White)
}
Spacer(DoubleHorzSpacer)
Text(
stringRes(id = R.string.cashu_redeem_to_zap),
color = Color.White,
fontSize = 16.sp,
)
}
}
Spacer(modifier = StdHorzSpacer)
Button(
onClick = {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("cashu://${token.token}"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(context, intent, null)
} catch (e: Exception) {
if (e is CancellationException) throw e
toast(stringRes(context, R.string.cashu), stringRes(context, R.string.cashu_no_wallet_found))
}
},
shape = QuoteBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
CashuIcon(Size20Modifier)
Spacer(DoubleHorzSpacer)
Text(
stringRes(id = R.string.cashu_redeem_to_cashu),
color = Color.White,
fontSize = 16.sp,
)
}
Spacer(modifier = StdHorzSpacer)
Button(
onClick = {
// Copying the token to clipboard
clipboardManager.setText(AnnotatedString(token.token))
},
shape = QuoteBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
CopyIcon(Size20Modifier, Color.White)
Spacer(DoubleHorzSpacer)
Text(stringRes(id = R.string.cashu_copy_token), color = Color.White, fontSize = 16.sp)
}
Spacer(modifier = StdHorzSpacer)
}
}
}
@Composable
fun CashuPreviewNew(
token: CashuToken,
@@ -339,6 +183,12 @@ fun CashuPreviewNew(
fontSize = 20.sp,
modifier = Modifier.padding(top = 5.dp),
)
Text(
text = "Mint: " + token.mint.replace("https://", ""),
fontSize = 7.sp,
color = Color.Gray,
modifier = Modifier.padding(start = 5.dp, bottom = 1.dp),
)
Row(modifier = Modifier.padding(top = 5.dp)) {
var isRedeeming by remember { mutableStateOf(false) }
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.util.Base64
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -29,7 +28,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
@@ -49,8 +47,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
@@ -65,10 +61,6 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImagePainter
import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
@@ -83,7 +75,6 @@ import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
import com.vitorpamplona.amethyst.commons.richtext.Segment
@@ -96,7 +87,6 @@ import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -112,7 +102,6 @@ import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.withContext
fun isMarkdown(content: String): Boolean =
content.startsWith("> ") ||
@@ -445,47 +434,7 @@ private fun RenderWordWithPreview(
is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> Text(word.segmentText)
is Base64Segment -> ImageFromBase64(word.segmentText)
}
}
@Composable
fun ImageFromBase64(base64String: String) {
val context = LocalContext.current
var imageBytes by remember { mutableStateOf<ByteArray?>(null) }
LaunchedEffect(base64String) {
imageBytes =
withContext(Dispatchers.IO) {
var base64String2 = base64String.removePrefix("data:image/jpeg;base64,")
RichTextParser.imageExtensions.forEach {
base64String2 = base64String2.removePrefix("data:image/$it;base64,")
}
runCatching { Base64.decode(base64String2, Base64.DEFAULT) }.getOrNull()
}
}
if (imageBytes == null) {
BlankNote()
} else {
val request =
ImageRequest
.Builder(context)
.data(imageBytes)
.build()
SubcomposeAsyncImage(
model = request,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
) {
when (painter.state) {
is AsyncImagePainter.State.Success -> {
SubcomposeAsyncImageContent()
}
else -> BlankNote()
}
}
is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel)
}
}
@@ -20,15 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Face
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@@ -37,22 +33,10 @@ import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.theme.isLight
import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
import java.util.Base64
@Composable
fun RobohashAsyncImage(
@@ -92,53 +76,34 @@ fun RobohashFallbackAsyncImage(
loadRobohash: Boolean,
) {
if (model != null && loadProfilePicture) {
val isBase64 = model.startsWith("data:image/jpeg;base64,")
if (isBase64) {
val context = LocalContext.current
val base64Painter =
rememberAsyncImagePainter(
model = Base64Requester.imageRequest(context, model),
val painter =
if (loadRobohash) {
rememberVectorPainter(
image = CachedRobohash.get(robot, MaterialTheme.colorScheme.isLight),
)
} else {
forwardingPainter(
painter =
rememberVectorPainter(
image = Icons.Default.Face,
),
colorFilter = MaterialTheme.colorScheme.onBackgroundColorFilter,
)
}
Image(
painter = base64Painter,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
colorFilter = colorFilter,
)
} else {
val painter =
if (loadRobohash) {
rememberVectorPainter(
image = CachedRobohash.get(robot, MaterialTheme.colorScheme.isLight),
)
} else {
forwardingPainter(
painter =
rememberVectorPainter(
image = Icons.Default.Face,
),
colorFilter = MaterialTheme.colorScheme.onBackgroundColorFilter,
)
}
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
)
}
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
)
} else {
if (loadRobohash) {
Image(
@@ -161,48 +126,3 @@ fun RobohashFallbackAsyncImage(
}
}
}
object Base64Requester {
fun imageRequest(
context: Context,
message: String,
): ImageRequest =
ImageRequest
.Builder(context)
.data(message)
.fetcherFactory(Base64Fetcher.Factory)
.build()
}
@Stable
class Base64Fetcher(
private val options: Options,
private val data: Uri,
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val base64String = data.toString().removePrefix("data:image/jpeg;base64,")
val byteArray = Base64.getDecoder().decode(base64String)
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
if (bitmap == null) {
throw Exception("Unable to load base64 $base64String")
}
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY,
)
}
object Factory : Fetcher.Factory<Uri> {
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher = Base64Fetcher(options, data)
}
}
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue.EndToStart
@@ -39,10 +40,13 @@ import androidx.compose.material3.SwipeToDismissBoxValue.Settled
import androidx.compose.material3.SwipeToDismissBoxValue.StartToEnd
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
@@ -69,6 +73,7 @@ fun SwipeToDeleteContainer(
}
return@rememberSwipeToDismissBoxState true
},
positionalThreshold = { it * .40f },
)
SwipeToDismissBox(
@@ -86,11 +91,19 @@ fun DismissBackground(dismissState: SwipeToDismissBoxState) {
if (dismissState.currentValue > dismissState.targetValue) {
Color(0xFFFF1744)
} else {
Color.Transparent
MaterialTheme.colorScheme.surfaceVariant
},
label = "DismissBackground",
)
val haptic = LocalHapticFeedback.current
LaunchedEffect(key1 = dismissState.currentValue > dismissState.targetValue) {
// doesn't run for the first time or when the list is updated.
if (dismissState.progress > 0 && dismissState.progress < 1) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
Row(
modifier =
Modifier
@@ -30,6 +30,7 @@ import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.VideoView
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearEasing
@@ -40,9 +41,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
@@ -217,6 +218,37 @@ fun VideoView(
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false,
) {
val borderModifier =
if (roundedCorner) {
MaterialTheme.colorScheme.imageModifier
} else if (gallery) {
MaterialTheme.colorScheme.videoGalleryModifier
} else {
Modifier
}
VideoView(videoUri, mimeType, title, thumb, borderModifier, isFiniteHeight, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, onControllerVisibilityChanged, accountViewModel, alwaysShowVideo)
}
@Composable
fun VideoView(
videoUri: String,
mimeType: String?,
title: String? = null,
thumb: VideoThumb? = null,
borderModifier: Modifier,
isFiniteHeight: Boolean,
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
dimensions: String? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
onDialog: ((Boolean) -> Unit)? = null,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false,
) {
val defaultToStart by remember(videoUri) { mutableStateOf(DEFAULT_MUTED_SETTING.value) }
@@ -248,8 +280,7 @@ fun VideoView(
defaultToStart = defaultToStart,
title = title,
thumb = thumb,
roundedCorner = roundedCorner,
gallery = gallery,
borderModifier = borderModifier,
isFiniteHeight = isFiniteHeight,
waveform = waveform,
artworkUri = artworkUri,
@@ -273,19 +304,12 @@ fun VideoView(
}
Box(modifier, contentAlignment = Alignment.Center) {
val image =
if (roundedCorner) {
MaterialTheme.colorScheme.imageModifier
} else {
Modifier.fillMaxWidth()
}
// Always displays Blurharh to avoid size flickering
DisplayBlurHash(
blurhash,
null,
if (isFiniteHeight) ContentScale.FillWidth else ContentScale.FillWidth,
if (ratio != null) image.aspectRatio(ratio) else modifier,
if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier,
)
if (!automaticallyStartPlayback.value) {
@@ -302,7 +326,7 @@ fun VideoView(
defaultToStart = defaultToStart,
title = title,
thumb = thumb,
roundedCorner = roundedCorner,
borderModifier = borderModifier,
isFiniteHeight = isFiniteHeight,
waveform = waveform,
artworkUri = artworkUri,
@@ -326,9 +350,9 @@ fun VideoViewInner(
defaultToStart: Boolean = false,
title: String? = null,
thumb: VideoThumb? = null,
roundedCorner: Boolean,
gallery: Boolean = false,
showControls: Boolean = false,
isFiniteHeight: Boolean,
borderModifier: Modifier,
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
@@ -338,7 +362,7 @@ fun VideoViewInner(
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
) {
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
VideoPlayerActiveMutex(videoUri) { videoModifier, activeOnScreen ->
GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
@@ -351,15 +375,15 @@ fun VideoViewInner(
mimeType = mimeType,
controller = controller,
thumbData = thumb,
roundedCorner = roundedCorner,
gallery = gallery,
hideControls = showControls,
isFiniteHeight = isFiniteHeight,
nostrUriCallback = nostrUriCallback,
waveform = waveform,
keepPlaying = keepPlaying,
automaticallyStartPlayback = automaticallyStartPlayback,
activeOnScreen = activeOnScreen,
modifier = modifier,
borderModifier = borderModifier,
videoModifier = videoModifier,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog,
accountViewModel = accountViewModel,
@@ -655,9 +679,9 @@ fun VideoPlayerActiveMutex(
onDispose { trackingVideos.remove(myCache) }
}
val myModifier =
val videoModifier =
remember(videoUri) {
Modifier.fillMaxWidth().defaultMinSize(minHeight = 70.dp).onVisiblePositionChanges { distanceToCenter ->
Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges { distanceToCenter ->
myCache.distanceToCenter = distanceToCenter
if (distanceToCenter != null) {
@@ -684,7 +708,7 @@ fun VideoPlayerActiveMutex(
}
}
inner(myModifier, active)
inner(videoModifier, active)
}
@Stable
@@ -699,15 +723,15 @@ private fun RenderVideoPlayer(
mimeType: String?,
controller: MediaController,
thumbData: VideoThumb?,
roundedCorner: Boolean,
gallery: Boolean = false,
hideControls: Boolean = false,
isFiniteHeight: Boolean,
nostrUriCallback: String?,
waveform: ImmutableList<Int>? = null,
keepPlaying: MutableState<Boolean>,
automaticallyStartPlayback: State<Boolean>,
activeOnScreen: MutableState<Boolean>,
modifier: Modifier,
borderModifier: Modifier,
videoModifier: Modifier,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: ((Boolean) -> Unit)?,
accountViewModel: AccountViewModel,
@@ -716,27 +740,9 @@ private fun RenderVideoPlayer(
val controllerVisible = remember(controller) { mutableStateOf(false) }
Box {
val borders = MaterialTheme.colorScheme.imageModifier
val bordersSquare = MaterialTheme.colorScheme.videoGalleryModifier
val myModifier =
remember(controller) {
if (roundedCorner) {
modifier.then(
borders.defaultMinSize(minHeight = 75.dp).align(Alignment.Center),
)
} else if (gallery) {
Modifier
modifier.then(
bordersSquare.defaultMinSize(minHeight = 75.dp).align(Alignment.Center),
)
} else {
modifier.fillMaxWidth().defaultMinSize(minHeight = 75.dp).align(Alignment.Center)
}
}
Box(modifier = borderModifier) {
AndroidView(
modifier = myModifier,
modifier = videoModifier,
factory = { context: Context ->
PlayerView(context).apply {
player = controller
@@ -748,7 +754,7 @@ private fun RenderVideoPlayer(
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
controllerAutoShow = false
useController = !gallery
useController = !hideControls
thumbData?.thumb?.let { defaultArtwork = it }
hideController()
resizeMode =
@@ -757,7 +763,7 @@ private fun RenderVideoPlayer(
} else {
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
}
if (!gallery) {
if (!hideControls) {
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
controller.pause()
@@ -776,7 +782,8 @@ private fun RenderVideoPlayer(
)
waveform?.let { Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) }
if (!gallery) {
if (!hideControls) {
val startingMuteState = remember(controller) { controller.volume < 0.001 }
MuteButton(
@@ -824,7 +831,7 @@ private fun RenderVideoPlayer(
}
AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle ->
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, toggle)
ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, null, null, null, mimeType, toggle)
}
} else {
controller.volume = 0f
@@ -89,6 +89,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -388,6 +389,12 @@ private fun RenderImageOrVideo(
accountViewModel: AccountViewModel,
) {
val automaticallyStartPlayback = remember { mutableStateOf<Boolean>(true) }
val contentScale =
if (isFiniteHeight) {
ContentScale.Fit
} else {
ContentScale.FillWidth
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
if (content is MediaUrlImage) {
@@ -405,21 +412,28 @@ private fun RenderImageOrVideo(
UrlImageView(
content = content,
contentScale = contentScale,
mainImageModifier = mainModifier,
loadedImageModifier = Modifier.fillMaxWidth(),
isFiniteHeight = isFiniteHeight,
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
alwayShowImage = true,
)
} else if (content is MediaUrlVideo) {
val borderModifier =
if (roundedCorner) {
MaterialTheme.colorScheme.imageModifier
} else {
Modifier.fillMaxWidth()
}
VideoViewInner(
videoUri = content.url,
mimeType = content.mimeType,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
roundedCorner = roundedCorner,
borderModifier = borderModifier,
isFiniteHeight = isFiniteHeight,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
@@ -440,14 +454,21 @@ private fun RenderImageOrVideo(
LocalImageView(
content = content,
contentScale = contentScale,
mainImageModifier = mainModifier,
loadedImageModifier = Modifier.fillMaxWidth(),
isFiniteHeight = isFiniteHeight,
controllerVisible = controllerVisible,
accountViewModel = accountViewModel,
alwayShowImage = true,
)
} else if (content is MediaLocalVideo) {
val borderModifier =
if (roundedCorner) {
MaterialTheme.colorScheme.imageModifier
} else {
Modifier.fillMaxWidth()
}
content.localFile?.let {
VideoViewInner(
videoUri = it.toUri().toString(),
@@ -455,7 +476,7 @@ private fun RenderImageOrVideo(
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
roundedCorner = roundedCorner,
borderModifier = borderModifier,
isFiniteHeight = isFiniteHeight,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
@@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.service.BlurHashRequester
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.BlankNote
@@ -94,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.toHexKey
@@ -116,6 +118,13 @@ fun ZoomableContentView(
) {
var dialogOpen by remember(content) { mutableStateOf(false) }
val contentScale =
if (isFiniteHeight) {
ContentScale.Fit
} else {
ContentScale.FillWidth
}
when (content) {
is MediaUrlImage ->
SensitivityWarning(content.contentWarning != null, accountViewModel) {
@@ -123,7 +132,7 @@ fun ZoomableContentView(
val mainImageModifier = Modifier.fillMaxWidth().clickable { dialogOpen = true }
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
UrlImageView(content, mainImageModifier, loadedImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel)
UrlImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
}
}
is MediaUrlVideo ->
@@ -150,7 +159,7 @@ fun ZoomableContentView(
val mainImageModifier = Modifier.fillMaxWidth().clickable { dialogOpen = true }
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
LocalImageView(content, mainImageModifier, loadedImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel)
LocalImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
}
is MediaLocalVideo ->
content.localFile?.let {
@@ -176,6 +185,67 @@ fun ZoomableContentView(
}
}
@Composable
fun GalleryContentView(
content: BaseMediaContent,
roundedCorner: Boolean,
isFiniteHeight: Boolean,
accountViewModel: AccountViewModel,
) {
when (content) {
is MediaUrlImage ->
SensitivityWarning(content.contentWarning != null, accountViewModel) {
TwoSecondController(content) { controllerVisible ->
val mainImageModifier = Modifier.fillMaxWidth()
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
UrlImageView(content, ContentScale.Crop, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
}
}
is MediaUrlVideo ->
SensitivityWarning(content.contentWarning != null, accountViewModel) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
VideoView(
videoUri = content.url,
mimeType = content.mimeType,
title = content.description,
artworkUri = content.artworkUri,
borderModifier = MaterialTheme.colorScheme.videoGalleryModifier,
authorName = content.authorName,
dimensions = content.dim,
blurhash = content.blurhash,
isFiniteHeight = isFiniteHeight,
nostrUriCallback = content.uri,
accountViewModel = accountViewModel,
)
}
}
is MediaLocalImage ->
TwoSecondController(content) { controllerVisible ->
val mainImageModifier = Modifier.fillMaxWidth()
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
LocalImageView(content, ContentScale.Crop, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
}
is MediaLocalVideo ->
content.localFile?.let {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
VideoView(
videoUri = it.toUri().toString(),
mimeType = content.mimeType,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
borderModifier = MaterialTheme.colorScheme.videoGalleryModifier,
isFiniteHeight = isFiniteHeight,
nostrUriCallback = content.uri,
accountViewModel = accountViewModel,
)
}
}
}
}
@Composable
fun TwoSecondController(
content: BaseMediaContent,
@@ -194,30 +264,24 @@ fun TwoSecondController(
@Composable
fun LocalImageView(
content: MediaLocalImage,
contentScale: ContentScale,
mainImageModifier: Modifier,
loadedImageModifier: Modifier,
isFiniteHeight: Boolean,
controllerVisible: MutableState<Boolean>,
accountViewModel: AccountViewModel,
alwayShowImage: Boolean = false,
) {
if (content.localFileExists()) {
Box(contentAlignment = Alignment.Center) {
val showImage =
remember {
mutableStateOf(
if (alwayShowImage) true else accountViewModel.settings.showImages.value,
)
}
val showImage =
remember {
mutableStateOf(
if (alwayShowImage) true else accountViewModel.settings.showImages.value,
)
}
val contentScale =
remember {
if (isFiniteHeight) ContentScale.Fit else ContentScale.FillWidth
}
val ratio = remember(content) { aspectRatio(content.dim) }
if (showImage.value) {
val ratio = remember(content) { aspectRatio(content.dim) }
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
if (it) {
SubcomposeAsyncImage(
model = content.localFile,
contentDescription = content.description,
@@ -298,28 +362,28 @@ fun LocalImageView(
@Composable
fun UrlImageView(
content: MediaUrlImage,
contentScale: ContentScale,
mainImageModifier: Modifier,
loadedImageModifier: Modifier,
isFiniteHeight: Boolean,
controllerVisible: MutableState<Boolean>,
accountViewModel: AccountViewModel,
alwayShowImage: Boolean = false,
) {
Box(contentAlignment = Alignment.Center) {
val showImage =
remember {
mutableStateOf(
if (alwayShowImage) true else accountViewModel.settings.showImages.value,
)
}
val ratio = remember(content) { aspectRatio(content.dim) }
val ratio = remember(content) { aspectRatio(content.dim) }
val showImage =
remember {
mutableStateOf(
if (alwayShowImage) true else accountViewModel.settings.showImages.value,
)
}
if (showImage.value) {
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
if (it) {
SubcomposeAsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = if (isFiniteHeight) ContentScale.Fit else ContentScale.FillWidth,
contentScale = contentScale,
modifier = mainImageModifier,
) {
when (painter.state) {
@@ -422,7 +486,7 @@ fun ImageUrlWithDownloadButton(
val inlineContent = mapOf("inlineContent" to InlineDownloadIcon(showImage))
val pressIndicator = remember { Modifier.clickable { runCatching { uri.openUri(url) } } }
val pressIndicator = remember { Modifier.fillMaxWidth().clickable { runCatching { uri.openUri(url) } } }
Text(
text = annotatedTermsString,
@@ -605,6 +669,10 @@ fun ShareImageAction(
popupExpanded = popupExpanded,
videoUri = content.url,
postNostrUri = content.uri,
blurhash = content.blurhash,
dim = content.dim,
hash = content.hash,
mimeType = content.mimeType,
onDismiss = onDismiss,
)
} else if (content is MediaPreloadedContent) {
@@ -613,6 +681,10 @@ fun ShareImageAction(
popupExpanded = popupExpanded,
videoUri = content.localFile?.toUri().toString(),
postNostrUri = content.uri,
blurhash = content.blurhash,
dim = content.dim,
hash = null,
mimeType = content.mimeType,
onDismiss = onDismiss,
)
}
@@ -625,6 +697,10 @@ fun ShareImageAction(
popupExpanded: MutableState<Boolean>,
videoUri: String?,
postNostrUri: String?,
blurhash: String?,
dim: String?,
hash: String?,
mimeType: String?,
onDismiss: () -> Unit,
) {
DropdownMenu(
@@ -660,7 +736,7 @@ fun ShareImageAction(
if (videoUri != null) {
var n19 = Nip19Bech32.uriToRoute(postNostrUri)?.entity as? Nip19Bech32.NEvent
if (n19 != null) {
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay[0]) // TODO Whole list or first?
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay[0], blurhash, dim, hash, mimeType) // TODO Whole list or first?
accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
}
}
@@ -36,15 +36,15 @@ class BookmarkPrivateFeedFilter(
val privateTags = bookmarks?.cachedPrivateTags() ?: return emptyList()
val notes =
bookmarks.filterEvents(privateTags).mapNotNull { LocalCache.checkGetOrCreateNote(it) }
val addresses =
bookmarks.filterAddresses(privateTags).map { LocalCache.getOrCreateAddressableNote(it) }
return notes
.plus(addresses)
.toSet()
.sortedWith(DefaultFeedOrder)
return privateTags
.mapNotNull {
if (it.size > 1 && it[0] == "e") {
LocalCache.checkGetOrCreateNote(it[1])
} else if (it.size > 1 && it[0] == "a") {
LocalCache.checkGetOrCreateAddressableNote(it[1])
} else {
null
}
}.reversed()
}
}
@@ -32,14 +32,16 @@ class BookmarkPublicFeedFilter(
override fun feed(): List<Note> {
val bookmarks = account.userProfile().latestBookmarkList
val notes =
bookmarks?.taggedEvents()?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
val addresses =
bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
return notes
.plus(addresses)
.toSet()
.sortedWith(DefaultFeedOrder)
return bookmarks
?.tags
?.mapNotNull {
if (it.size > 1 && it[0] == "e") {
LocalCache.checkGetOrCreateNote(it[1])
} else if (it.size > 1 && it[0] == "a") {
LocalCache.checkGetOrCreateAddressableNote(it[1])
} else {
null
}
}?.reversed() ?: emptyList()
}
}
@@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.Event
val DefaultFeedOrder: Comparator<Note> =
compareBy<Note>(
compareByDescending<Note>
{
val noteEvent = it.event
if (noteEvent == null) {
@@ -36,8 +36,4 @@ val DefaultFeedOrder: Comparator<Note> =
null
}
}
},
{
it.idHex
},
).reversed()
}.thenBy { it.idHex }
@@ -47,16 +47,7 @@ class UserProfileGalleryFeedFilter(
}
var sorted = sort(notes)
var finalnotes = setOf<Note>()
for (item in sorted) {
val note = (item.event as ProfileGalleryEntryEvent).event()?.let { LocalCache.checkGetOrCreateNote(it) }
if (note != null) {
note.associatedNote = item
finalnotes = finalnotes + note
}
}
return finalnotes.toList()
return sorted.toList()
}
override fun applyFilter(collection: Set<Note>): Set<Note> = innerApplyFilter(collection)
@@ -74,7 +65,7 @@ class UserProfileGalleryFeedFilter(
): Boolean {
val noteEvent = it.event
return (
(it.event?.pubKey() == user.pubkeyHex && noteEvent is ProfileGalleryEntryEvent) && noteEvent.hasUrl() && noteEvent.hasEvent() // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
(it.event?.pubKey() == user.pubkeyHex && noteEvent is ProfileGalleryEntryEvent) && noteEvent.hasUrl() && noteEvent.hasFromEvent() // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
) &&
params.match(noteEvent) &&
account.isAcceptable(it)
@@ -27,6 +27,11 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.BottomAppBarDefaults.windowInsets
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -43,11 +48,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size0dp
import com.vitorpamplona.amethyst.ui.theme.Size10Modifier
@@ -126,11 +131,18 @@ private fun RenderBottomMenu(
navEntryState: State<NavBackStackEntry?>,
nav: (Route, Boolean) -> Unit,
) {
Column(modifier = BottomTopHeight) {
Column(
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(windowInsets)
.consumeWindowInsets(windowInsets)
.height(50.dp),
) {
HorizontalDivider(
thickness = DividerThickness,
)
NavigationBar(tonalElevation = Size0dp, containerColor = Color.Transparent) {
NavigationBar(tonalElevation = Size0dp) {
bottomNavigationItems.forEach { item ->
HasNewItemsIcon(item, accountViewModel, navEntryState, nav)
}
@@ -100,7 +100,7 @@ fun AppNavigation(
) {
val scope = rememberCoroutineScope()
val nav =
remember {
remember(navController) {
{ route: String ->
scope.launch {
if (getRouteWithArguments(navController) != route) {
@@ -219,7 +219,6 @@ fun AppNavigation(
composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) })
composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) })
composable(Route.Drafts.route, content = { DraftListScreen(accountViewModel, nav) })
Route.ContentDiscovery.let { route ->
@@ -20,7 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.navigation
import android.app.ActivityManager
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Debug
import android.util.Log
import androidx.compose.foundation.clickable
@@ -66,6 +68,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -130,7 +133,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
@@ -761,9 +763,11 @@ class FollowListViewModel(
@OptIn(ExperimentalCoroutinesApi::class)
val liveKind3FollowsFlow: Flow<List<CodeName>> =
account.userProfile().flow().follows.stateFlow.transformLatest {
account.liveKind3FollowsFlow.transformLatest {
checkNotInMainThread()
val communities =
it.user.cachedFollowingCommunitiesSet().mapNotNull {
it.communities.mapNotNull {
LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote ->
CodeName(
"Community/${communityNote.idHex}",
@@ -774,12 +778,12 @@ class FollowListViewModel(
}
val hashtags =
it.user.cachedFollowingTagSet().map {
it.hashtags.map {
CodeName("Hashtag/$it", HashtagName(it), CodeNameType.ROUTE)
}
val geotags =
it.user.cachedFollowingGeohashSet().map {
it.geotags.map {
CodeName("Geohash/$it", GeoHashName(it), CodeNameType.ROUTE)
}
@@ -987,7 +991,6 @@ fun FlexibleTopBarWithBackButton(
navigationIcon = { IconButton(onClick = popBack) { ArrowBackIcon() } },
actions = {},
)
Spacer(modifier = HalfVertSpacer)
HorizontalDivider(thickness = DividerThickness)
}
}
@@ -1006,7 +1009,7 @@ fun AmethystClickableIcon() {
fun debugState(context: Context) {
Client
.allSubscriptions()
.forEach { Log.d("STATE DUMP", "${it.key} ${it.value.joinToString { it.filter.toJson() }}") }
.forEach { Log.d("STATE DUMP", "${it.key} ${it.value.joinToString { it.filter.toDebugJson() }}") }
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
@@ -1036,6 +1039,14 @@ fun debugState(context: Context) {
Log.d("STATE DUMP", "Total Native Heap Allocated: " + nativeHeap + " MB")
val activityManager: ActivityManager? = context.getSystemService()
if (activityManager != null) {
val isLargeHeap = (context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP) != 0
val memClass = if (isLargeHeap) activityManager.largeMemoryClass else activityManager.memoryClass
Log.d("STATE DUMP", "Memory Class " + memClass + " MB (largeHeap $isLargeHeap)")
}
Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
val imageLoader = Coil.imageLoader(context)
@@ -1094,6 +1105,13 @@ fun debugState(context: Context) {
LocalCache.observablesByKindAndAuthor.size,
)
Log.d(
"STATE DUMP",
"Spam: " +
LocalCache.antiSpam.recentMessages.size() +
" / " + LocalCache.antiSpam.spamMessages.size(),
)
Log.d(
"STATE DUMP",
"Memory used by Events: " +
@@ -1111,11 +1129,11 @@ fun debugState(context: Context) {
LocalCache.addressables
.sumByGroup(groupMap = { _, it -> it.event?.kind() }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
qttNotes.forEach { kind, qtt ->
Log.d("STATE DUMP", "Kind $kind:\t$qtt elements\t${bytesNotes.get(kind)?.div((1024 * 1024))}MB ")
qttNotes.toList().sortedByDescending { bytesNotes.get(it.first) }.forEach { (kind, qtt) ->
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes.get(kind)?.div((1024 * 1024))}MB ")
}
qttAddressables.forEach { kind, qtt ->
Log.d("STATE DUMP", "Kind $kind:\t$qtt elements\t${bytesAddressables.get(kind)?.div((1024 * 1024))}MB ")
qttAddressables.toList().sortedByDescending { bytesNotes.get(it.first) }.forEach { (kind, qtt) ->
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables.get(kind)?.div((1024 * 1024))}MB ")
}
}
@@ -83,6 +83,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.mediaServers.MediaServersListView
@@ -114,7 +115,6 @@ import com.vitorpamplona.ammolite.relays.RelayPoolStatus
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
@@ -136,7 +136,9 @@ fun DrawerContent(
drawerTonalElevation = 0.dp,
) {
Column(
Modifier.fillMaxHeight().verticalScroll(rememberScrollState()),
Modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
) {
ProfileContent(
baseAccountUser = accountViewModel.account.userProfile(),
@@ -146,10 +148,10 @@ fun DrawerContent(
)
Column(drawerSpacing) {
EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel)
EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, drawerState)
}
FollowingAndFollowerCounts(accountViewModel.account.userProfile(), onClickUser)
FollowingAndFollowerCounts(accountViewModel.account, onClickUser)
HorizontalDivider(
thickness = DividerThickness,
@@ -263,15 +265,16 @@ fun ProfileContentTemplate(
private fun EditStatusBoxes(
baseAccountUser: User,
accountViewModel: AccountViewModel,
drawerState: DrawerState,
) {
LoadStatuses(user = baseAccountUser, accountViewModel) { statuses ->
if (statuses.isEmpty()) {
StatusEditBar(accountViewModel = accountViewModel)
StatusEditBar(accountViewModel = accountViewModel, drawerState = drawerState)
} else {
statuses.forEach {
val originalStatus by it.live().content.observeAsState()
StatusEditBar(originalStatus, it.address, accountViewModel)
StatusEditBar(originalStatus, it.address, accountViewModel, drawerState = drawerState)
}
}
}
@@ -282,11 +285,17 @@ fun StatusEditBar(
savedStatus: String? = null,
tag: ATag? = null,
accountViewModel: AccountViewModel,
drawerState: DrawerState,
) {
val focusManager = LocalFocusManager.current
val currentStatus = remember { mutableStateOf(savedStatus ?: "") }
val hasChanged = remember { derivedStateOf { currentStatus.value != (savedStatus ?: "") } }
LaunchedEffect(drawerState.isClosed) {
if (drawerState.isClosed) {
focusManager.clearFocus(true)
}
}
OutlinedTextField(
value = currentStatus.value,
@@ -371,18 +380,12 @@ fun UserStatusDeleteButton(onClick: () -> Unit) {
@Composable
private fun FollowingAndFollowerCounts(
baseAccountUser: User,
baseAccountUser: Account,
onClick: () -> Unit,
) {
var followingCount by remember { mutableStateOf("--") }
val followingCount = baseAccountUser.liveKind3Follows.collectAsStateWithLifecycle()
var followerCount by remember { mutableStateOf("--") }
WatchFollow(baseAccountUser = baseAccountUser) { newFollowing ->
if (followingCount != newFollowing) {
followingCount = newFollowing
}
}
WatchFollower(baseAccountUser = baseAccountUser) { newFollower ->
if (followerCount != newFollower) {
followerCount = newFollower
@@ -393,7 +396,9 @@ private fun FollowingAndFollowerCounts(
modifier = drawerSpacing.clickable(onClick = onClick),
) {
Text(
text = followingCount,
text =
followingCount.value.users.size
.toString(),
fontWeight = FontWeight.Bold,
)
@@ -410,31 +415,19 @@ private fun FollowingAndFollowerCounts(
}
}
@Composable
fun WatchFollow(
baseAccountUser: User,
onReady: (String) -> Unit,
) {
val accountUserFollowsState by baseAccountUser.live().follows.observeAsState()
LaunchedEffect(key1 = accountUserFollowsState) {
launch(Dispatchers.IO) {
onReady(accountUserFollowsState?.user?.cachedFollowCount()?.toString() ?: "--")
}
}
}
@Composable
fun WatchFollower(
baseAccountUser: User,
baseAccountUser: Account,
onReady: (String) -> Unit,
) {
val accountUserFollowersState by baseAccountUser.live().followers.observeAsState()
val accountUserFollowersState by baseAccountUser
.userProfile()
.live()
.followers
.observeAsState()
LaunchedEffect(key1 = accountUserFollowersState) {
launch(Dispatchers.IO) {
onReady(accountUserFollowersState?.user?.cachedFollowerCount()?.toString() ?: "--")
}
onReady(baseAccountUser.followerCount().toString() ?: "--")
}
}
@@ -676,7 +676,7 @@ fun LoadModerators(
val newParticipantUsers =
if (followingKeySet == null) {
val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet()
val allFollows = accountViewModel.account.liveKind3Follows.value.users
val followingParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts)
@@ -732,7 +732,7 @@ private fun LoadParticipants(
val newParticipantUsers =
if (followingKeySet == null) {
val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet()
val allFollows = accountViewModel.account.liveKind3Follows.value.users
val followingParticipants =
ParticipantListBuilder()
.followsThatParticipateOn(baseNote, allFollows)
@@ -889,7 +889,7 @@ fun RenderChannelThumb(
val newParticipantUsers =
if (followingKeySet == null) {
val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet()
val allFollows = accountViewModel.account.liveKind3Follows.value.users
val followingParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList()
@@ -71,6 +71,7 @@ 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.encoders.ATag
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -168,7 +169,7 @@ private fun VerifyAndDisplayNIP05OrStatusLine(
if (nip05Verified.value != true) {
DisplayNIP05(nip05, nip05Verified, accountViewModel)
} else if (!statuses.isEmpty()) {
RotateStatuses(statuses, accountViewModel, nav)
ObserveRotateStatuses(statuses, accountViewModel, nav)
} else {
DisplayNIP05(nip05, nip05Verified, accountViewModel)
}
@@ -183,6 +184,29 @@ private fun VerifyAndDisplayNIP05OrStatusLine(
}
}
@Composable
fun ObserveRotateStatuses(
statuses: ImmutableList<AddressableNote>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
ObserveAllStatusesToAvoidSwitchigAllTheTime(statuses)
RotateStatuses(
statuses,
accountViewModel,
nav,
)
}
@Composable
fun ObserveAllStatusesToAvoidSwitchigAllTheTime(statuses: ImmutableList<AddressableNote>) {
statuses
.map {
it.live().metadata.observeAsState()
}
}
@Composable
fun RotateStatuses(
statuses: ImmutableList<AddressableNote>,
@@ -221,14 +245,29 @@ fun DisplayStatus(
nav: (String) -> Unit,
) {
val noteState by addressableNote.live().metadata.observeAsState()
val noteEvent = noteState?.note?.event ?: return
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 } }
DisplayStatusInner(
noteEvent.content(),
(noteEvent as? AddressableEvent)?.dTag() ?: "",
noteEvent.firstTaggedUrl()?.ifBlank { null },
noteEvent.firstTaggedAddress(),
noteEvent.firstTaggedEvent()?.ifBlank { null },
accountViewModel,
nav,
)
}
@Composable
fun DisplayStatusInner(
content: String,
type: String,
url: String?,
nostrATag: ATag?,
nostrHexID: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
when (type) {
"music" ->
Icon(
@@ -991,11 +991,7 @@ fun FirstUserInfoRow(
BoostedMark()
}
if (editState.value is GenericLoadable.Loaded) {
(editState.value as? GenericLoadable.Loaded<EditState>)?.loaded?.let {
DisplayEditStatus(it)
}
}
CheckAndDisplayEditStatus(editState)
if (baseNote.isDraft()) {
DisplayDraft()
@@ -1007,6 +1003,15 @@ fun FirstUserInfoRow(
}
}
@Composable
fun CheckAndDisplayEditStatus(editState: State<GenericLoadable<EditState>>) {
if (editState.value is GenericLoadable.Loaded) {
(editState.value as? GenericLoadable.Loaded<EditState>)?.loaded?.let {
DisplayEditStatus(it)
}
}
}
@Composable
fun observeEdits(
baseNote: Note,
@@ -48,6 +48,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.outlined.ContentPaste
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Button
@@ -426,18 +427,6 @@ fun UpdateZapAmountDialog(
Modifier.weight(1f),
)
IconButton(onClick = {
onClose()
runCatching { uri.openUri("https://app.mutinywallet.com/settings/connections?name=Amethyst") }
}) {
Icon(
painter = painterResource(R.mipmap.mutiny),
null,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified,
)
}
IconButton(
onClick = {
onClose()
@@ -458,7 +447,7 @@ fun UpdateZapAmountDialog(
},
) {
Icon(
Icons.Default.ContentPaste,
Icons.Outlined.ContentPaste,
contentDescription = stringRes(id = R.string.paste_from_clipboard),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
@@ -696,7 +685,7 @@ fun authenticate(
stringRes(
context,
R.string.biometric_authentication_failed_explainer_with_error,
errString,
errString.toString(),
),
)
}
@@ -367,11 +367,10 @@ fun WatchUserFollows(
remember {
accountViewModel.userFollows
.map {
it.user.isFollowingCached(userHex) ||
(userHex == accountViewModel.account.userProfile().pubkeyHex)
accountViewModel.isFollowing(userHex) || (userHex == accountViewModel.account.userProfile().pubkeyHex)
}.distinctUntilChanged()
}.observeAsState(
accountViewModel.account.userProfile().isFollowingCached(userHex) ||
accountViewModel.isFollowing(userHex) ||
(userHex == accountViewModel.account.userProfile().pubkeyHex),
)
@@ -29,12 +29,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -44,12 +44,11 @@ fun DisplayFollowingHashtagsInPost(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val userFollowState by accountViewModel.userFollows.observeAsState()
val userFollowState by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle()
var firstTag by remember(baseNote) { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = userFollowState) {
val followingTags = userFollowState?.user?.cachedFollowingTagSet() ?: emptySet()
val newFirstTag = baseNote.event?.firstIsTaggedHashes(followingTags)
val newFirstTag = baseNote.event?.firstIsTaggedHashes(userFollowState.hashtags)
if (firstTag != newFirstTag) {
firstTag = newFirstTag
@@ -42,9 +42,10 @@ import kotlinx.coroutines.launch
@Composable
fun DisplayUncitedHashtags(
event: Event,
callbackUri: String? = null,
nav: (String) -> Unit,
) {
DisplayUncitedHashtags(event, event.content, nav)
DisplayUncitedHashtags(event, event.content, callbackUri, nav)
}
@OptIn(ExperimentalLayoutApi::class)
@@ -52,6 +53,7 @@ fun DisplayUncitedHashtags(
fun DisplayUncitedHashtags(
event: Event,
content: String,
callbackUri: String? = null,
nav: (String) -> Unit,
) {
val unusedHashtags by
@@ -59,7 +61,7 @@ fun DisplayUncitedHashtags(
val tagsInEvent = event.hashtags()
if (tagsInEvent.isNotEmpty()) {
launch(Dispatchers.Default) {
val state = CachedRichTextParser.parseText(content, event.tags.toImmutableListOfLists())
val state = CachedRichTextParser.parseText(content, event.tags.toImmutableListOfLists(), callbackUri)
val tagsInContent =
state
@@ -188,10 +188,9 @@ fun NoteDropDownMenu(
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_text)) },
onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.decrypt(note) { clipboardManager.setText(AnnotatedString(it)) }
onDismiss()
}
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) }
onDismiss()
},
)
DropdownMenuItem(
@@ -216,6 +216,8 @@ fun AudioHeader(
}
}
val callbackUri = remember(note) { note.toNostrUri() }
content?.let {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -231,7 +233,7 @@ fun AudioHeader(
tags = tags,
backgroundColor = background,
id = note.idHex,
callbackUri = note.toNostrUri(),
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -240,7 +242,7 @@ fun AudioHeader(
if (noteEvent.hasHashtags()) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
DisplayUncitedHashtags(noteEvent, nav)
DisplayUncitedHashtags(noteEvent, callbackUri, nav)
}
}
}
@@ -170,23 +170,23 @@ private fun RenderGitPatchEvent(
overflow = TextOverflow.Ellipsis,
)
} else {
val callbackUri = remember(note) { note.toNostrUri() }
SensitivityWarning(
note = note,
accountViewModel = accountViewModel,
) {
val modifier = remember(note) { Modifier.fillMaxWidth() }
val tags =
remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview && !makeItShort,
quotesLeft = quotesLeft,
modifier = modifier,
modifier = Modifier.fillMaxWidth(),
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -196,6 +196,7 @@ private fun RenderGitPatchEvent(
DisplayUncitedHashtags(
event = noteEvent,
content = eventContent,
callbackUri = callbackUri,
nav = nav,
)
}
@@ -274,11 +275,12 @@ private fun RenderGitIssueEvent(
overflow = TextOverflow.Ellipsis,
)
} else {
val callbackUri = remember(note) { note.toNostrUri() }
SensitivityWarning(
note = note,
accountViewModel = accountViewModel,
) {
val modifier = remember(note) { Modifier.fillMaxWidth() }
val tags =
remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
@@ -286,18 +288,18 @@ private fun RenderGitIssueEvent(
content = eventContent,
canPreview = canPreview && !makeItShort,
quotesLeft = quotesLeft,
modifier = modifier,
modifier = Modifier.fillMaxWidth(),
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (note.event?.hasHashtags() == true) {
DisplayUncitedHashtags(noteEvent, eventContent, nav)
DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav)
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import android.net.Uri
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -127,8 +128,9 @@ fun DisplayHighlight(
verticalArrangement = Arrangement.Center,
) {
DisplayQuoteAuthor(
highlightQuote = highlight,
authorHex = authorHex,
url = url,
baseUrl = url,
postAddress = postAddress,
postVersion = postVersion,
accountViewModel = accountViewModel,
@@ -139,8 +141,9 @@ fun DisplayHighlight(
@Composable
private fun DisplayQuoteAuthor(
highlightQuote: String,
authorHex: String?,
url: String?,
baseUrl: String?,
postAddress: ATag?,
postVersion: HexKey?,
accountViewModel: AccountViewModel,
@@ -196,7 +199,9 @@ private fun DisplayQuoteAuthor(
version?.let {
DisplayEntryForNote(it, userBase, accountViewModel, nav)
}
} else if (url != null) {
} else if (baseUrl != null) {
val url = "$baseUrl${if (baseUrl.contains("#")) "&" else "#"}:~:text=${Uri.encode(highlightQuote)}"
DisplayEntryForAUrl(url, userBase, accountViewModel, nav)
} else if (userBase != null) {
userBase?.let {
@@ -21,11 +21,13 @@
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -40,13 +42,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import coil.compose.AsyncImage
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.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -56,13 +64,44 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.Participant
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.util.Locale
@Composable
@Preview
fun RenderLiveActivityEventPreview() {
val event = Event.fromJson("{\"id\":\"19406ad34ce3c653d62eb73c1816ac27dcf473c2ccdccf5af7d90d2633c62561\",\"pubkey\":\"6b66886b3add72c779d205be574ec2d7cec619061ac3e75717389b26445989e4\",\"created_at\":1719084750,\"kind\":30311,\"tags\":[[\"r\",\"podcast:guid:72d5e069-f907-5ee7-b0d7-45404f4f0aa5\"],[\"r\",\"podcast:item:guid:bfc33d6e-e00f-4f11-a2ff-94865b7867aa\"],[\"d\",\"bfc33d6e-e00f-4f11-a2ff-94865b7867aa\"],[\"title\",\"The Online Identity Time Bomb\"],[\"summary\",\"Online identity is a ticking time bomb. But are trustworthy, open-source solutions ready to disarm it, or will we be stuck with lackluster, proprietary systems?\\n Live chat: https:/jblive.tv\"],[\"streaming\",\"https://jblive.fm\"],[\"starts\",\"1719167400\"],[\"status\",\"planned\"],[\"image\",\"https://station.us-iad-1.linodeobjects.com/art/lup-mp3.jpg\"]],\"content\":\"\",\"sig\":\"2ce3fae9ad4512541aaae4dbd9484f50df62ab95ba935d7512b736f087f151c1d15ec2bf62d3135474f16c3e070f335b24349c5493461873ddca3051804ca944\"}") as LiveActivitiesEvent
val accountViewModel = mockAccountViewModel()
val nav: (String) -> Unit = {}
val baseNote: Note?
runBlocking {
withContext(Dispatchers.IO) {
LocalCache.justConsume(event, null)
baseNote = LocalCache.getOrCreateNote("19406ad34ce3c653d62eb73c1816ac27dcf473c2ccdccf5af7d90d2633c62561")
}
}
if (baseNote != null) {
ThemeComparisonColumn(
toPreview = {
RenderLiveActivityEvent(baseNote = baseNote, accountViewModel, nav)
},
)
}
}
@Composable
fun RenderLiveActivityEvent(
baseNote: Note,
@@ -129,20 +168,6 @@ fun RenderLiveActivityEventInner(
}
}
var participantUsers by remember {
mutableStateOf<ImmutableList<Pair<Participant, User>>>(
persistentListOf(),
)
}
LaunchedEffect(key1 = eventUpdates) {
accountViewModel.loadParticipants(participants) { newParticipantUsers ->
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
}
}
media?.let { media ->
if (status == LiveActivitiesEvent.STATUS_LIVE) {
CheckIfUrlIsOnline(media, accountViewModel) { isOnline ->
@@ -178,19 +203,43 @@ fun RenderLiveActivityEventInner(
}
}
}
} else if (status == LiveActivitiesEvent.STATUS_ENDED) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(10.dp)
.height(100.dp),
) {
Text(
text = stringRes(id = R.string.live_stream_has_ended),
color = MaterialTheme.colorScheme.onBackground,
fontWeight = FontWeight.Bold,
)
} else {
if (status == LiveActivitiesEvent.STATUS_ENDED || (status == LiveActivitiesEvent.STATUS_PLANNED && (starts ?: 0) < TimeUtils.eightHoursAgo())) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.padding(vertical = 10.dp)
.heightIn(min = 100.dp),
) {
cover?.let {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(model = it, contentDescription = null, modifier = MaterialTheme.colorScheme.imageModifier)
}
} ?: run { DisplayAuthorBanner(baseNote) }
Text(
text = stringRes(id = R.string.live_stream_has_ended),
color = MaterialTheme.colorScheme.onBackground,
fontWeight = FontWeight.Bold,
)
}
}
}
}
var participantUsers by remember {
mutableStateOf<ImmutableList<Pair<Participant, User>>>(
persistentListOf(),
)
}
LaunchedEffect(key1 = eventUpdates) {
accountViewModel.loadParticipants(participants) { newParticipantUsers ->
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
}
}
@@ -47,6 +47,7 @@ fun RenderNIP90ContentDiscoveryResponse(
nav: (String) -> Unit,
) {
val noteEvent = note.event as? NIP90ContentDiscoveryResponseEvent ?: return
val callbackUri = remember(note) { note.toNostrUri() }
SensitivityWarning(
note = note,
@@ -64,13 +65,13 @@ fun RenderNIP90ContentDiscoveryResponse(
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (noteEvent.hasHashtags()) {
DisplayUncitedHashtags(noteEvent, noteEvent.content, nav)
DisplayUncitedHashtags(noteEvent, noteEvent.content, callbackUri, nav)
}
}
@@ -97,6 +97,7 @@ fun RenderPoll(
)
} else {
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
val callbackUri = remember(note) { note.toNostrUri() }
SensitivityWarning(
note = note,
@@ -110,7 +111,7 @@ fun RenderPoll(
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -125,7 +126,7 @@ fun RenderPoll(
}
if (noteEvent.hasHashtags()) {
DisplayUncitedHashtags(noteEvent, eventContent, nav)
DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav)
}
}
}
@@ -101,6 +101,8 @@ fun RenderPrivateMessage(
overflow = TextOverflow.Ellipsis,
)
} else {
val callbackUri = remember(note) { note.toNostrUri() }
SensitivityWarning(
note = note,
accountViewModel = accountViewModel,
@@ -113,14 +115,14 @@ fun RenderPrivateMessage(
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (noteEvent.hasHashtags()) {
DisplayUncitedHashtags(noteEvent, eventContent, nav)
DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav)
}
}
}
@@ -128,6 +128,8 @@ fun RenderTextEvent(
overflow = TextOverflow.Ellipsis,
)
} else {
val callbackUri = remember(note) { note.toNostrUri() }
SensitivityWarning(
note = note,
accountViewModel = accountViewModel,
@@ -150,14 +152,14 @@ fun RenderTextEvent(
} else {
note.idHex
},
callbackUri = note.toNostrUri(),
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (noteEvent.hasHashtags()) {
DisplayUncitedHashtags(noteEvent, eventContent, nav)
DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, nav)
}
}
}
@@ -165,6 +165,8 @@ fun VideoDisplay(
}
summary?.let {
val callbackUri = remember(note) { note.toNostrUri() }
TranslatableRichTextViewer(
content = it,
canPreview = canPreview && !makeItShort,
@@ -173,7 +175,7 @@ fun VideoDisplay(
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -182,7 +184,7 @@ fun VideoDisplay(
Row(
Modifier.fillMaxWidth(),
) {
DisplayUncitedHashtags(event, summary, nav)
DisplayUncitedHashtags(event, summary, callbackUri, nav)
}
}
}
@@ -176,7 +176,7 @@ fun LoggedInPage(
)
}
},
contentResolver = { Amethyst.instance.contentResolver },
contentResolver = Amethyst.instance::contentResolverFn,
)
}
}
@@ -196,7 +196,7 @@ fun LoggedInPage(
)
}
},
contentResolver = { Amethyst.instance.contentResolver },
contentResolver = Amethyst.instance::contentResolverFn,
)
onDispose {
accountViewModel.account.signer.launcher
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
import com.vitorpamplona.amethyst.ui.note.CheckAndDisplayEditStatus
import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport
import com.vitorpamplona.amethyst.ui.note.DisplayDraft
import com.vitorpamplona.amethyst.ui.note.DisplayOtsIfInOriginal
@@ -95,7 +96,6 @@ import com.vitorpamplona.amethyst.ui.note.RenderRepost
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus
import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingCommunityInPost
import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPost
import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation
@@ -421,11 +421,7 @@ private fun FullBleedNoteCompose(
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
}
if (editState.value is GenericLoadable.Loaded) {
(editState.value as? GenericLoadable.Loaded<EditState>)?.loaded?.let {
DisplayEditStatus(it)
}
}
CheckAndDisplayEditStatus(editState)
TimeAgo(note = baseNote)
@@ -317,7 +317,7 @@ class AccountViewModel(
}.flowOn(Dispatchers.Default)
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
SharingStarted.WhileSubscribed(10000, 10000),
NoteComposeReportState(),
).also {
noteIsHiddenFlows.put(note, it)
@@ -334,7 +334,7 @@ class AccountViewModel(
.map { it.note.relays.size > 3 }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
SharingStarted.WhileSubscribed(10000, 10000),
note.relays.size > 3,
).also {
noteMustShowExpandButtonFlows.put(note, it)
@@ -673,8 +673,12 @@ class AccountViewModel(
hex: String,
url: String,
relay: String?,
blurhash: String?,
dim: String?,
hash: String?,
mimeType: String?,
) {
viewModelScope.launch(Dispatchers.IO) { account.addToGallery(hex, url, relay) }
viewModelScope.launch(Dispatchers.IO) { account.addToGallery(hex, url, relay, blurhash, dim, hash, mimeType) }
}
fun removefromMediaGallery(note: Note) {
@@ -725,6 +729,10 @@ class AccountViewModel(
}
}
fun delete(notes: List<Note>) {
viewModelScope.launch(Dispatchers.IO) { account.delete(notes) }
}
fun delete(note: Note) {
viewModelScope.launch(Dispatchers.IO) { account.delete(note) }
}
@@ -799,10 +807,10 @@ class AccountViewModel(
fun isFollowing(user: User?): Boolean {
if (user == null) return false
return account.userProfile().isFollowingCached(user)
return account.isFollowing(user)
}
fun isFollowing(user: HexKey): Boolean = account.userProfile().isFollowingCached(user)
fun isFollowing(user: HexKey): Boolean = account.isFollowing(user)
val hideDeleteRequestDialog: Boolean
get() = account.hideDeleteRequestDialog
@@ -22,22 +22,32 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteContainer
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.FeedState
@@ -48,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys.DRAFTS
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.maxWidthWithBackground
@Composable
fun DraftListScreen(
@@ -114,21 +125,76 @@ private fun DraftFeedLoaded(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = {
showDeleteDialog = false
},
title = {
Text(text = stringResource(R.string.drafts))
},
text = {
Text(text = stringResource(R.string.delete_all_drafts_confirmation))
},
confirmButton = {
TextButton(
onClick = {
accountViewModel.delete(state.feed.value)
showDeleteDialog = false
},
) {
Text(text = stringResource(R.string.yes))
}
},
dismissButton = {
TextButton(
onClick = {
showDeleteDialog = false
},
) {
Text(text = stringResource(R.string.no))
}
},
)
}
LazyColumn(
contentPadding = FeedPadding,
state = listState,
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
SwipeToDeleteContainer(
modifier = Modifier.fillMaxWidth().animateContentSize(),
onStartToEnd = { accountViewModel.delete(item) },
onEndToStart = { accountViewModel.delete(item) },
stickyHeader {
Row(
Modifier
.fillMaxWidth(),
Arrangement.Center,
) {
Row(Modifier.fillMaxWidth().animateItemPlacement().background(MaterialTheme.colorScheme.background)) {
ElevatedButton(
onClick = { showDeleteDialog = true },
) {
Text(stringResource(R.string.delete_all))
}
}
}
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
Row(
Modifier
.fillMaxWidth()
.animateItemPlacement(),
) {
SwipeToDeleteContainer(
modifier =
Modifier
.fillMaxWidth()
.animateContentSize(),
onStartToEnd = { accountViewModel.delete(item) },
onEndToStart = { accountViewModel.delete(item) },
) {
NoteCompose(
item,
routeForLastRead = routeForLastRead,
modifier = Modifier.fillMaxWidth(),
modifier = MaterialTheme.colorScheme.maxWidthWithBackground,
isBoostedNote = false,
isHiddenFeed = state.showHidden.value,
quotesLeft = 3,
@@ -188,7 +188,7 @@ fun GeoHashActionOptions(
.observeAsState()
val isFollowingTag by
remember(userState) {
derivedStateOf { userState?.user?.isFollowingGeohashCached(tag) ?: false }
derivedStateOf { userState?.user?.isFollowingGeohash(tag) ?: false }
}
if (isFollowingTag) {
@@ -167,7 +167,7 @@ fun HashtagActionOptions(
.observeAsState()
val isFollowingTag by
remember(userState) {
derivedStateOf { userState?.user?.isFollowingHashtagCached(tag) ?: false }
derivedStateOf { userState?.user?.isFollowingHashtag(tag) ?: false }
}
if (isFollowingTag) {
@@ -34,9 +34,12 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
@@ -487,10 +490,11 @@ private fun MainScaffold(
) {
Column(
modifier =
Modifier.padding(
top = it.calculateTopPadding(),
bottom = it.calculateBottomPadding(),
),
Modifier
.padding(it)
.consumeWindowInsets(it)
.systemBarsPadding()
.imePadding(),
) {
AppNavigation(
homeFeedViewModel = homeFeedViewModel,
@@ -45,18 +45,21 @@ import androidx.compose.ui.Alignment.Companion.BottomStart
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.GalleryContentView
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport
import com.vitorpamplona.amethyst.ui.note.ClickableNote
import com.vitorpamplona.amethyst.ui.note.LongPressToQuickActionGallery
@@ -79,7 +82,6 @@ import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
fun RenderGalleryFeed(
viewModel: FeedViewModel,
routeForLastRead: String?,
forceEventKind: Int?,
listState: LazyGridState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@@ -103,7 +105,6 @@ fun RenderGalleryFeed(
state,
routeForLastRead,
listState,
forceEventKind,
accountViewModel,
nav,
)
@@ -121,7 +122,6 @@ private fun GalleryFeedLoaded(
state: FeedState.Loaded,
routeForLastRead: String?,
listState: LazyGridState,
forceEventKind: Int?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -131,14 +131,18 @@ private fun GalleryFeedLoaded(
state = listState,
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() }
val defaultModifier =
remember {
Modifier
.fillMaxWidth()
.animateItemPlacement()
}
Row(defaultModifier) {
GalleryCardCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
modifier = Modifier,
forceEventKind = forceEventKind,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -157,7 +161,6 @@ fun GalleryCardCompose(
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
forceEventKind: Int?,
isHiddenFeed: Boolean = false,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@@ -172,20 +175,41 @@ fun GalleryCardCompose(
nav = nav,
) { canPreview ->
if (baseNote.associatedNote != null) {
if (baseNote.associatedNote!!.event != null) {
val image = (baseNote.associatedNote!!.event as ProfileGalleryEntryEvent).url()
if (image != null) {
GalleryCard(
galleryNote = baseNote.associatedNote!!,
baseNote = baseNote,
image = image,
modifier = modifier,
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
val galleryEvent = (baseNote.event as? ProfileGalleryEntryEvent) ?: return@CheckHiddenFeedWatchBlockAndReport
galleryEvent.url()?.let { image ->
val sourceEvent = galleryEvent.fromEvent()
if (sourceEvent != null) {
LoadNote(baseNoteHex = sourceEvent, accountViewModel = accountViewModel) { sourceNote ->
if (sourceNote != null) {
ClickableGalleryCard(
galleryNote = baseNote,
baseNote = sourceNote,
image = image,
modifier = modifier,
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
} else {
GalleryCard(
galleryNote = baseNote,
image = image,
modifier = modifier,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
} else {
GalleryCard(
galleryNote = baseNote,
image = image,
modifier = modifier,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@@ -193,7 +217,7 @@ fun GalleryCardCompose(
}
@Composable
fun GalleryCard(
fun ClickableGalleryCard(
galleryNote: Note,
baseNote: Note,
image: String,
@@ -204,44 +228,38 @@ fun GalleryCard(
) {
// baseNote.event?.let { Text(text = it.pubKey()) }
LongPressToQuickActionGallery(baseNote = galleryNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderChannelCard(
baseNote,
image,
modifier,
parentBackgroundColor,
accountViewModel,
showPopup,
nav,
)
val backgroundColor =
calculateBackgroundColor(
createdAt = baseNote.createdAt(),
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
)
ClickableNote(
baseNote = baseNote,
backgroundColor = backgroundColor,
modifier = modifier,
accountViewModel = accountViewModel,
showPopup = showPopup,
nav = nav,
) {
InnerGalleryCardBox(galleryNote, image, accountViewModel, nav)
}
}
}
@Composable
private fun CheckNewAndRenderChannelCard(
baseNote: Note,
fun GalleryCard(
galleryNote: Note,
image: String,
modifier: Modifier = Modifier,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
showPopup: () -> Unit,
nav: (String) -> Unit,
) {
val backgroundColor =
calculateBackgroundColor(
createdAt = baseNote.createdAt(),
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
)
ClickableNote(
baseNote = baseNote,
backgroundColor = backgroundColor,
modifier = modifier,
accountViewModel = accountViewModel,
showPopup = showPopup,
nav = nav,
) {
InnerGalleryCardBox(baseNote, image, accountViewModel, nav)
LongPressToQuickActionGallery(baseNote = galleryNote, accountViewModel = accountViewModel) { showPopup ->
Column(modifier = modifier) {
InnerGalleryCardBox(galleryNote, image, accountViewModel, nav)
}
}
}
@@ -267,7 +285,6 @@ data class GalleryThumb(
val id: String?,
val image: String?,
val title: String?,
// val price: Price?,
)
@Composable
@@ -287,7 +304,6 @@ fun RenderGalleryThumb(
image = image,
title = "",
// noteEvent?.title(),
// price = noteEvent?.price(),
)
}.distinctUntilChanged()
.observeAsState(
@@ -298,12 +314,14 @@ fun RenderGalleryThumb(
),
)
InnerRenderGalleryThumb(card as GalleryThumb, baseNote, accountViewModel)
InnerRenderGalleryThumb(card, baseNote, accountViewModel)
}
@Preview
@Composable
fun RenderGalleryThumbPreview(accountViewModel: AccountViewModel) {
fun RenderGalleryThumbPreview() {
val accountViewModel = mockAccountViewModel()
Surface(Modifier.size(200.dp)) {
InnerRenderGalleryThumb(
card =
@@ -311,7 +329,6 @@ fun RenderGalleryThumbPreview(accountViewModel: AccountViewModel) {
id = "",
image = null,
title = "Like New",
// price = Price("800000", "SATS", null),
),
note = Note("hex"),
accountViewModel = accountViewModel,
@@ -332,27 +349,45 @@ fun InnerRenderGalleryThumb(
contentAlignment = BottomStart,
) {
card.image?.let {
var blurHash = (note.event as ProfileGalleryEntryEvent).blurhash()
var description = (note.event as ProfileGalleryEntryEvent).content
// var hash = (note.event as ProfileGalleryEntryEvent).hash()
var dimensions = (note.event as ProfileGalleryEntryEvent).dimensions()
var mimeType = (note.event as ProfileGalleryEntryEvent).mimeType()
var content: BaseMediaContent? = null
if (isVideoUrl(it)) {
VideoView(
videoUri = it,
mimeType = null,
title = "",
authorName = note.author?.toBestDisplayName(),
roundedCorner = false,
gallery = true,
isFiniteHeight = false,
alwaysShowVideo = true,
accountViewModel = accountViewModel,
)
content =
MediaUrlVideo(
url = it,
description = description,
hash = null,
blurhash = blurHash,
dim = dimensions,
uri = null,
mimeType = mimeType,
)
} else {
AsyncImage(
model = it,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
content =
MediaUrlImage(
url = it,
description = description,
hash = null, // We don't want to show the hash banner here
blurhash = blurHash,
dim = dimensions,
uri = null,
mimeType = mimeType,
)
}
GalleryContentView(
content = content,
roundedCorner = false,
isFiniteHeight = false,
accountViewModel = accountViewModel,
)
}
// }
?: run { DisplayGalleryAuthorBanner(note) }
}
}
@@ -240,6 +240,16 @@ fun PrepareViewModels(
),
)
val galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserGalleryFeedViewModel",
factory =
NostrUserProfileGalleryFeedViewModel.Factory(
baseUser,
accountViewModel.account,
),
)
val followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserProfileFollowersUserFeedViewModel",
@@ -298,16 +308,6 @@ fun PrepareViewModels(
),
)
val galleryFeedViewModel: NostrUserProfileGalleryFeedViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserGalleryFeedViewModel",
factory =
NostrUserProfileGalleryFeedViewModel.Factory(
baseUser,
accountViewModel.account,
),
)
val reportsFeedViewModel: NostrUserProfileReportFeedViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel",
@@ -1574,7 +1574,6 @@ fun TabGallery(
RenderGalleryFeed(
feedViewModel,
null,
0,
listState,
accountViewModel = accountViewModel,
nav = nav,
@@ -198,7 +198,7 @@ fun LoginPage(
}
}
},
contentResolver = { Amethyst.instance.contentResolver },
contentResolver = Amethyst.instance::contentResolverFn,
)
onDispose { externalSignerLauncher.clearLauncher() }
}
@@ -65,8 +65,8 @@ private val DarkColorPalette =
primary = Purple200,
secondary = Teal200,
tertiary = Teal200,
background = Color(0xFF000000),
surface = Color(0xFF000000),
background = Color(red = 0, green = 0, blue = 0),
surface = Color(red = 0, green = 0, blue = 0),
surfaceVariant = Color(red = 29, green = 26, blue = 34),
)
@@ -189,6 +189,16 @@ val LightInnerPostBorderModifier =
.clip(shape = QuoteBorder)
.border(1.dp, LightSubtleBorder, QuoteBorder)
val DarkMaxWidthWithBackground =
Modifier
.fillMaxWidth()
.background(DarkColorPalette.background)
val LightMaxWidthWithBackground =
Modifier
.fillMaxWidth()
.background(LightColorPalette.background)
val DarkSelectedReactionBoxModifier =
Modifier
.padding(horizontal = 5.dp, vertical = 5.dp)
@@ -395,6 +405,9 @@ val ColorScheme.replyModifier: Modifier
val ColorScheme.innerPostModifier: Modifier
get() = if (isLight) LightInnerPostBorderModifier else DarkInnerPostBorderModifier
val ColorScheme.maxWidthWithBackground: Modifier
get() = if (isLight) LightMaxWidthWithBackground else DarkMaxWidthWithBackground
val ColorScheme.channelNotePictureModifier: Modifier
get() = if (isLight) LightChannelNotePictureModifier else DarkChannelNotePictureModifier
@@ -451,14 +464,13 @@ fun AmethystTheme(
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
val insetsController = WindowCompat.getInsetsController(window, view)
if (darkTheme) {
window.statusBarColor = colors.background.toArgb()
} else {
window.statusBarColor = colors.primary.toArgb()
}
val insets = WindowCompat.getInsetsController(window, view)
insets.isAppearanceLightNavigationBars = !darkTheme
insets.isAppearanceLightStatusBars = !darkTheme
window.statusBarColor = colors.background.toArgb()
window.navigationBarColor = colors.background.toArgb()
insetsController.isAppearanceLightNavigationBars = !darkTheme
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

@@ -801,4 +801,6 @@
<string name="http_status_508">Detekována smyčka - Server detekuje nekonečnou smyčku při zpracování požadavku</string>
<string name="http_status_511">Vyžadováno ověření sítě - Klient musí být autentizován, aby získal přístup k síti</string>
<string name="add_a_nip96_server">Přidat NIP-96 server</string>
<string name="delete_all">Vymazat vše</string>
<string name="delete_all_drafts_confirmation">Opravdu chcete odstranit všechny koncepty?</string>
</resources>
@@ -806,4 +806,6 @@ anz der Bedingungen ist erforderlich</string>
<string name="http_status_508">Schleife erkannt - Der Server erkennt eine Endlosschleife, während er die Anfrage bearbeitet</string>
<string name="http_status_511">Netzwerk-Authentifizierung erforderlich - Der Client muss authentifiziert werden, um auf das Netzwerk zuzugreifen</string>
<string name="add_a_nip96_server">NIP-96 Server hinzufügen</string>
<string name="delete_all">Alles löschen</string>
<string name="delete_all_drafts_confirmation">Möchten Sie wirklich alle Entwürfe löschen?</string>
</resources>
@@ -9,6 +9,7 @@
<string name="post_was_hidden">Esta publicación se ocultó porque menciona tus usuarios o palabras ocultas</string>
<string name="post_was_flagged_as_inappropriate_by">Post marcado como inapropiado por</string>
<string name="post_not_found">El evento se está cargando o no se puede encontrar en la lista de relés</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Imagen del canal</string>
<string name="referenced_event_not_found">Evento referenciado no encontrado</string>
<string name="could_not_decrypt_the_message">No se pudo desencriptar el mensaje</string>
@@ -132,6 +133,7 @@
<string name="conversations">Conversaciones</string>
<string name="notes">Notas</string>
<string name="replies">Respuestas</string>
<string name="gallery">Galería</string>
<string name="follows">"Siguiendo"</string>
<string name="reports">"Reportes"</string>
<string name="more_options">Más opciones</string>
@@ -255,6 +257,8 @@
<string name="quick_action_delete">Eliminar</string>
<string name="quick_action_unfollow">Dejar de seguir</string>
<string name="quick_action_follow">Seguir</string>
<string name="quick_action_request_deletion_gallery_title">Eliminar de la galería</string>
<string name="quick_action_request_deletion_gallery_alert_body">Elimina este contenido multimedia de la galería, aunque puedes volver a agregarlo luego.</string>
<string name="quick_action_request_deletion_alert_title">Solicitar eliminación</string>
<string name="quick_action_request_deletion_alert_body">Amethyst solicitará que se elimine su nota de los relays a los que está conectado actualmente. No hay garantía de que su nota se elimine permanentemente de esos relays, o de otros relays donde pueda almacenarse.</string>
<string name="quick_action_block_dialog_btn">Bloquear</string>
@@ -517,6 +521,7 @@
<string name="share_or_save">Compartir o guardar</string>
<string name="copy_url_to_clipboard">Copiar URL al portapapeles</string>
<string name="copy_the_note_id_to_the_clipboard">Copiar ID de la nota al portapapeles</string>
<string name="add_media_to_gallery">Agregar contenido multimedia a la galería</string>
<string name="created_at">Creado</string>
<string name="rules">Reglas</string>
<string name="login_with_external_signer">Iniciar sesión con Amber</string>
@@ -720,6 +725,8 @@
<string name="private_outbox_section_explainer">Inserta entre 1 y 3 relés para almacenar eventos que nadie más pueda ver, como los borradores o la configuración de la app. Lo ideal es que estos relés sean locales o requieran autenticación antes de descargar el contenido de cada usuario.</string>
<string name="kind_3_section">Relés generales</string>
<string name="kind_3_section_description">Amethyst usa estos relés para descargar publicaciones por ti.</string>
<string name="kind_3_recommended_section">Relés recomendados</string>
<string name="kind_3_recommended_section_description">Agrega los siguientes relés a la lista de relés generales para recibir publicaciones de los usuarios de la lista.</string>
<string name="search_section">Relés de búsqueda</string>
<string name="search_section_explainer">Lista de relés que se usarán al buscar contenido o usuarios. El etiquetado y la búsqueda no funcionarán si no hay opciones disponibles. Asegúrate de que implementen NIP-50.</string>
<string name="local_section">Relés locales</string>
@@ -9,6 +9,7 @@
<string name="post_was_hidden">Esta publicación se ocultó porque menciona tus usuarios o palabras ocultas</string>
<string name="post_was_flagged_as_inappropriate_by">La publicación fue reportada por</string>
<string name="post_not_found">El evento se está cargando o no se puede encontrar en la lista de relés</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Imagen del canal</string>
<string name="referenced_event_not_found">No se encontró el evento referenciado</string>
<string name="could_not_decrypt_the_message">No se pudo desencriptar el mensaje</string>
@@ -132,6 +133,7 @@
<string name="conversations">Conversaciones</string>
<string name="notes">Notas</string>
<string name="replies">Respuestas</string>
<string name="gallery">Galería</string>
<string name="follows">"Seguidos"</string>
<string name="reports">"Reportes"</string>
<string name="more_options">Más opciones</string>
@@ -255,6 +257,8 @@
<string name="quick_action_delete">Eliminar</string>
<string name="quick_action_unfollow">Dejar de seguir</string>
<string name="quick_action_follow">Seguir</string>
<string name="quick_action_request_deletion_gallery_title">Eliminar de la galería</string>
<string name="quick_action_request_deletion_gallery_alert_body">Elimina este contenido multimedia de la galería, aunque puedes volver a agregarlo luego.</string>
<string name="quick_action_request_deletion_alert_title">Solicitar eliminación</string>
<string name="quick_action_request_deletion_alert_body">Amethyst solicitará que se elimine la nota de los relés con los que tienes conexión actualmente. No hay garantía de que la nota se elimine permanentemente de esos relés o de otros donde pueda estar guardada.</string>
<string name="quick_action_block_dialog_btn">Bloquear</string>
@@ -517,6 +521,7 @@
<string name="share_or_save">Compartir o guardar</string>
<string name="copy_url_to_clipboard">Copiar URL al portapapeles</string>
<string name="copy_the_note_id_to_the_clipboard">Copiar ID de la nota al portapapeles</string>
<string name="add_media_to_gallery">Agregar contenido multimedia a la galería</string>
<string name="created_at">Creado</string>
<string name="rules">Reglas</string>
<string name="login_with_external_signer">Iniciar sesión con Amber</string>
@@ -720,6 +725,8 @@
<string name="private_outbox_section_explainer">Inserta entre 1 y 3 relés para almacenar eventos que nadie más pueda ver, como los borradores o la configuración de la app. Lo ideal es que estos relés sean locales o requieran autenticación antes de descargar el contenido de cada usuario.</string>
<string name="kind_3_section">Relés generales</string>
<string name="kind_3_section_description">Amethyst usa estos relés para descargar publicaciones por ti.</string>
<string name="kind_3_recommended_section">Relés recomendados</string>
<string name="kind_3_recommended_section_description">Agrega los siguientes relés a la lista de relés generales para recibir publicaciones de los usuarios de la lista.</string>
<string name="search_section">Relés de búsqueda</string>
<string name="search_section_explainer">Lista de relés que se usarán al buscar contenido o usuarios. El etiquetado y la búsqueda no funcionarán si no hay opciones disponibles. Asegúrate de que implementen NIP-50.</string>
<string name="local_section">Relés locales</string>
@@ -9,6 +9,7 @@
<string name="post_was_hidden">Esta publicación se ocultó porque menciona tus usuarios o palabras ocultas</string>
<string name="post_was_flagged_as_inappropriate_by">La publicación fue reportada por</string>
<string name="post_not_found">El evento se está cargando o no se puede encontrar en la lista de relés</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Imagen del canal</string>
<string name="referenced_event_not_found">No se encontró el evento referenciado</string>
<string name="could_not_decrypt_the_message">No se pudo desencriptar el mensaje</string>
@@ -132,6 +133,7 @@
<string name="conversations">Conversaciones</string>
<string name="notes">Notas</string>
<string name="replies">Respuestas</string>
<string name="gallery">Galería</string>
<string name="follows">"Seguidos"</string>
<string name="reports">"Reportes"</string>
<string name="more_options">Más opciones</string>
@@ -255,6 +257,8 @@
<string name="quick_action_delete">Eliminar</string>
<string name="quick_action_unfollow">Dejar de seguir</string>
<string name="quick_action_follow">Seguir</string>
<string name="quick_action_request_deletion_gallery_title">Eliminar de la galería</string>
<string name="quick_action_request_deletion_gallery_alert_body">Elimina este contenido multimedia de la galería, aunque puedes volver a agregarlo luego.</string>
<string name="quick_action_request_deletion_alert_title">Solicitar eliminación</string>
<string name="quick_action_request_deletion_alert_body">Amethyst solicitará que se elimine la nota de los relés con los que tienes conexión actualmente. No hay garantía de que la nota se elimine permanentemente de esos relés o de otros donde pueda estar guardada.</string>
<string name="quick_action_block_dialog_btn">Bloquear</string>
@@ -517,6 +521,7 @@
<string name="share_or_save">Compartir o guardar</string>
<string name="copy_url_to_clipboard">Copiar URL al portapapeles</string>
<string name="copy_the_note_id_to_the_clipboard">Copiar ID de la nota al portapapeles</string>
<string name="add_media_to_gallery">Agregar contenido multimedia a la galería</string>
<string name="created_at">Creado</string>
<string name="rules">Reglas</string>
<string name="login_with_external_signer">Iniciar sesión con Amber</string>
@@ -720,6 +725,8 @@
<string name="private_outbox_section_explainer">Inserta entre 1 y 3 relés para almacenar eventos que nadie más pueda ver, como los borradores o la configuración de la app. Lo ideal es que estos relés sean locales o requieran autenticación antes de descargar el contenido de cada usuario.</string>
<string name="kind_3_section">Relés generales</string>
<string name="kind_3_section_description">Amethyst usa estos relés para descargar publicaciones por ti.</string>
<string name="kind_3_recommended_section">Relés recomendados</string>
<string name="kind_3_recommended_section_description">Agrega los siguientes relés a la lista de relés generales para recibir publicaciones de los usuarios de la lista.</string>
<string name="search_section">Relés de búsqueda</string>
<string name="search_section_explainer">Lista de relés que se usarán al buscar contenido o usuarios. El etiquetado y la búsqueda no funcionarán si no hay opciones disponibles. Asegúrate de que implementen NIP-50.</string>
<string name="local_section">Relés locales</string>
@@ -803,4 +803,6 @@
<string name="http_status_508">Boucle détectée - Le serveur détecte une boucle infinie lors du traitement de la requête</string>
<string name="http_status_511">Authentification réseau requise - Le client doit être authentifié pour accéder au réseau</string>
<string name="add_a_nip96_server">Ajouter un serveur NIP-96</string>
<string name="delete_all">Tout supprimer</string>
<string name="delete_all_drafts_confirmation">Êtes-vous sûr de vouloir supprimer tous les brouillons ?</string>
</resources>
@@ -725,6 +725,8 @@
<string name="private_outbox_section_explainer">१ - ३ पुनःप्रसारकों को जोडें उन घटनाओं को रखने के लिए जो कोई अन्य नहीं देख सकता जैसे कि पाण्डुलिपियाँ अथवा क्रमक के स्थापना विकल्प। आदर्शतः ये पुनःप्रसारक स्थानीय होने चाहिए अथवा प्रत्येक उपयोगकर्ता के विषयवस्तु अवरोहण के पूर्व परिचय प्रमाणीकरण अनिवार्य होना चाहिए।</string>
<string name="kind_3_section">सामान्य पुनःप्रसारक</string>
<string name="kind_3_section_description">अमेथिस्ट इन पुनःप्रसारकों का प्रयोग करता है आपके पत्रों का अवरोहण करने के लिए।</string>
<string name="kind_3_recommended_section">पुनःशंसित पुनःप्रसारक</string>
<string name="kind_3_recommended_section_description">सूचित उपयोगकर्ताओं के पत्र प्राप्त करने के लिए निम्नलिखित पुनःप्रसारकों को अपने सामान्य पुनःप्रसारक सूची में जोडें।</string>
<string name="search_section">खोज पुनःप्रसारक</string>
<string name="search_section_explainer">पुनःप्रसारकों की सूची जिनको किसी विषयवस्तु अथवा उपयोगकर्ताओं को खोजने में प्रयोग करना चाहिए। कोई विकल्प नहीं तो उपयोगकर्ता सूचक जोडना तथा खोज निष्क्रिय होंगे। सुनिश्चित करें कि वे निप॰-५० को कार्यान्वित किये हैं।</string>
<string name="local_section">स्थानीय पुनःप्रसारक</string>
@@ -9,11 +9,13 @@
<string name="post_was_hidden">Ez a bejegyzés el lett rejtve, mert említ rejtett felhasználóidat vagy szavaidat</string>
<string name="post_was_flagged_as_inappropriate_by">A bejegyzést nem megfelelőként jelölte meg</string>
<string name="post_not_found">Bejegyzés nem található</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Csatorna Kép</string>
<string name="referenced_event_not_found">A hivatkozott esemény nem található</string>
<string name="could_not_decrypt_the_message">Az üzenet dekódolása sikertelen</string>
<string name="group_picture">Csoport Kép</string>
<string name="explicit_content">Szókimondó tartalom</string>
<string name="spam">Spam</string>
<string name="spam_description">Ebből a csomópontból érkező spam események száma</string>
<string name="impersonation">Megszemélyesítés</string>
<string name="illegal_behavior">Illegális viselkedés</string>
@@ -35,6 +37,7 @@
<string name="report_impersonation">Megszemélyesítés bejelentés</string>
<string name="report_explicit_content">Szókimondó tartalom bejelentés</string>
<string name="report_illegal_behaviour">Illegális viselkedés bejelentés</string>
<string name="report_malware">Vírus bejelentése</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Jelentkezzen be privát kulccsal, hogy válaszolni tudjon</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Jelentkezzen be privát kulccsal a bejegyzések megosztásához</string>
<string name="login_with_a_private_key_to_like_posts">Jelentkezzen be privát kulccsal a bejegyzések kedveléséhez</string>
@@ -52,6 +55,7 @@
<string name="edited_number">szerkesztés #%1$s</string>
<string name="original">eredeti</string>
<string name="quote">Idézet</string>
<string name="fork">Tükrözés</string>
<string name="propose_an_edit">Szerkesztési javaslat</string>
<string name="new_amount_in_sats">Új összeg sats-ban</string>
<string name="add">Hozzáad</string>
@@ -129,6 +133,7 @@
<string name="conversations">Beszélgetések</string>
<string name="notes">Bejegyzések</string>
<string name="replies">Válaszok</string>
<string name="gallery">Galéria</string>
<string name="follows">"Követek"</string>
<string name="reports">"Jelentések"</string>
<string name="more_options">Több beállítás</string>
@@ -251,6 +256,8 @@
<string name="quick_action_delete">Törlés</string>
<string name="quick_action_unfollow">Kikövetés</string>
<string name="quick_action_follow">Követem</string>
<string name="quick_action_request_deletion_gallery_title">Törlés a Galériából</string>
<string name="quick_action_request_deletion_gallery_alert_body">Távolítsa el ezt a médiát a galériából, később elolvashatod</string>
<string name="quick_action_request_deletion_alert_title">Törlés Kérése</string>
<string name="quick_action_request_deletion_alert_body">Az Amethyst kérni fogja, hogy töröljék a bejegyzésed azokról a csomópontokról, amelyekhez jelenleg csatlakozol. Nincs garancia arra, hogy a bejegyzésed ezekről a csomópontokról vagy más közvetítőkből (ahol tárolni lehet) véglegesen törlődik.</string>
<string name="quick_action_block_dialog_btn">Tiltás</string>
@@ -264,6 +271,7 @@
<string name="report_dialog_impersonation">Rosszindulatú megszemélyesítés</string>
<string name="report_dialog_nudity">Meztelenség vagy pornográf tartalom</string>
<string name="report_dialog_illegal">Illegális viselkedés</string>
<string name="report_dialog_malware">Vírus</string>
<string name="report_dialog_blocking_a_user">Az alkalmazásban egy felhasználó letiltása minden hozzá köthető tartalmat elrejt. Jegyzeteid továbbra is nyilvánosan megtekinthetők, beleértve a letiltott személyek számára is. A letiltott felhasználók a Biztonsági szűrők felületen jelennek meg.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Felhasználó Tiltása és Elrejtése]]></string>
<string name="report_dialog_report_btn">Visszaélés jelentése</string>
@@ -301,6 +309,7 @@
<string name="poll_zap_value_min">Minimum Zap</string>
<string name="poll_zap_value_max">Maximum Zap</string>
<string name="poll_consensus_threshold">Konszenzus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_time">Szavazás lezárása</string>
<string name="poll_closing_time_days">napok</string>
<string name="poll_unable_to_vote">Nem lehet szavazni</string>
@@ -335,6 +344,14 @@
<string name="zap_type_nonzap_explainer">Nostr-ban nyoma sincs, csak a Lightning-ben</string>
<string name="file_server">Fájl Szerver</string>
<string name="zap_forward_lnAddress">LnCím vagy @Felhasználó</string>
<string name="media_servers">Média Szerverek</string>
<string name="set_preferred_media_servers">Állítsd be a kívánt médiafeltöltő szervereket.</string>
<string name="no_media_server_message">Nincs beállítva egyéni médiaszerver. Használhatod az Amethyst listáját, vagy alább hozzáadhatsz egyet ↓</string>
<string name="built_in_media_servers_title">Beépített médiaszerverek</string>
<string name="built_in_servers_description">Az Amethist alapértelmezett listája. Hozzáadhatod őket egyenként, vagy hozzáadhatod az egész listát.</string>
<string name="use_default_servers">Az alapértelmezett lista használata</string>
<string name="add_media_server">Médiaszerver hozzáadása</string>
<string name="delete_media_server">Médiaszerver törlése</string>
<string name="upload_server_relays_nip95">Saját csomópontjaid (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel</string>
<string name="connect_via_tor_short">Tor/Orbot beállítása</string>
@@ -363,6 +380,7 @@
<string name="app_notification_dms_channel_description">Értesítést küld, amikor privát üzeneted érkezik</string>
<string name="app_notification_zaps_channel_name">Zap-et kaptál</string>
<string name="app_notification_zaps_channel_description">Értesítést küld, amikor valaki neked Zap-et küldött</string>
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">%1$s -tól</string>
<string name="app_notification_zaps_channel_message_for">%1$s -ért</string>
<string name="reply_notify">Értesítés: </string>
@@ -414,6 +432,7 @@
<string name="minimum_pow">Minimális PoW</string>
<string name="auth">Azonosítás</string>
<string name="payment">Fizetés</string>
<string name="cashu">Cashu Token</string>
<string name="cashu_redeem">Beváltás</string>
<string name="cashu_redeem_to_zap">Küldje a Zap tárcába</string>
<string name="cashu_redeem_to_cashu">Nyissa meg a Cashu tárcában</string>
@@ -442,6 +461,7 @@
<string name="settings">Beállítások</string>
<string name="connectivity_type_always">Mindig</string>
<string name="connectivity_type_wifi_only">Csak WIFI</string>
<string name="connectivity_type_unmetered_wifi_only">Korlátlan WiFi</string>
<string name="connectivity_type_never">Soha</string>
<string name="ui_feature_set_type_complete">Teljes</string>
<string name="ui_feature_set_type_simplified">Egyszerűsített</string>
@@ -500,6 +520,7 @@
<string name="share_or_save">Megosztás vagy Mentés</string>
<string name="copy_url_to_clipboard">Az URL vágólapra másolása</string>
<string name="copy_the_note_id_to_the_clipboard">A bejegyzésazonosító vágólapra másolása</string>
<string name="add_media_to_gallery">Média hozzáadása a galériához</string>
<string name="created_at">Létrehozva</string>
<string name="rules">Szabályok</string>
<string name="login_with_external_signer">Bejelentkezés Amber-el</string>
@@ -530,6 +551,7 @@
<string name="missing_lud16">Hiányzó lightning beállítás</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">A %1$s felhasználó a sat fogadáshoz nem rendelkezik LN cím beállítással</string>
<string name="zap_split_weight">Százalék</string>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">Zap-ek megosztása</string>
<string name="forwarding_zaps_to">Zap-ek továbbítása</string>
<string name="lightning_wallets_not_found2">Lightning tárca nem található</string>
@@ -596,9 +618,11 @@
<string name="hi_there_is_this_still_available">Szia, ez még aktuális?</string>
<string name="classifieds">Elem eladása</string>
<string name="classifieds_title">Cím</string>
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="classifieds_condition">Állapot</string>
<string name="classifieds_category">Kategória</string>
<string name="classifieds_price">Ár (satoshiban)</string>
<string name="classifieds_price_placeholder">1000</string>
<string name="classifieds_location">Helyszín</string>
<string name="classifieds_location_placeholder">Város, állam, ország</string>
<string name="classifieds_condition_new">Új</string>
@@ -617,6 +641,7 @@
<string name="classifieds_category_books">Könyvek</string>
<string name="classifieds_category_pets">Háziállatok</string>
<string name="classifieds_category_sports">Sport</string>
<string name="classifieds_category_fitness">Fitnesz</string>
<string name="classifieds_category_art">Művészet</string>
<string name="classifieds_category_crafts">Iparművészet</string>
<string name="classifieds_category_home">Otthon</string>
@@ -631,6 +656,8 @@
<string name="server_did_not_provide_a_url_after_uploading">A szerver a feltöltés után nem adott URL-t</string>
<string name="could_not_download_from_the_server">Nem sikerült a szerverről a feltöltött médiát letölteni</string>
<string name="could_not_prepare_local_file_to_upload">Nem sikerült előkészíteni a helyi fájlt a feltöltésre: %1$s</string>
<string name="failed_to_upload_with_message">Nem sikerült feltölteni: %1$s</string>
<string name="failed_to_delete_with_message">Nem sikerült törölni: %1$s</string>
<string name="edit_draft">Piszkozat szerkesztése</string>
<string name="login_with_qr_code">Bejelentkezés QR kóddal</string>
<string name="route">Útvonal</string>
@@ -649,6 +676,9 @@
<string name="close_all_reactions_to_this_post">Zárja be a bejegyzésre adott összes reakciót</string>
<string name="reply_description">Válasz</string>
<string name="boost_or_quote_description">Megosztás vagy Idézés</string>
<string name="like_description">Like</string>
<string name="zap_description">Zap</string>
<string name="change_reaction">Változtasd meg a gyors reakciókat</string>
<string name="profile_image_of_user">%1$s profilképe</string>
<string name="relay_info">%1$s csomópont</string>
<string name="expand_relay_list">Csomópont lista kibontása</string>
@@ -694,6 +724,8 @@
<string name="private_outbox_section_explainer">Adj hozzá 13 csomópontot, hogy olyan eseményeket tároljon, amelyeket senki más nem láthat, például a piszkozataidat és/vagy az alkalmazásbeállításaidat. Ideális esetben ezek a csomópontok vagy helyiek, vagy az egyes felhasználók tartalmának letöltése előtt hitelesítést igényelnek.</string>
<string name="kind_3_section">Általános Csomópontok</string>
<string name="kind_3_section_description">Az Amethyst ezeket a csomópontokat a bejegyzések letöltésére használja.</string>
<string name="kind_3_recommended_section">Ajánlott csomópontok</string>
<string name="kind_3_recommended_section_description">Add hozzá a következő csomópontokat az Általános Csomópontok listájához, hogy a felsorolt felhasználóktól üzeneteket kapj.</string>
<string name="search_section">Kereső Csomópontok</string>
<string name="search_section_explainer">A tartalom vagy a felhasználók kereséséhez használt csomópontok listája. A címkézés és a keresés nem működik, ha itt nincsenek megadott lehetőségek. Győződj meg arról, hogy alkalmazzák a NIP-50-et.</string>
<string name="local_section">Helyi Csomópontok</string>
@@ -705,10 +737,14 @@
<string name="this_version_brought_to_you_by">Ezt a verziót hozta neked:</string>
<string name="version_name">%1$s verzió</string>
<string name="thank_you">Köszönöm!</string>
<string name="max_limit">Max Limit</string>
<string name="restricted_writes">Korlátozott írások</string>
<string name="forked_from">Fork-olva innen</string>
<string name="forked_tag">FORK</string>
<string name="git_repository">Git-tár: %1$s</string>
<string name="git_web_address">Web:</string>
<string name="git_clone_address">Klónoz:</string>
<string name="existed_since">OTS: %1$s</string>
<string name="ots_info_title">Időbélyeg igazolás</string>
<string name="ots_info_description">Bizonyíték van arra, hogy ezt a bejegyzést valamikor %1$s előtt írták alá. A bizonyítékot ezen a napon és időpontban a Bitcoin blokkláncába bélyegezték.</string>
<string name="edit_post">Bejegyzés szerkesztése</string>
@@ -736,4 +772,36 @@
<string name="dvm_requesting_job">Feladatot kér a DVM-től</string>
<string name="nwc_payment_request">Fizetési kérelem elküldve, megerősítésére vár a tárcádtól</string>
<string name="dvm_waiting_to_confirm_payment">Várakozás a DVM-re a fizetés megerősítésére vagy az eredmények elküldésére</string>
<string name="http_status_400">Hibás kérés A szerver nem tudja vagy nem akarja a kérést feldolgozni.</string>
<string name="http_status_401">Jogosulatlan A felhasználó nem rendelkezik érvényes hitelesítési adatokkal</string>
<string name="http_status_402">Fizetés szükséges A szerver fizetséget kér a kérés teljesítéséhez</string>
<string name="http_status_403">Tiltott A felhasználónak nincs hozzáférési joga a kérelem benyújtásához</string>
<string name="http_status_404">Nem található A szerver a kért címet nem találja</string>
<string name="http_status_405">Módszer Nem Engedélyezett A kiszolgáló támogatja a kérési módszert, de a célerőforrás nem</string>
<string name="http_status_406">Nem elfogadható A szerver nem talál olyan tartalmat, amely a kérést kielégítené.</string>
<string name="http_status_407">Proxy hitelesítés szükséges A felhasználó nem rendelkezik érvényes hitelesítési adatokkal</string>
<string name="http_status_408">Kérelem időtúllépése A szerver időtúllépés miatt valaki másra vár</string>
<string name="http_status_409">Ütközés A szerver nem tudja teljesíteni a kérést, mert az erőforrással konfliktus van</string>
<string name="http_status_410">Eltűnt A kért tartalmat a szerverről véglegesen törölték, és nem lesz visszaállítva</string>
<string name="http_status_411">Kötelező hossz A szerver elutasítja a kérést, mert nincs kellően definiálva</string>
<string name="http_status_412">Előfeltétel nem sikerült A fejlécben lévő kérés előfeltételeit, a szerver nem teljesíti</string>
<string name="http_status_413">A kérés túl nagy A kérés nagyobb, mint a szerver által meghatározott korlátok, és a szerver nem hajlandó feldolgozni</string>
<string name="http_status_414">Túl hosszú URI Az ügyfél által kért URL túl hosszú ahhoz, hogy a szerver feldolgozza. </string>
<string name="http_status_415">Nem támogatott médiatípus A kérés olyan médiaformátumot használ, amelyet a szerver nem támogat</string>
<string name="http_status_416">Tartomány nem teljesíthető A szerver nem tudja teljesíteni a kérés Tartomány fejlécében jelzett értéket. </string>
<string name="http_status_417">Az elvárás nem sikerült A szerver nem tudja teljesíteni a Várható kérés fejlécében jelzett követelményeket</string>
<string name="http_status_426">Frissítés szükséges A szerver megtagadja a kérés feldolgozását az aktuális protokoll használatával, hacsak az ügyfél nem frissít egy másik protokollra.</string>
<string name="http_status_500">Belső szerverhiba A szerver váratlan hibát észlelt, és nem tudja a kérést teljesíteni</string>
<string name="http_status_501">Nincs végrehajtva A szerver nem tudja a kérést teljesíteni, vagy nem ismeri fel a kérés módszerét</string>
<string name="http_status_502">Rossz átjáró A szerver átjáróként működik, és érvénytelen választ kapott a gazdagéptől</string>
<string name="http_status_503">A szolgáltatás nem elérhető Ez gyakran akkor fordul elő, ha a kiszolgáló túlterhelt vagy karbantartás miatt leáll</string>
<string name="http_status_504">Átjáró Időtúllépés A szerver átjáróként vagy proxyként működött, és a válaszra várva időtúllépés történt</string>
<string name="http_status_505">A HTTP-verzió nem támogatott A szerver a kérésben szereplő HTTP-verziót nem támogatja</string>
<string name="http_status_506">Váltózó porbléma A kiszolgálón belső konfigurációs hiba lépett fel</string>
<string name="http_status_507">Elégtelen tárhely A szervernek nincs elég tárhelye a kérés sikeres feldolgozásához</string>
<string name="http_status_508">Hurok észlelve A szerver a kérés feldolgozása közben végtelen hurkot észlel</string>
<string name="http_status_511">Hálózati hitelesítés szükséges A klienst a hálózat eléréséhez hitelesíteni kell</string>
<string name="add_a_nip96_server">Adj hozzá egy NIP-96 szervert</string>
<string name="delete_all">Törlés mind</string>
<string name="delete_all_drafts_confirmation">Biztosan törölni szeretnéd az összes jegyzetet?</string>
</resources>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Amethyst" parent="Theme.AppCompat.Light.NoActionBar">
<style name="Theme.Amethyst" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item>
<item name="android:windowBackground">@color/black</item>
<item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>
@@ -9,6 +9,7 @@
<string name="post_was_hidden">Dit bericht is verborgen omdat het je verborgen gebruikers of woorden noemt</string>
<string name="post_was_flagged_as_inappropriate_by">Bericht gemarkeerd als ongepast door</string>
<string name="post_not_found">Bericht niet gevonden</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Kanaalafbeelding</string>
<string name="referenced_event_not_found">Verwezen event niet gevonden</string>
<string name="could_not_decrypt_the_message">Note versleuteld met encryptie</string>
@@ -36,6 +37,7 @@
<string name="report_impersonation">Meld imitatie</string>
<string name="report_explicit_content">Rapporteer expliciete inhoud</string>
<string name="report_illegal_behaviour">Rapporteer illegaal gedrag</string>
<string name="report_malware">Rapporteer malware</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Je gebruikt een publieke sleutel en publieke sleutels zijn alleen-lezen. Login met een geheime sleutel om te reageren</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Je gebruikt een publieke sleutel en publieke sleutels zijn alleen-lezen. Login met een geheime sleutel om berichten te boosten</string>
<string name="login_with_a_private_key_to_like_posts">Je gebruikt een publieke sleutel en publieke sleutels zijn alleen-lezen. Login met een geheime sleutel om berichten te liken</string>
@@ -131,6 +133,7 @@
<string name="conversations">Gesprekken</string>
<string name="notes">Notes</string>
<string name="replies">Reacties</string>
<string name="gallery">Galerij</string>
<string name="follows">"Volgend"</string>
<string name="reports">"Rapporten"</string>
<string name="more_options">Meer opties</string>
@@ -254,6 +257,8 @@
<string name="quick_action_delete">Verwijderen</string>
<string name="quick_action_unfollow">Ontvolgen</string>
<string name="quick_action_follow">Volgen</string>
<string name="quick_action_request_deletion_gallery_title">Verwijderen uit galerij</string>
<string name="quick_action_request_deletion_gallery_alert_body">Verwijder deze media uit je galerij, je kunt het later lezen</string>
<string name="quick_action_request_deletion_alert_title">Verzoek om te verwijderen</string>
<string name="quick_action_request_deletion_alert_body">Amethyst zal vragen om uw note te verwijderen van de relays waarmee u momenteel verbonden bent. Er is geen garantie dat uw note permanent wordt verwijderd van deze relays, of van andere relays waar het kan worden opgeslagen.</string>
<string name="quick_action_block_dialog_btn">Blokkeren</string>
@@ -267,6 +272,7 @@
<string name="report_dialog_impersonation">Kwaadwillige imitatie</string>
<string name="report_dialog_nudity">Naaktheid of gevoelige inhoud</string>
<string name="report_dialog_illegal">Illegaal gedrag</string>
<string name="report_dialog_malware">Malware</string>
<string name="report_dialog_blocking_a_user">Als u een gebruiker blokkeert, wordt zijn inhoud in uw app verborgen. Uw notes zijn nog steeds publiekelijk zichtbaar, ook voor mensen die u blokkeert. Geblokkeerde gebruikers worden vermeld in het scherm Beveiligingsfilters.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Blokkeer & verberg gebruiker]]></string>
<string name="report_dialog_report_btn">Misbruik melden</string>
@@ -339,6 +345,14 @@
<string name="zap_type_nonzap_explainer">Geen spoor binnen Nostr, alleen in Lightning</string>
<string name="file_server">File server</string>
<string name="zap_forward_lnAddress">LnAddress of @User</string>
<string name="media_servers">Mediaservers</string>
<string name="set_preferred_media_servers">Stel uw gewenste media upload servers in.</string>
<string name="no_media_server_message">Je hebt geen aangepaste mediaservers ingesteld. Je kunt de lijst van Amethyst gebruiken of er hieronder een toevoegen ↓</string>
<string name="built_in_media_servers_title">Ingebouwde mediaservers</string>
<string name="built_in_servers_description">Amethyst\'s standaardlijst. U kunt ze individueel toevoegen of de lijst toevoegen.</string>
<string name="use_default_servers">Gebruik standaard lijst</string>
<string name="add_media_server">Mediaserver toevoegen</string>
<string name="delete_media_server">Mediaserver verwijderen</string>
<string name="upload_server_relays_nip95">Uw relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Bestanden worden geüpload naar en gehost door relays. Ze zijn vrij van een vaste url (afhankelijkheid van derden). Zorg ervoor dat u een NIP-95 relay in uw lijst met relays hebt.</string>
<string name="connect_via_tor_short">Orbot/Tor setup</string>
@@ -507,6 +521,7 @@
<string name="share_or_save">Delen of opslaan</string>
<string name="copy_url_to_clipboard">Kopieer URL naar klembord</string>
<string name="copy_the_note_id_to_the_clipboard">Note naar klembord kopiëren</string>
<string name="add_media_to_gallery">Media toevoegen aan galerij</string>
<string name="created_at">Gemaakt op</string>
<string name="rules">Regels</string>
<string name="login_with_external_signer">Login met Amber</string>
@@ -710,6 +725,8 @@
<string name="private_outbox_section_explainer">Plaats tussen de 1 tot 3 relays om gebeurtenissen op te slaan die niemand anders kan zien, zoals uw Concepten en/of app instellingen. Idealiter zijn deze relays lokaal of vereist authenticatie voordat de inhoud van elke gebruiker wordt gedownload.</string>
<string name="kind_3_section">Algemene relays</string>
<string name="kind_3_section_description">Amethyst gebruikt deze relays om berichten voor jou te downloaden.</string>
<string name="kind_3_recommended_section">Aanbevolen relays</string>
<string name="kind_3_recommended_section_description">Voeg de volgende relays toe aan je algemene relay-lijst om berichten van de vermelde gebruikers te ontvangen.</string>
<string name="search_section">Relays zoeken</string>
<string name="search_section_explainer">Lijst van relays om te gebruiken bij het zoeken naar inhoud of gebruikers. Taggen en zoeken werkt niet als er geen opties beschikbaar zijn. Zorg ervoor dat ze NIP-50 implementeren.</string>
<string name="local_section">Lokale relays</string>
@@ -785,4 +802,7 @@
<string name="http_status_507">Onvoldoende opslagruimte - De server heeft niet genoeg opslagruimte om het verzoek succesvol te verwerken</string>
<string name="http_status_508">Loop gedetecteerd - De server detecteert een oneindige loop tijdens het verwerken van het verzoek</string>
<string name="http_status_511">Netwerk authenticatie vereist - De client moet geauthenticeerd zijn om toegang te krijgen tot het netwerk</string>
<string name="add_a_nip96_server">Voeg een NIP-96 Server toe</string>
<string name="delete_all">Alles verwijderen</string>
<string name="delete_all_drafts_confirmation">Weet u zeker dat u alle concepten wilt verwijderen?</string>
</resources>
@@ -20,7 +20,7 @@
<string name="impersonation">Podszywanie się</string>
<string name="illegal_behavior">Antyspołeczne zachowanie</string>
<string name="other">Inne</string>
<string name="unknown">Nieznany</string>
<string name="unknown">Nieznani</string>
<string name="relay_icon">Ikona transmitera</string>
<string name="unknown_author">Autor nieznany</string>
<string name="copy_text">Skopiuj tekst</string>
@@ -126,7 +126,7 @@
<string name="copy_channel_id_note_to_the_clipboard">Kopiuj ID kanału (wpisu) do schowka</string>
<string name="edits_the_channel_metadata">Edytuje metadane kanału</string>
<string name="join">Dołącz</string>
<string name="known">Popularne</string>
<string name="known">Znajomi</string>
<string name="new_requests">Nowe Prośby</string>
<string name="blocked_users">Zablokowani użytkownicy</string>
<string name="new_threads">Nowe Wpisy</string>
@@ -725,6 +725,8 @@
<string name="private_outbox_section_explainer">Wstaw od 1 do 3 transmiterów przechowujących dane zdarzeń, których nikt inny nie widzi, takich jak Wersje robocze i/lub ustawienia aplikacji. Idealnie byłoby, gdyby te transmitery były lokalne lub wymagały uwierzytelnienia przed pobraniem treści każdego użytkownika.</string>
<string name="kind_3_section">Transmitery ogólne</string>
<string name="kind_3_section_description">Amethyst używa tych przekaźników, aby pobrać dla Ciebie posty.</string>
<string name="kind_3_recommended_section">Polecane Transmitery</string>
<string name="kind_3_recommended_section_description">Dodaj te transmitery do głównej listy, aby otrzymywać wiadomości od wymienionych użytkowników.</string>
<string name="search_section">Transmitery wyszukujące</string>
<string name="search_section_explainer">Lista transmiterów używanych podczas wyszukiwania treści lub użytkowników. Tagowanie i wyszukiwanie nie będą działać, jeśli nie będą dostępne żadne opcje. Upewnij się, że zaimplementowano NIP-50.</string>
<string name="local_section">Lokalne Transmitery</string>
@@ -786,6 +788,7 @@
<string name="http_status_413">Payload Zbyt duży - Żądanie jest większe niż określone limity serwera i serwer odmawia jego przetwarzania</string>
<string name="http_status_414">URI Zbyt długi - URL klienta jest zbyt długi, aby serwer mógł go przetworzyć. </string>
<string name="http_status_415">Nieobsługiwany format plików - żądanie używa formatu multimediów, którego serwer nie obsługuje</string>
<string name="http_status_417">Błąd oczekiwania-serwer nie może spełnić wymagań wskazanych w polu nagłówka żądania</string>
<string name="http_status_426">Wymagana aktualizacja - serwer odmawia przetwarzania żądania przy użyciu bieżącego protokołu, chyba że klient uaktualni go do innego protokołu.</string>
<string name="http_status_500">Wewnętrzny błąd serwera - serwer napotkał nieoczekiwany błąd i nie może zakończyć żądania</string>
<string name="http_status_501">Nie zaimplementowano - serwer nie może spełnić żądania lub nie rozpoznaje metody żądania</string>
@@ -801,4 +801,6 @@
<string name="http_status_508">Loop detectado - O servidor detecta um loop infinito enquanto processa a requisição</string>
<string name="http_status_511">Autenticação de rede necessária - O cliente deve ser autenticado para acessar a rede</string>
<string name="add_a_nip96_server">Adicionar um servidor NIP-96</string>
<string name="delete_all">Apagar tudo</string>
<string name="delete_all_drafts_confirmation">Você tem certeza de que deseja excluir todos os rascunhos?</string>
</resources>
@@ -800,4 +800,6 @@
<string name="http_status_508">Loop upptäckt - Servern upptäcker en oändlig loop medan den bearbetar begäran</string>
<string name="http_status_511">Nätverksautentisering krävs - Klienten måste autentiseras för att få tillgång till nätverket</string>
<string name="add_a_nip96_server">Lägg till en NIP-96 Server</string>
<string name="delete_all">Radera alla</string>
<string name="delete_all_drafts_confirmation">Är du säker på att du vill ta bort alla utkast?</string>
</resources>
@@ -722,6 +722,8 @@
<string name="private_outbox_section_explainer">ใส่รีเลย์ 1-3 ตัวเพื่อเก็บเหตุการณ์ที่ไม่มีใครสามารถเห็นได้ เช่น ร่างโพสต์ และการตั้งค่าแอปของคุณ รีเลย์เหล่านี้ควรเป็นรีเลย์ภายในเครื่องหรือรีเลย์ที่ต้องมีการยืนยันตัวตนก่อนที่จะดาวน์โหลดเนื้อหาของผู้ใช้แต่ละคน</string>
<string name="kind_3_section">รีเลย์ทั่วไป</string>
<string name="kind_3_section_description">Amethyst ใช้รีเลย์เหล่านี้เพื่อดาวน์โหลดโพสต์สำหรับคุณ</string>
<string name="kind_3_recommended_section">รีเลย์ที่แนะนำ</string>
<string name="kind_3_recommended_section_description">เพิ่มรีเลย์เข้ารายการรีเลย์ทั่วไปเพื่อให้เห็นโพสต์จากผู้ใช้อื่น ๆ มากขึ้น</string>
<string name="search_section">ค้นหารีเลย์</string>
<string name="search_section_explainer">รายการรีเลย์ที่จะใช้เมื่อค้นหาข้อมูลหรือผู้ใช้ การแท็กและการค้นหาจะไม่ทำงานหากไม่มีตัวเลือก ตรวจสอบให้แน่ใจว่ารีเลย์เหล่านี้รองรับ NIP-50</string>
<string name="local_section">รีเลย์ในพื้นที่ของคุณ</string>
@@ -798,4 +800,6 @@
<string name="http_status_508">พบลูป - เซิร์ฟเวอร์ตรวจพบการวนลูปไม่สิ้นสุดขณะประมวลผลคำขอ</string>
<string name="http_status_511">ต้องการการตรวจสอบเครือข่าย - ไคลเอนต์ต้องได้รับการตรวจสอบเพื่อเข้าถึงเครือข่าย</string>
<string name="add_a_nip96_server">เพิ่ม เซิร์ฟเวอร์ NIP-96</string>
<string name="delete_all">ลบทั้งหมด</string>
<string name="delete_all_drafts_confirmation">คุณแน่ใจหรือไม่ว่าต้องการลบแบบร่างทั้งหมด</string>
</resources>
@@ -726,6 +726,7 @@
<string name="kind_3_section">通用中继</string>
<string name="kind_3_section_description">Amethyst 会通过这些中继为您获取帖子。</string>
<string name="kind_3_recommended_section">推荐的中继器</string>
<string name="kind_3_recommended_section_description">在通用中继列表中添加下列中继以接收列表中用户的帖子。</string>
<string name="search_section">搜索中继</string>
<string name="search_section_explainer">用于关键词和标签检索的中继列表。如果没有可用选项,也就无法使用关键词和标签检索。需要确保它们支持 NIP-50。</string>
<string name="local_section">本地中继</string>
@@ -802,4 +803,6 @@
<string name="http_status_508">Loop Detected - 服务器处理该请求时检测到死循环。</string>
<string name="http_status_511">Network Authentication Required - 客户端必须通过认证才能访问该网络。</string>
<string name="add_a_nip96_server">添加 NIP-96 服务器</string>
<string name="delete_all">删除所有</string>
<string name="delete_all_drafts_confirmation">确定要删除所有草稿吗?</string>
</resources>
+2
View File
@@ -949,4 +949,6 @@
<string name="http_status_511">Network Authentication Required - The client must be authenticated to access the network</string>
<string name="add_a_nip96_server">Add a NIP-96 Server</string>
<string name="delete_all">Delete all</string>
<string name="delete_all_drafts_confirmation">Are you sure you want to delete all drafts?</string>
</resources>
+45
View File
@@ -0,0 +1,45 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# preserve the line number information for debugging stack traces.
-dontobfuscate
-keepattributes LocalVariableTable
-keepattributes LocalVariableTypeTable
-keepattributes *Annotation*
-keepattributes SourceFile
-keepattributes LineNumberTable
-keepattributes Signature
-keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes EnclosingMethod
-keepattributes MethodParameters
-keepparameternames
-keepdirectories libs
# Keep all names
-keepnames class ** { *; }
# Keep All enums
-keep enum ** { *; }
-keep class com.vitorpamplona.ammolite.service.** { *; }
-keep class com.vitorpamplona.ammolite.relays.** { *; }
+21
View File
@@ -19,6 +19,27 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# preserve the line number information for debugging stack traces.
-dontobfuscate
-keepattributes LocalVariableTable
-keepattributes LocalVariableTypeTable
-keepattributes *Annotation*
-keepattributes SourceFile
-keepattributes LineNumberTable
-keepattributes Signature
-keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes EnclosingMethod
-keepattributes MethodParameters
-keepparameternames
-keepdirectories libs
# Keep all names
-keepnames class ** { *; }
# Keep All enums
-keep enum ** { *; }
-keep class com.vitorpamplona.ammolite.service.** { *; }
-keep class com.vitorpamplona.ammolite.relays.** { *; }

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