mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
31 Commits
claude/nos
...
35e61e1d98
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35e61e1d98 | ||
|
|
6730100853 | ||
|
|
69671d0009 | ||
|
|
078758888a | ||
|
|
387bfe99ee | ||
|
|
4df7354ffc | ||
|
|
a11de43400 | ||
|
|
0a228a935b | ||
|
|
9ddf571b11 | ||
|
|
1fee674350 | ||
|
|
50025660a3 | ||
|
|
f67d242f7a | ||
|
|
6033957b1c | ||
|
|
37923d9101 | ||
|
|
a960ceb6a8 | ||
|
|
3fdb976aa9 | ||
|
|
a6b40cfc0f | ||
|
|
b616f69c1d | ||
|
|
57ffb3386d | ||
|
|
a3e239d33f | ||
|
|
b6bf3b8a70 | ||
|
|
41240eeab9 | ||
|
|
5988db010d | ||
|
|
73a58e042d | ||
|
|
e3dae26483 | ||
|
|
86c8d0b12c | ||
|
|
db0ff71432 | ||
|
|
72e27a3530 | ||
|
|
556ef9f880 | ||
|
|
52c863a9c6 | ||
|
|
5e5ecdab8b |
@@ -302,6 +302,21 @@ Do this before considering the task complete.
|
||||
`forEach`/`map`/`filter`/`any` — the `fast*` variants allocate no iterator,
|
||||
no intermediate list, and no lambda object. Don't "modernize" those into
|
||||
stdlib collection calls; match the surrounding hot-path style.
|
||||
- **Never put raw invisible/bidirectional Unicode characters in source files**
|
||||
— write them as `\uXXXX` escapes instead (`'\u202E'`, `Regex("[\u200B-\u200D\uFEFF]")`).
|
||||
This covers the bidi family Sonar's Trojan-Source rule (CVE-2021-42574)
|
||||
flags — U+202A–U+202E, the isolates U+2066–U+2069, U+200E/U+200F, U+061C —
|
||||
plus zero-width characters (U+200B–U+200D, U+FEFF, U+2060). The escape
|
||||
compiles to the identical codepoint, so behaviour is unchanged; the point is
|
||||
that the file on disk stays visually unambiguous. Applies even when the
|
||||
character is *intentional* (sanitizer strip-lists, adversarial test
|
||||
payloads) — that's data, and escapes express it just as well. Exceptions:
|
||||
U+200D as part of a real emoji ZWJ sequence in test data (👩👧 — functional,
|
||||
not a bidi control), and LRM/RLM inside Crowdin-managed `strings.xml`
|
||||
translations (legitimate RTL typography; don't touch those files by hand
|
||||
anyway). Note the tooling trap: the Edit tool may normalise a typed
|
||||
`\uXXXX` back into the raw character — if that happens, do the replacement
|
||||
at byte level (`perl -CSD -pe 's/\x{202E}/\\u202E/g'`).
|
||||
|
||||
### Navigation Shell
|
||||
- **Desktop**: Sidebar + main content area
|
||||
|
||||
@@ -332,6 +332,35 @@ When adding translated strings to locale files:
|
||||
|
||||
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
|
||||
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
|
||||
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because Crowdin strips keys asymmetrically (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
|
||||
|
||||
```bash
|
||||
# For each locale, insert only the keys comm -23 reports missing FOR THAT LOCALE.
|
||||
for l in cs de-rDE sv-rSE pt-rBR; do
|
||||
missing=$(comm -23 \
|
||||
<(grep '<string name=' $base/values/strings.xml | grep -v 'translatable="false"' \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||||
<(grep '<string name=' $base/values-$l/strings.xml \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
|
||||
# ... append ONLY the $missing keys' translations to values-$l/strings.xml ...
|
||||
done
|
||||
```
|
||||
|
||||
(This bit us on 2026-07-21: `ps1_save_block`, `podcast_value_for_value`, and `chats_history_relays` were each missing in only *some* commons locales, but the same 3-key block was pasted into all four — producing duplicates in the locales that already had them.)
|
||||
|
||||
- **After inserting, verify each edited file has no duplicate keys AND is well-formed XML — before you call the task done.** A duplicate key is not a warning: the `commons` tree's Compose-resources build task fails hard on it (`convertXmlValueResourcesForCommonMain: … Duplicated key '…'`), which breaks the build for everyone. Quick post-insertion gate over every file you touched:
|
||||
|
||||
```bash
|
||||
for f in <every edited strings.xml>; do
|
||||
dups=$(grep -oE '<(string|plurals) name="[^"]*"' "$f" \
|
||||
| sed 's/.*name="\([^"]*\)"/\1/' | sort | uniq -d)
|
||||
[ -n "$dups" ] && echo "DUP in $f: $dups"
|
||||
python3 -c "import xml.dom.minidom; xml.dom.minidom.parse('$f')" \
|
||||
|| echo "MALFORMED $f"
|
||||
done
|
||||
# For a commons change, also run the build task that enforces this:
|
||||
# ./gradlew :commons:convertXmlValueResourcesForCommonMain
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
@@ -344,6 +373,7 @@ When adding translated strings to locale files:
|
||||
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
|
||||
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
|
||||
- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results.
|
||||
- **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.)
|
||||
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
|
||||
- **Hardcoding `"1"` in a `<plurals>` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
|
||||
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
|
||||
|
||||
@@ -17,7 +17,7 @@ Join the social network you control.
|
||||
[](https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz)
|
||||
[](https://jitpack.io/#vitorpamplona/amethyst)
|
||||
[](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
|
||||
[](/LICENSE)
|
||||
[](/LICENSE)
|
||||
[](https://deepwiki.com/vitorpamplona/amethyst)
|
||||
|
||||
## Download and Install
|
||||
|
||||
86
SKILL.md
86
SKILL.md
@@ -24,7 +24,9 @@ Build customized Amethyst Nostr clients for Android. Fork, rebrand, customize, a
|
||||
|
||||
2. **Android SDK**
|
||||
- Command-line tools from https://developer.android.com/studio#command-line-tools-only
|
||||
- Required components: build-tools, platform-tools, platforms;android-35
|
||||
- Required components: build-tools, platform-tools, platforms;android-37
|
||||
- The exact SDK level is `android-compileSdk` in `gradle/libs.versions.toml` —
|
||||
check there if this number has drifted.
|
||||
|
||||
3. **Git** for cloning the repository
|
||||
|
||||
@@ -66,48 +68,54 @@ keyPassword=your-password
|
||||
|
||||
### 3. Configure Signing
|
||||
|
||||
Add to `amethyst/build.gradle` inside the `android {}` block:
|
||||
Add to `amethyst/build.gradle.kts` inside the `android {}` block:
|
||||
|
||||
```gradle
|
||||
def keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
def keystoreProperties = new Properties()
|
||||
```kotlin
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
create("release") {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
storeFile rootProject.file(keystoreProperties['storeFile'])
|
||||
storePassword keystoreProperties['storePassword']
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This needs `import java.util.Properties` at the top of the file.
|
||||
|
||||
Update the release buildType to use the signing config:
|
||||
```gradle
|
||||
```kotlin
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
getByName("release") {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
// ... existing config
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Verify with `./gradlew :amethyst:signingReport` — the release variants should
|
||||
report your keystore rather than `~/.android/debug.keystore`.
|
||||
|
||||
### 4. Disable Google Services (Required for F-Droid)
|
||||
|
||||
**⚠️ CRITICAL:** The Google Services plugin fails when you change the package name. For F-Droid builds, disable it.
|
||||
|
||||
Edit `amethyst/build.gradle`, comment out the plugin:
|
||||
```gradle
|
||||
Edit `amethyst/build.gradle.kts`, comment out the plugin:
|
||||
```kotlin
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.jetbrainsKotlinAndroid)
|
||||
// alias(libs.plugins.googleServices) // DISABLED for F-Droid
|
||||
alias(libs.plugins.jetbrainsComposeCompiler)
|
||||
alias(libs.plugins.serialization)
|
||||
alias(libs.plugins.googleKsp)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -141,8 +149,8 @@ Edit `amethyst/src/main/res/values/strings.xml`:
|
||||
|
||||
### Change Package ID
|
||||
|
||||
Edit `amethyst/build.gradle`:
|
||||
```gradle
|
||||
Edit `amethyst/build.gradle.kts`:
|
||||
```kotlin
|
||||
android {
|
||||
defaultConfig {
|
||||
applicationId = "com.yourcompany.yourapp"
|
||||
@@ -152,8 +160,8 @@ android {
|
||||
|
||||
### Change Project Name
|
||||
|
||||
Edit `settings.gradle`:
|
||||
```gradle
|
||||
Edit `settings.gradle.kts`:
|
||||
```kotlin
|
||||
rootProject.name = "YourAppName"
|
||||
```
|
||||
|
||||
@@ -167,36 +175,28 @@ Replace icon files in:
|
||||
|
||||
Make your app identify itself on posts with `["client", "YourAppName"]`.
|
||||
|
||||
**1. Create tag builder extension:**
|
||||
You do **not** need to add the tag per event type. The client tag is applied
|
||||
centrally by `NostrSignerWithClientTag`, a signer decorator that appends the tag
|
||||
to everything it signs (and respects the user's "add client tag" privacy
|
||||
setting). Changing the name is a one-constant edit:
|
||||
|
||||
Create `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/clientTag/TagArrayBuilderExt.kt`:
|
||||
Edit `amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt`:
|
||||
```kotlin
|
||||
package com.vitorpamplona.quartz.nip01Core.tags.clientTag
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.client(clientName: String) =
|
||||
addUnique(arrayOf(ClientTag.TAG_NAME, clientName))
|
||||
const val CLIENT_TAG_NAME = "YourAppName"
|
||||
```
|
||||
|
||||
**2. Add to TextNoteEvent:**
|
||||
That constant is passed to `NostrSignerWithClientTag` when the account's signer
|
||||
is built, so every signed event carries your name.
|
||||
|
||||
Edit `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt`:
|
||||
|
||||
Add import:
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.clientTag.client
|
||||
```
|
||||
|
||||
In both `build()` functions, add after `alt(...)`:
|
||||
```kotlin
|
||||
client("YourAppName")
|
||||
```
|
||||
The tag itself lives in
|
||||
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/`
|
||||
(`ClientTag`, `TagArrayBuilderExt`, `NostrSignerWithClientTag`) — you only need to
|
||||
touch it if you want the optional NIP-89 handler address / relay hint variants.
|
||||
|
||||
### Modify Default Relays
|
||||
|
||||
Edit relay configuration in `quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/` or the UI settings files.
|
||||
Edit `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt`
|
||||
(see also `AmethystDefaults.kt` and `DefaultDmIndexerRelays.kt` in the same folder).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -567,6 +567,7 @@ dependencies {
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.secp256k1.kmp.jni.jvm)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
@@ -38,8 +38,10 @@ import com.vitorpamplona.amethyst.model.UiSettings
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
@@ -55,6 +57,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
@@ -168,7 +171,10 @@ private object PrefKeys {
|
||||
const val LATEST_GEOHASH_LIST = "latestGeohashList"
|
||||
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
|
||||
const val LATEST_RELAY_GROUP_LIST = "latestRelayGroupList"
|
||||
const val LATEST_CONCORD_LIST = "latestConcordList"
|
||||
const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList"
|
||||
const val LATEST_KEY_PACKAGE_RELAY_LIST = "latestKeyPackageRelayList"
|
||||
const val LATEST_FAVORITE_ALGO_FEEDS_LIST = "latestFavoriteAlgoFeedsList"
|
||||
const val CALLS_ENABLED = "calls_enabled"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
@@ -577,7 +583,10 @@ object LocalPreferences {
|
||||
putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList)
|
||||
putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList)
|
||||
putOrRemove(PrefKeys.LATEST_RELAY_GROUP_LIST, settings.backupRelayGroupList)
|
||||
putOrRemove(PrefKeys.LATEST_CONCORD_LIST, settings.backupConcordList)
|
||||
putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList)
|
||||
putOrRemove(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, settings.backupKeyPackageRelayList)
|
||||
putOrRemove(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, settings.backupFavoriteAlgoFeedsList)
|
||||
putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets)
|
||||
putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet)
|
||||
putOrRemove(PrefKeys.LATEST_NUTZAP_INFO, settings.backupNutzapInfo)
|
||||
@@ -763,7 +772,10 @@ object LocalPreferences {
|
||||
val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null)
|
||||
val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null)
|
||||
val latestRelayGroupListStr = getString(PrefKeys.LATEST_RELAY_GROUP_LIST, null)
|
||||
val latestConcordListStr = getString(PrefKeys.LATEST_CONCORD_LIST, null)
|
||||
val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null)
|
||||
val latestKeyPackageRelayListStr = getString(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, null)
|
||||
val latestFavoriteAlgoFeedsListStr = getString(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, null)
|
||||
val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null)
|
||||
val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null)
|
||||
val latestNutzapInfoStr = getString(PrefKeys.LATEST_NUTZAP_INFO, null)
|
||||
@@ -823,7 +835,10 @@ object LocalPreferences {
|
||||
val latestGeohashList = async { parseEventOrNull<GeohashListEvent>(latestGeohashListStr) }
|
||||
val latestEphemeralList = async { parseEventOrNull<EphemeralChatListEvent>(latestEphemeralListStr) }
|
||||
val latestRelayGroupList = async { parseEventOrNull<SimpleGroupListEvent>(latestRelayGroupListStr) }
|
||||
val latestConcordList = async { parseEventOrNull<ConcordCommunityListEvent>(latestConcordListStr) }
|
||||
val latestTrustProviderList = async { parseEventOrNull<TrustProviderListEvent>(latestTrustProviderListStr) }
|
||||
val latestKeyPackageRelayList = async { parseEventOrNull<KeyPackageRelayListEvent>(latestKeyPackageRelayListStr) }
|
||||
val latestFavoriteAlgoFeedsList = async { parseEventOrNull<FavoriteAlgoFeedsListEvent>(latestFavoriteAlgoFeedsListStr) }
|
||||
val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) }
|
||||
val latestCashuWallet =
|
||||
async {
|
||||
@@ -875,7 +890,10 @@ object LocalPreferences {
|
||||
val latestGeohashListResolved = latestGeohashList.await()
|
||||
val latestEphemeralListResolved = latestEphemeralList.await()
|
||||
val latestRelayGroupListResolved = latestRelayGroupList.await()
|
||||
val latestConcordListResolved = latestConcordList.await()
|
||||
val latestTrustProviderListResolved = latestTrustProviderList.await()
|
||||
val latestKeyPackageRelayListResolved = latestKeyPackageRelayList.await()
|
||||
val latestFavoriteAlgoFeedsListResolved = latestFavoriteAlgoFeedsList.await()
|
||||
val latestPaymentTargetsResolved = latestPaymentTargets.await()
|
||||
val latestCashuWalletResolved = latestCashuWallet.await()
|
||||
val latestNutzapInfoResolved = latestNutzapInfo.await()
|
||||
@@ -969,7 +987,10 @@ object LocalPreferences {
|
||||
backupGeohashList = latestGeohashListResolved,
|
||||
backupEphemeralChatList = latestEphemeralListResolved,
|
||||
backupRelayGroupList = latestRelayGroupListResolved,
|
||||
backupConcordList = latestConcordListResolved,
|
||||
backupTrustProviderList = latestTrustProviderListResolved,
|
||||
backupKeyPackageRelayList = latestKeyPackageRelayListResolved,
|
||||
backupFavoriteAlgoFeedsList = latestFavoriteAlgoFeedsListResolved,
|
||||
lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved),
|
||||
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
|
||||
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),
|
||||
|
||||
@@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.note.UpdateReactionTypeScreen
|
||||
import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageFileScreen
|
||||
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageScreen
|
||||
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsQrScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -583,6 +584,7 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ShareNoteAsImage> { ShareNoteAsImageScreen(it.id, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ShareNoteAsImageFile> { ShareNoteAsImageFileScreen(it.id, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ShareNoteAsQr> { ShareNoteAsQrScreen(it.id, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.ContactListUsers> { ContactListUsersScreen(it.noteId, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
|
||||
|
||||
@@ -507,6 +507,10 @@ sealed class Route {
|
||||
val id: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class ShareNoteAsQr(
|
||||
val id: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class ContactListUsers(
|
||||
val noteId: String,
|
||||
) : Route()
|
||||
|
||||
@@ -38,8 +38,9 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
* both from the reaction-row Share button and from the note's 3-dot menu).
|
||||
*
|
||||
* Only the true "send it somewhere" options live here — browser link, image
|
||||
* file, image URL. The copy-to-clipboard options stay in the 3-dot menu, so
|
||||
* they are intentionally NOT part of this shared element.
|
||||
* file, image URL, and the display-only QR code. The copy-to-clipboard
|
||||
* options stay in the 3-dot menu, so they are intentionally NOT part of this
|
||||
* shared element.
|
||||
*
|
||||
* Callers only render these for non-private notes: every option exposes the
|
||||
* note publicly (a shareable web link, or an image of it), which must never
|
||||
@@ -76,4 +77,8 @@ fun ShareActionRows(
|
||||
nav.nav(Route.ShareNoteAsImage(shareId))
|
||||
onDismiss()
|
||||
}
|
||||
M3ActionRow(icon = MaterialSymbols.QrCode2, text = stringRes(R.string.share_as_qr)) {
|
||||
nav.nav(Route.ShareNoteAsQr(shareId))
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.ui.note.share
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
|
||||
|
||||
/** Which of the two payloads the QR code currently encodes. */
|
||||
enum class QrPayloadMode {
|
||||
/**
|
||||
* An `https://njump.to/…` link. The default, because a stock phone camera will offer to
|
||||
* open an http(s) URL but may treat a bare custom scheme as inert text.
|
||||
*/
|
||||
Web,
|
||||
|
||||
/**
|
||||
* A `nostr:nevent1…` / `nostr:naddr1…` URI. Resolves without a web round-trip and is what
|
||||
* in-app scanners expect.
|
||||
*/
|
||||
Nostr,
|
||||
}
|
||||
|
||||
/**
|
||||
* The string encoded into the QR code for [note] in [mode].
|
||||
*
|
||||
* Both payloads carry the note's relay hint: [externalLinkForNote] builds its URL from
|
||||
* `toNAddr()` / `toNEvent()`, which already call `relayHintUrl()`.
|
||||
*/
|
||||
fun qrPayloadFor(
|
||||
note: Note,
|
||||
mode: QrPayloadMode,
|
||||
): String =
|
||||
when (mode) {
|
||||
QrPayloadMode.Web -> externalLinkForNote(note)
|
||||
QrPayloadMode.Nostr -> note.toNostrUri()
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.ui.note.share
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivityWindow
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_MARGIN_PX in QrCodeDrawer.kt) is a
|
||||
// fixed pixel count subtracted from raw size.width, so its share of the tile grows as density
|
||||
// falls. Hard-sizing this call to a small dp value starved long-form naddr payloads of scannable
|
||||
// resolution on low-density screens. Deriving the size from the available column width keeps
|
||||
// enough real pixels per module; this only bounds it from growing unreasonably large on tablets.
|
||||
private val QrMaxSize = 320.dp
|
||||
|
||||
/**
|
||||
* Display-only screen presenting [id]'s note as a scannable QR code.
|
||||
*
|
||||
* There is no export or save action by design — the screen exists to be held up and
|
||||
* photographed by another device.
|
||||
*
|
||||
* F6: the Scaffold (and its back button) lives in this id-based wrapper, OUTSIDE LoadNote's
|
||||
* null check, so an id that never resolves to a note still leaves the user a way back — only
|
||||
* the body inside is empty in that case. Rendering nothing else for an unresolved id is
|
||||
* deliberate, matching ShareNoteAsImageScreen; only the missing chrome was the bug.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ShareNoteAsQrScreen(
|
||||
id: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringRes(R.string.share_as_qr), nav) },
|
||||
) { pad ->
|
||||
LoadNote(id, accountViewModel) { note ->
|
||||
if (note != null) {
|
||||
ShareNoteAsQrScreenContent(note, accountViewModel, nav, pad)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ShareNoteAsQrScreen(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringRes(R.string.share_as_qr), nav) },
|
||||
) { pad ->
|
||||
ShareNoteAsQrScreenContent(note, accountViewModel, nav, pad)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ShareNoteAsQrScreenContent(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
pad: PaddingValues,
|
||||
) {
|
||||
var mode by remember { mutableStateOf(QrPayloadMode.Web) }
|
||||
|
||||
// F4: keyed on the observed note state, not on `note` alone (a stable object identity that
|
||||
// does not change while the event and the author's relay list are still loading). A payload
|
||||
// computed before then would omit the relay hint (Note.relayHintUrl()) and never recompute.
|
||||
// Keying on `noteState` re-derives the payload once the event arrives — the same observation
|
||||
// SharedNoteCard uses for its own sensitivity gate (F1).
|
||||
val noteState by observeNote(note, accountViewModel)
|
||||
val payload = remember(noteState, mode) { qrPayloadFor(note, mode) }
|
||||
|
||||
KeepScreenBrightAndAwake()
|
||||
|
||||
// F5: scrollable so the toggle and hint — the screen's only controls — stay reachable on a
|
||||
// short viewport (landscape, split screen, large font scale) where the square QR plus the
|
||||
// card above it can otherwise exceed the available height. A plain fillMaxSize() Column would
|
||||
// silently place that overflow outside its bounds instead of clipping or scrolling to it.
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(pad)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
SharedNoteCard(note, accountViewModel, nav)
|
||||
|
||||
val qrContentDescription =
|
||||
when (mode) {
|
||||
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_code_description_web)
|
||||
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_code_description_nostr)
|
||||
}
|
||||
QrCodeDrawer(
|
||||
contents = payload,
|
||||
modifier =
|
||||
Modifier
|
||||
.widthIn(max = QrMaxSize)
|
||||
.fillMaxWidth()
|
||||
.semantics { contentDescription = qrContentDescription },
|
||||
)
|
||||
|
||||
// Fill the width so each button gets an equal, generous half (a bare
|
||||
// SingleChoiceSegmentedButtonRow shrinks to content and clips longer labels), and pass an
|
||||
// empty `icon` so the selected-state checkmark never steals horizontal room from the
|
||||
// label — selection is already signalled by the fill colour. Both matter for translated
|
||||
// labels, which are often longer than the English "Web link" / "Nostr link".
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
val modes = listOf(QrPayloadMode.Web, QrPayloadMode.Nostr)
|
||||
modes.forEachIndexed { index, candidate ->
|
||||
SegmentedButton(
|
||||
selected = mode == candidate,
|
||||
onClick = { mode = candidate },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = index, count = modes.size),
|
||||
icon = {},
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
when (candidate) {
|
||||
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_mode_web)
|
||||
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_mode_nostr)
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text =
|
||||
when (mode) {
|
||||
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_hint_web)
|
||||
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_hint_nostr)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises the screen to full brightness and prevents it sleeping while the QR is displayed,
|
||||
* restoring both on exit.
|
||||
*
|
||||
* This is functional, not polish: the screen exists to be photographed, and a dark-theme phone
|
||||
* on auto-brightness in a dim room is exactly the case that fails.
|
||||
*
|
||||
* Residual hazard, not reachable today: [LocalView.current] is the Activity's single shared root
|
||||
* `ComposeView`, not a view scoped to this screen. During a nav transition two compositions can
|
||||
* briefly coexist on that same root view, so this screen's `onDispose` could in theory clobber
|
||||
* brightness/`keepScreenOn` state an incoming screen has already set. Nothing in the current nav
|
||||
* graph triggers that overlap, so this is left as a comment rather than code.
|
||||
*/
|
||||
@Composable
|
||||
private fun KeepScreenBrightAndAwake() {
|
||||
val view = LocalView.current
|
||||
// NOT `(view.context as? Activity)`: under Compose the context is routinely a
|
||||
// ContextThemeWrapper, so that cast silently yields null and brightness never changes —
|
||||
// no crash, no log, just a dead feature. getActivityWindow() unwraps the ContextWrapper
|
||||
// chain (WindowUtils.kt:39-46).
|
||||
val window = getActivityWindow()
|
||||
|
||||
DisposableEffect(window, view) {
|
||||
// Capture the RAW attribute, not a computed fraction. When no override is set this is
|
||||
// BRIGHTNESS_OVERRIDE_NONE (-1f), and restoring that value returns the device to auto
|
||||
// brightness. Restoring a *computed* fraction would install an override where none
|
||||
// existed and silently disable auto-brightness for the rest of the session.
|
||||
val previousBrightness = window?.attributes?.screenBrightness
|
||||
|
||||
// F8: same capture/replay discipline as brightness above, and for the same reason.
|
||||
// `view` is the Activity's single shared root ComposeView, and PlayerEventListener
|
||||
// (ControlWhenPlayerIsActive.kt:150-165) owns this exact flag while media plays.
|
||||
// Hard-setting `false` on dispose — instead of restoring what was here before this
|
||||
// screen took it over — would clobber that ownership: navigating back from the QR
|
||||
// screen while audio or video is still playing would let the screen sleep mid-playback.
|
||||
val previousKeepScreenOn = view.keepScreenOn
|
||||
|
||||
window?.let {
|
||||
it.attributes = it.attributes.apply { screenBrightness = 1f }
|
||||
}
|
||||
view.keepScreenOn = true
|
||||
|
||||
onDispose {
|
||||
// Restore the captured value rather than calling a release helper: resetting to
|
||||
// BRIGHTNESS_OVERRIDE_NONE unconditionally would clobber an override the user
|
||||
// already had, e.g. one left by the fullscreen video controls.
|
||||
window?.let { w ->
|
||||
previousBrightness?.let { prev ->
|
||||
w.attributes = w.attributes.apply { screenBrightness = prev }
|
||||
}
|
||||
}
|
||||
view.keepScreenOn = previousKeepScreenOn
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.ui.note.share
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.GalleryThumbnail
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.alt
|
||||
import com.vitorpamplona.quartz.nip71Video.blurhash
|
||||
import com.vitorpamplona.quartz.nip71Video.mimeType
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
|
||||
private val CardHeight = 72.dp
|
||||
private val ThumbSize = 56.dp
|
||||
private val ThumbShape = RoundedCornerShape(9.dp)
|
||||
|
||||
/**
|
||||
* A fixed-height preview of the note being shared, shown above the QR code.
|
||||
*
|
||||
* The height is fixed on purpose: it keeps the QR code in the same screen position for every
|
||||
* note, so the screen is predictable to hold up to a scanner. That is why this does not use
|
||||
* [com.vitorpamplona.amethyst.ui.note.NoteCompose] — see the design spec for the full reasoning,
|
||||
* but in short, `isQuotedNote` never reaches the media renderer and note images render at their
|
||||
* natural aspect ratio with no height ceiling.
|
||||
*/
|
||||
@Composable
|
||||
fun SharedNoteCard(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().height(CardHeight).padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
// Plain SensitivityWarning is NOT enough here: it gates on event.isSensitiveOrNSFW(),
|
||||
// which only reads the note-level content-warning tag / nsfw hashtag. GalleryThumbnail's
|
||||
// inner gate (GalleryThumb.kt:236) reads each media entry's per-imeta `contentWarning`,
|
||||
// but none of GalleryThumb.kt's four media-construction sites ever set that field, so it
|
||||
// is always null and that inner gate is permanently dead on this path. A note whose only
|
||||
// warning lives inside an `imeta` tag (e.g. a kind 20 PictureEvent) would then render
|
||||
// completely unblurred. `hasImetaContentWarning` below checks every imeta for the mere
|
||||
// PRESENCE of a `content-warning` key, not for non-blank reason text: an imeta warning
|
||||
// with an empty reason string (`["content-warning"]` with no second element) still counts
|
||||
// — collectContentWarningReasons()'s `takeIf { it.isNotBlank() }` would silently drop that
|
||||
// exact case, which is what let it slip the gate before. collectContentWarningReasons()
|
||||
// is still called for the human-readable *reason text*, shown in the covered box's
|
||||
// accessibility label when one exists — it never drives the show/hide decision.
|
||||
//
|
||||
// `note.event` is a plain field read that never recomposes if this composable is
|
||||
// rendered before the note's event has arrived over the relay (id-only reference, e.g.
|
||||
// straight off a deep link). observeNote() subscribes both the relay finder and the
|
||||
// LocalCache flow, so `event` below updates — and this whole gate recomputes — the
|
||||
// moment the event loads or changes, the same idiom GalleryThumbnail itself already uses
|
||||
// (GalleryThumb.kt:78).
|
||||
//
|
||||
// This deliberately does NOT use the shared ContentWarningGate: its overlay
|
||||
// (ContentWarningOverlayBody, SensitivityWarning.kt:254+) opens with an 80.dp icon box
|
||||
// that consumes this card's entire 56.dp thumbnail height, pushing the title, reasons,
|
||||
// and "Show anyway" button below the clipped, tappable area. Reshaping that shared
|
||||
// composable was rejected — six other screens depend on its current layout — so this
|
||||
// call site renders its own compact, permanently-covered state instead: no reveal
|
||||
// affordance, because this screen is held up in public and pointed at someone else's
|
||||
// camera. `accountViewModel.showSensitiveContent()` is still honoured exactly as
|
||||
// ContentWarningGate honours it (SensitivityWarning.kt:138-140), so a user who has
|
||||
// opted into seeing sensitive content globally sees the real thumbnail here too. Either
|
||||
// way the thumbnail box stays a fixed 56.dp, keeping this Row's height fixed at 72.dp.
|
||||
//
|
||||
// `nav` is passed because GalleryThumbnail's signature demands it, but it is unused
|
||||
// there (GalleryThumb.kt:76) — navigation comes from ClickableNote at its other call
|
||||
// site. This card is not tappable.
|
||||
val noteState by observeNote(note, accountViewModel)
|
||||
val event = noteState.note.event
|
||||
val reasons = remember(event) { event?.let { collectContentWarningReasons(it) } ?: emptySet() }
|
||||
val hasImetaContentWarning =
|
||||
remember(event) {
|
||||
event?.imetas()?.any { it.properties.containsKey(ContentWarningTag.TAG_NAME) } ?: false
|
||||
}
|
||||
val isSensitive =
|
||||
remember(event, hasImetaContentWarning) {
|
||||
event != null && (event.isSensitiveOrNSFW() || hasImetaContentWarning)
|
||||
}
|
||||
val showSensitiveContent by accountViewModel.showSensitiveContent().collectAsStateWithLifecycle()
|
||||
val isGated = isSensitive && showSensitiveContent != true
|
||||
|
||||
// Thumbnail source, in priority order:
|
||||
// 1. structured media event (kind 20/21/22, gallery, live clip) -> GalleryThumbnail;
|
||||
// 2. an image carried in an `imeta` tag of an otherwise-unstructured note (a kind 1
|
||||
// image post — content is typically just the media URL) -> render that image;
|
||||
// 3. no media at all -> the author's round AVATAR, which (unlike GalleryThumbnail's own
|
||||
// DisplayGalleryAuthorBanner fallback, a banner crop) cannot be mistaken for the
|
||||
// note's own picture (F7).
|
||||
// hasStructuredMedia() mirrors GalleryThumbnail's own per-kind checks (GalleryThumb.kt:
|
||||
// 82-186); contentImage covers the case GalleryThumbnail does NOT handle — a bare image
|
||||
// URL in an imeta on a kind 1 — so those posts show the picture instead of avatar + URL.
|
||||
val hasStructuredMedia = remember(event) { event != null && hasStructuredMedia(event) }
|
||||
val contentImage = remember(event) { event?.let { firstContentImage(it) } }
|
||||
|
||||
Box(Modifier.size(ThumbSize).clip(ThumbShape)) {
|
||||
if (isGated) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Warning,
|
||||
contentDescription =
|
||||
reasons.firstOrNull()?.let { stringRes(R.string.content_warning_with_reason, it) }
|
||||
?: stringRes(R.string.share_as_qr_thumbnail_hidden_sensitive),
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else if (hasStructuredMedia) {
|
||||
GalleryThumbnail(note, accountViewModel, nav)
|
||||
} else if (contentImage != null) {
|
||||
// UrlImageView crops to fill (ContentScale.Crop) and honours the account's
|
||||
// show-images setting and blossom bridge on its own; the enclosing 56.dp Box
|
||||
// bounds it so the card height stays fixed. No extra SensitivityWarning: the
|
||||
// isGated branch above already covered the sensitive case.
|
||||
UrlImageView(contentImage, accountViewModel)
|
||||
} else {
|
||||
NoteAuthorPicture(note, ThumbSize, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = note.author?.toBestDisplayName() ?: "",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = secondaryLineFor(event, isGated, contentImage != null),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [event] carries the kind of structured media [GalleryThumbnail] would render (a
|
||||
* profile-gallery entry, kind 20 picture, kind 21/22 video, or live-activity clip with a video
|
||||
* URL) — used only to pick between the media thumbnail and the author-avatar fallback (F7).
|
||||
* Mirrors GalleryThumbnail's own when-branch conditions (GalleryThumb.kt:82-186) rather than
|
||||
* duplicating its full [com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent] construction.
|
||||
*/
|
||||
private fun hasStructuredMedia(event: Event): Boolean =
|
||||
when (event) {
|
||||
is ProfileGalleryEntryEvent -> event.urls().isNotEmpty()
|
||||
is PictureEvent -> event.imetaTags().isNotEmpty()
|
||||
is VideoEvent -> event.imetaTags().isNotEmpty()
|
||||
is LiveActivitiesClipEvent -> event.videoUrl() != null
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** True when this `imeta` describes an image — by declared mime type, or failing that its URL. */
|
||||
internal fun IMetaTag.isImage(): Boolean {
|
||||
val mime = mimeType()?.firstOrNull()
|
||||
return if (mime != null) mime.startsWith("image/") else RichTextParser.isImageUrl(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* The first image carried in an [event]'s `imeta` tags, as a renderable [MediaUrlImage], or null
|
||||
* if the note has none. Covers the common kind-1 image post whose content is just a media URL —
|
||||
* a case [GalleryThumbnail] does not handle (its when-branches fall through to the author banner).
|
||||
* Only images are returned; a video-only imeta yields null and the card falls back to the avatar
|
||||
* rather than feeding a video URL to the image loader.
|
||||
*/
|
||||
internal fun firstContentImage(event: Event): MediaUrlImage? {
|
||||
val imeta = event.imetas().firstOrNull { it.isImage() } ?: return null
|
||||
return MediaUrlImage(
|
||||
url = imeta.url,
|
||||
description = imeta.alt()?.firstOrNull(),
|
||||
blurhash = imeta.blurhash()?.firstOrNull(),
|
||||
mimeType = imeta.mimeType()?.firstOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-or-two-line description under the author name: an article's title, else the note's own
|
||||
* text (with any embedded media URLs stripped), else the image's alt text, else a kind label.
|
||||
*
|
||||
* F3: when [isGated] (the same sensitive-and-not-opted-in decision computed for the thumbnail,
|
||||
* passed in rather than recomputed) title, content and alt text are all skipped in favor of the
|
||||
* neutral kind label — otherwise this line would render the sensitive note's own text unblurred
|
||||
* right next to the covered thumbnail, which is exactly what `Text.kt`'s `SensitivityWarning`
|
||||
* wrapping exists to prevent for note bodies elsewhere in the app. The author name stays visible
|
||||
* either way; only this line is affected.
|
||||
*
|
||||
* [hasContentImage] lets an image-only post whose text and alt are both empty fall back to a
|
||||
* "Picture" label rather than a blank line.
|
||||
*/
|
||||
@Composable
|
||||
private fun secondaryLineFor(
|
||||
event: Event?,
|
||||
isGated: Boolean,
|
||||
hasContentImage: Boolean,
|
||||
): String {
|
||||
if (event == null) return ""
|
||||
|
||||
// F10: remembered against (event, isGated) so a long article body is not re-trimmed on every
|
||||
// recomposition — only when the underlying event or the gate decision actually changes.
|
||||
val bodyText = remember(event, isGated) { secondaryBodyTextFor(event, isGated) }
|
||||
if (bodyText != null) return bodyText
|
||||
|
||||
return when {
|
||||
event is PictureEvent -> stringRes(R.string.share_as_qr_kind_picture)
|
||||
event is VideoEvent -> stringRes(R.string.kind_video)
|
||||
event is LongTextNoteEvent -> stringRes(R.string.article)
|
||||
hasContentImage -> stringRes(R.string.share_as_qr_kind_picture)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
// Plain (non-@Composable) so it can be wrapped in `remember` — `stringRes` calls, which the
|
||||
// kind-label fallback needs, are not allowed inside a remember calculation lambda.
|
||||
internal fun secondaryBodyTextFor(
|
||||
event: Event,
|
||||
isGated: Boolean,
|
||||
): String? {
|
||||
// Gated: never surface the note's own title, content or alt text, only the kind label above.
|
||||
if (isGated) return null
|
||||
|
||||
if (event is LongTextNoteEvent) {
|
||||
val title = event.title()
|
||||
if (!title.isNullOrBlank()) return title
|
||||
}
|
||||
|
||||
// An image-only post's content is typically just the media URL(s). Strip any that appear
|
||||
// verbatim (only when the note actually has imeta media, so a plain article — no imeta — is
|
||||
// never scanned) so the line does not show a bare CDN URL. If nothing meaningful remains,
|
||||
// fall through to the alt text, then the kind label.
|
||||
val mediaUrls = event.imetas().map { it.url }
|
||||
val stripped =
|
||||
if (mediaUrls.isEmpty()) {
|
||||
event.content
|
||||
} else {
|
||||
mediaUrls.fold(event.content) { acc, url -> acc.replace(url, "") }
|
||||
}
|
||||
// F10: bounded prefix — this line only ever shows two lines of bodySmall text, so there is
|
||||
// no need to trim() a full long-form article body (potentially tens of KB) to get there.
|
||||
val content = stripped.take(MAX_SECONDARY_LINE_CHARS).trim()
|
||||
if (content.isNotEmpty()) return content
|
||||
|
||||
return event
|
||||
.imetas()
|
||||
.firstOrNull { it.isImage() }
|
||||
?.alt()
|
||||
?.firstOrNull()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private const val MAX_SECONDARY_LINE_CHARS = 280
|
||||
@@ -77,7 +77,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.dal.WorkoutFeedFilter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AccountFeedContentStates(
|
||||
@@ -201,6 +203,26 @@ class AccountFeedContentStates(
|
||||
}
|
||||
}
|
||||
|
||||
// A Concord control-plane fold is what first reveals a community's channels (and what makes
|
||||
// ConcordCommunitySession.state non-null, without which ChatroomListKnownFeedFilter emits
|
||||
// nothing at all for that community). None of it flows through LocalCache.newEventBundles,
|
||||
// so the additive path can't see it: a folded channel reaches the Messages tab only if a
|
||||
// message for it happens to arrive afterwards. Cold boot therefore shows a *subset* of a
|
||||
// community's channels, or omits a quiet community entirely, until some unrelated
|
||||
// invalidation fires. Rebuild on every structural change instead. `revision` bumps only on
|
||||
// fold/membership/rekey (never a plain message), and sample() coalesces the burst of folds
|
||||
// that lands as each control plane catches up — the same pairing Account.kt uses to drive
|
||||
// refreshConcordChannelIndex off this flow.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@OptIn(FlowPreview::class)
|
||||
account.concordSessions.revision
|
||||
.drop(1)
|
||||
.sample(500)
|
||||
.collect {
|
||||
dmKnown.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
// Same for the Concord view mode (inline channels vs one row per community).
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.settings.concordViewMode
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<string name="referenced_event_not_found">Odkazovaná událost nebyla nalezena</string>
|
||||
<string name="could_not_decrypt_the_message">Nepodařilo se dešifrovat zprávu</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="chat_preview_decrypting">Dešifrování…</string>
|
||||
<string name="group_picture">Obrázek skupiny</string>
|
||||
<string name="explicit_content">Explicitní obsah</string>
|
||||
<string name="relay_notice">Oznámení relé</string>
|
||||
@@ -308,6 +309,10 @@
|
||||
<string name="concord_invite_failed_invalid">Tento zvací odkaz je neplatný nebo jej nelze otevřít tímto účtem.</string>
|
||||
<string name="concord_invite_failed_incompatible">Tento zvací odkaz nelze otevřít. Může být zastaralý nebo již nahrazený novějším, případně vytvořený novější verzí aplikace. Vyžádejte si nový zvací odkaz.</string>
|
||||
<string name="concord_invite_failed_revoked">Tento zvací odkaz byl zrušen a již jej nelze použít. Vyžádejte si nový.</string>
|
||||
<string name="concord_invite_failed_expired">Platnost tohoto pozvánkového odkazu vypršela a nelze jej již použít. Vyžádejte si nový odkaz.</string>
|
||||
<string name="concord_invite_preview_unknown_name">Název komunity se zobrazí až po připojení</string>
|
||||
<string name="concord_invite_preview_explainer">Připojením se spojíte s relayi této pozvánky, zveřejníte oznámení o připojení podepsané vaším účtem a přidáte komunitu do svého seznamu. Dokud nestisknete Připojit se, nic se neodešle.</string>
|
||||
<string name="concord_invite_preview_relays">Relaye, které tato pozvánka kontaktuje: %1$s</string>
|
||||
<string name="concord_home_title">Kanály Concord</string>
|
||||
<string name="concord_home_empty">Zatím jste se nepřipojili k žádným kanálům Concord. Vytvořte nějaký nebo otevřete pozvánku.</string>
|
||||
<string name="concord_channels_empty">Zatím žádné kanály.</string>
|
||||
@@ -323,6 +328,10 @@
|
||||
<string name="concord_channel_delete_title">Smazat kanál?</string>
|
||||
<string name="concord_channel_delete_message">Smazat #%1$s? Tuto akci nelze vrátit zpět a kanál nelze znovu vytvořit se stejným id.</string>
|
||||
<string name="concord_channel_delete_confirm">Smazat</string>
|
||||
<string name="concord_leave_community">Opustit komunitu</string>
|
||||
<string name="concord_leave_title">Opustit komunitu?</string>
|
||||
<string name="concord_leave_message">Opustit %1$s? Bude odebrána ze seznamu tohoto účtu a přestane se synchronizovat na vašich zařízeních. Komunita o tom nedostane oznámení a nebudete odebráni z jejího seznamu členů. Zprávy, které již nedokážete dešifrovat, mohou být neobnovitelné, a vrátit se můžete pouze s novou pozvánkou.</string>
|
||||
<string name="concord_leave_owner_warning">Tuto komunitu jste vytvořili vy. Opuštěním ji nesmažete ani ji nepředáte nikomu jinému, ale zahodíte klíč vlastníka uložený ve vašem seznamu — už byste ji nemohli spravovat.</string>
|
||||
<string name="concord_edit_relays_desc">Kde jsou publikovány a čteny šifrované roviny této komunity.</string>
|
||||
<string name="concord_typing_one">%1$s píše…</string>
|
||||
<string name="concord_typing_two">%1$s a %2$s píší…</string>
|
||||
@@ -373,6 +382,13 @@
|
||||
<string name="concord_members_remove_title">Odebrat člena?</string>
|
||||
<string name="concord_members_remove_message">Tímto se otočí šifrovací klíč komunity, takže tento člen už nebude moci číst nic odeslaného poté. Všem ostatním se klíč vymění automaticky. Tuto akci nelze vrátit zpět.</string>
|
||||
<string name="concord_members_remove_confirm">Odebrat</string>
|
||||
<string name="concord_members_roles">Role…</string>
|
||||
<string name="concord_members_roles_title">Přiřadit role</string>
|
||||
<string name="concord_members_roles_message">Vyberte všechny role, které by měl tento člen mít. Zrušením zaškrtnutí role ji odeberete.</string>
|
||||
<string name="concord_members_roles_save">Uložit</string>
|
||||
<string name="concord_members_roles_out_of_reach">Tento člen vám není podřízen</string>
|
||||
<string name="concord_members_roles_none_assignable">Žádné role, které můžete přiřadit</string>
|
||||
<string name="concord_members_roles_failed">Role tohoto člena se nepodařilo aktualizovat.</string>
|
||||
<string name="concord_role_owner">Vlastník</string>
|
||||
<string name="concord_role_admin">Správce</string>
|
||||
<string name="concord_role_banned">Zablokovaný</string>
|
||||
@@ -499,6 +515,15 @@
|
||||
<string name="share_as_image_url">Sdílet jako URL obrázku</string>
|
||||
<string name="share_as_image_generating">Generování náhledu…</string>
|
||||
<string name="share_as_image_watermark">Sdíleno přes Amethyst</string>
|
||||
<string name="share_as_qr">Sdílet jako QR</string>
|
||||
<string name="share_as_qr_mode_web">Webový odkaz</string>
|
||||
<string name="share_as_qr_mode_nostr">Nostr odkaz</string>
|
||||
<string name="share_as_qr_hint_web">Naskenujte pomocí libovolného fotoaparátu telefonu</string>
|
||||
<string name="share_as_qr_hint_nostr">Naskenujte pomocí Nostr aplikace</string>
|
||||
<string name="share_as_qr_kind_picture">Obrázek</string>
|
||||
<string name="share_as_qr_code_description_web">QR kód obsahující webový odkaz na tuto poznámku</string>
|
||||
<string name="share_as_qr_code_description_nostr">QR kód obsahující Nostr odkaz na tuto poznámku</string>
|
||||
<string name="share_as_qr_thumbnail_hidden_sensitive">Náhled je skrytý kvůli citlivému obsahu</string>
|
||||
<string name="quick_action_copy_user_id">ID autora</string>
|
||||
<string name="quick_action_copy_note_id">ID poznámky</string>
|
||||
<string name="quick_action_copy_text">Kopírovat text</string>
|
||||
@@ -834,6 +859,7 @@
|
||||
<string name="napplet_consent_storage">Tento nApplet chce používat své soukromé úložiště.</string>
|
||||
<string name="napplet_consent_pay">Tento nApplet chce zaplatit Lightning fakturu.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_pay_no_amount">⚠ Tento nApplet chce zaplatit Lightning fakturu, která neuvádí ŽÁDNOU částku — o výši odebrané platby rozhoduje příjemce. Povolte to jen tehdy, pokud mu důvěřujete.</string>
|
||||
<string name="napplet_consent_resource">Tento nApplet chce načíst webový zdroj.</string>
|
||||
<string name="napplet_consent_upload">Tento nApplet chce nahrát soubor na váš mediální server.</string>
|
||||
<string name="napplet_consent_notify">Tento nApplet vám chce zobrazovat oznámení.</string>
|
||||
@@ -848,11 +874,74 @@
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<plurals name="napplet_consent_diff_follow_added">
|
||||
<item quantity="one">začne sledovat %1$d nový účet</item>
|
||||
<item quantity="few">začne sledovat %1$d nové účty</item>
|
||||
<item quantity="many">začne sledovat %1$d nového účtu</item>
|
||||
<item quantity="other">začne sledovat %1$d nových účtů</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_follow_removed">
|
||||
<item quantity="one">PŘESTANE SLEDOVAT %1$d účet</item>
|
||||
<item quantity="few">PŘESTANE SLEDOVAT %1$d účty</item>
|
||||
<item quantity="many">PŘESTANE SLEDOVAT %1$d účtu</item>
|
||||
<item quantity="other">PŘESTANE SLEDOVAT %1$d účtů</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_relay_added">
|
||||
<item quantity="one">přidá %1$d relay</item>
|
||||
<item quantity="few">přidá %1$d relaye</item>
|
||||
<item quantity="many">přidá %1$d relaye</item>
|
||||
<item quantity="other">přidá %1$d relayů</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_relay_removed">
|
||||
<item quantity="one">ODEBERE %1$d relay</item>
|
||||
<item quantity="few">ODEBERE %1$d relaye</item>
|
||||
<item quantity="many">ODEBERE %1$d relaye</item>
|
||||
<item quantity="other">ODEBERE %1$d relayů</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_mute_added">
|
||||
<item quantity="one">ztlumí %1$d další osobu</item>
|
||||
<item quantity="few">ztlumí %1$d další osoby</item>
|
||||
<item quantity="many">ztlumí %1$d další osoby</item>
|
||||
<item quantity="other">ztlumí %1$d dalších osob</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_mute_removed">
|
||||
<item quantity="one">ZRUŠÍ ZTLUMENÍ %1$d osoby</item>
|
||||
<item quantity="few">ZRUŠÍ ZTLUMENÍ %1$d osob</item>
|
||||
<item quantity="many">ZRUŠÍ ZTLUMENÍ %1$d osoby</item>
|
||||
<item quantity="other">ZRUŠÍ ZTLUMENÍ %1$d osob</item>
|
||||
</plurals>
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<string name="napplet_consent_diff_follow_one">⚠ Tímto začnete sledovat %1$s.</string>
|
||||
<string name="napplet_consent_diff_unfollow_one">⚠ Tímto PŘESTANETE SLEDOVAT %1$s.</string>
|
||||
<string name="napplet_consent_diff_mute_one">⚠ Tímto ztlumíte %1$s.</string>
|
||||
<string name="napplet_consent_diff_unmute_one">⚠ Tímto ZRUŠÍTE ZTLUMENÍ %1$s.</string>
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<string name="napplet_consent_diff_follows">⚠ Tímto se přepíše váš seznam sledovaných: %1$s.</string>
|
||||
<string name="napplet_consent_diff_relays">⚠ Tímto se přepíše váš seznam relayů: %1$s. Vaše příspěvky a čtení se přesunou spolu s ním.</string>
|
||||
<string name="napplet_consent_diff_mutes">⚠ Tímto se přepíše váš seznam ztlumených: %1$s. Ztlumená slova a hashtagy se zde nezobrazují — celý seznam zobrazíte klepnutím na “Zobrazit událost”.</string>
|
||||
<string name="napplet_consent_diff_joiner">%1$s a %2$s</string>
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<string name="napplet_consent_diff_none">Tímto se váš stávající seznam znovu zveřejní beze změny.</string>
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<plurals name="napplet_consent_diff_no_baseline">
|
||||
<item quantity="one">⚠ Tímto se zapíše seznam s %1$d položkou. Amethyst nemá uloženou kopii k porovnání.</item>
|
||||
<item quantity="few">⚠ Tímto se zapíše seznam s %1$d položkami. Amethyst nemá uloženou kopii k porovnání.</item>
|
||||
<item quantity="many">⚠ Tímto se zapíše seznam s %1$d položkami. Amethyst nemá uloženou kopii k porovnání.</item>
|
||||
<item quantity="other">⚠ Tímto se zapíše seznam s %1$d položkami. Amethyst nemá uloženou kopii k porovnání.</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_effect_deletes">
|
||||
<item quantity="one">⚠ Tímto se požaduje smazání %1$d vaší události.</item>
|
||||
<item quantity="few">⚠ Tímto se požaduje smazání %1$d vašich událostí.</item>
|
||||
<item quantity="many">⚠ Tímto se požaduje smazání %1$d vaší události.</item>
|
||||
<item quantity="other">⚠ Tímto se požaduje smazání %1$d vašich událostí.</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_effect_tags">
|
||||
<item quantity="one">Nese %1$d značku. Klepnutím na “Zobrazit událost” uvidíte přesně, co by bylo podepsáno.</item>
|
||||
<item quantity="few">Nese %1$d značky. Klepnutím na “Zobrazit událost” uvidíte přesně, co by bylo podepsáno.</item>
|
||||
<item quantity="many">Nese %1$d značky. Klepnutím na “Zobrazit událost” uvidíte přesně, co by bylo podepsáno.</item>
|
||||
<item quantity="other">Nese %1$d značek. Klepnutím na “Zobrazit událost” uvidíte přesně, co by bylo podepsáno.</item>
|
||||
</plurals>
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Připojit k Nostr</string>
|
||||
<string name="napplet_connect_subtitle">se chce připojit k vašemu účtu Nostr</string>
|
||||
@@ -889,9 +978,13 @@
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">číst vaše soukromé zprávy</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<string name="napplet_op_decrypt_from">číst vaše soukromé zprávy s %1$s</string>
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<string name="nip46_signer_allow_always_for">Vždy povolit pro %1$s</string>
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<string name="nip46_signer_decrypt_failed">Amethyst nemohl tuto zprávu dešifrovat. Možná není určena pro tento účet.</string>
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<string name="nip46_signer_messages_with">Zprávy s</string>
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Připojené aplikace</string>
|
||||
<string name="napplet_permissions_revoke_all">Odvolat všechna oprávnění</string>
|
||||
@@ -1535,6 +1628,41 @@
|
||||
<string name="local_blossom_cache_profile_pics_only">Cachovat pouze profilové obrázky</string>
|
||||
<string name="local_blossom_cache_profile_pics_only_caption">Omezit lokální cache pouze na profilové obrázky. Obrázky a videa z feedu budou stahovány přímo z původních serverů.</string>
|
||||
<string name="no_blossom_server_message">Nemáte nastaveny žádné Blossom servery. Můžete použít Amethystův seznam, nebo přidat některý níže ↓</string>
|
||||
<string name="media_servers_upload_section">Chování při nahrávání</string>
|
||||
<string name="blossom_mirror_uploads">Zrcadlit nahrané soubory</string>
|
||||
<string name="blossom_mirror_uploads_caption">Po nahrání zkopíruje soubor na vaše další servery Blossom, aby zůstal dostupný i v případě, že některý z nich přestane fungovat.</string>
|
||||
<string name="blossom_optimize_media">Optimalizovat média na serveru</string>
|
||||
<string name="blossom_optimize_media_caption">Nahrávejte přes koncový bod /media serveru, aby mohl odstranit metadata a soubor zkomprimovat. Uložený soubor se může lišit od originálu.</string>
|
||||
<string name="manage_stored_files">Spravovat uložené soubory</string>
|
||||
<string name="my_blossom_data">Mé soubory Blossom</string>
|
||||
<string name="blossom_refresh">Obnovit</string>
|
||||
<string name="blossom_sync_all">Synchronizovat vše</string>
|
||||
<string name="blossom_sync_gaps">Některé z vašich souborů ještě nejsou na všech vašich serverech.</string>
|
||||
<string name="blossom_syncing">Kopírování vašich souborů mezi servery…</string>
|
||||
<string name="blossom_sync_done">Synchronizace dokončena</string>
|
||||
<string name="blossom_sync_cancel">Zrušit</string>
|
||||
<string name="blossom_sync_channel_name">Synchronizace Blossom</string>
|
||||
<string name="blossom_sync_channel_description">Zobrazuje průběh kopírování vašich souborů mezi servery Blossom.</string>
|
||||
<string name="manage_stored_files_empty">Na vašich serverech Blossom nebyly nalezeny žádné uložené soubory.</string>
|
||||
<string name="blossom_mirror_to_missing">Zrcadlit na chybějící</string>
|
||||
<string name="blossom_delete_from">Smazat z…</string>
|
||||
<string name="blossom_delete_from_host">Smazat z %1$s</string>
|
||||
<string name="blossom_report">Nahlásit</string>
|
||||
<string name="blossom_open">Otevřít</string>
|
||||
<string name="blossom_payment_required">Tento server vyžaduje za nahrání platbu: %1$s</string>
|
||||
<string name="blossom_payment_title">Vyžadována platba</string>
|
||||
<string name="blossom_payment_message">%1$s si za uložení tohoto souboru účtuje platbu přes Lightning. Pro pokračování zaplaťte ze své propojené peněženky.</string>
|
||||
<string name="blossom_payment_server_says">%1$s říká: “%2$s”</string>
|
||||
<string name="blossom_pay">Zaplatit</string>
|
||||
<plurals name="blossom_pay_sats">
|
||||
<item quantity="one">Zaplatit %1$d sat</item>
|
||||
<item quantity="few">Zaplatit %1$d saty</item>
|
||||
<item quantity="many">Zaplatit %1$d satu</item>
|
||||
<item quantity="other">Zaplatit %1$d satů</item>
|
||||
</plurals>
|
||||
<string name="blossom_report_title">Nahlásit blob</string>
|
||||
<string name="blossom_report_comment_hint">Důvod (nepovinné)</string>
|
||||
<string name="blossom_send">Odeslat</string>
|
||||
<string name="recommended_media_servers">Doporučené média servery</string>
|
||||
<string name="built_in_servers_description">Výchozí seznam Amethysty. Můžete je přidat jednotlivě nebo přidat seznam.</string>
|
||||
<string name="use_default_servers">Použít výchozí seznam</string>
|
||||
@@ -1578,6 +1706,11 @@
|
||||
<string name="follow_list_kind3follows_users_only">Všech Uživatelů Sledování</string>
|
||||
<string name="follow_list_kind3_follows_users_only">Výchozí seznam sledování</string>
|
||||
<string name="follow_list_aroundme">Kolem mě</string>
|
||||
<string name="follow_list_teleport">Teleportovat na místo…</string>
|
||||
<string name="follow_geohash">Sledovat toto místo</string>
|
||||
<string name="unfollow_geohash">Přestat sledovat toto místo</string>
|
||||
<string name="geo_post_posting_to">Zveřejnění na</string>
|
||||
<string name="geo_post_change_place">Změnit</string>
|
||||
<string name="follow_list_global">Globální</string>
|
||||
<string name="follow_list_curated">Kurátorováno</string>
|
||||
<string name="follow_list_mine">Moje</string>
|
||||
@@ -1992,6 +2125,9 @@
|
||||
<string name="default_relays_longer">Obnovit výchozí nastavení</string>
|
||||
<string name="geohash_title">Zveřejnit polohu jako </string>
|
||||
<string name="geohash_explainer">Přidá Geohash vaší polohy do příspěvku. Veřejnost bude vědět, že se nacházíte do 5 km od aktuální polohy</string>
|
||||
<string name="geohash_teleport_action">✈ Teleportovat sem</string>
|
||||
<string name="location_pick_on_map">Vyberte místo na mapě</string>
|
||||
<string name="location_change_place">Změnit místo na mapě</string>
|
||||
<string name="geohash_exclusive">Lokace-Exkluzívní příspěvek</string>
|
||||
<string name="geohash_exclusive_explainer">Uvidí to pouze následovníci umístění. Tvoji obecní následovníci to neuvidí.</string>
|
||||
<string name="hashtag_exclusive">Hashtag-exkluzivní příspěvek</string>
|
||||
@@ -2188,7 +2324,19 @@
|
||||
<string name="relay_group_field_topics_hint">bitcoin, nostr, umění</string>
|
||||
<string name="relay_group_field_geohash">Poloha (geohash)</string>
|
||||
<string name="relay_group_field_geohash_hint">u0nd</string>
|
||||
<string name="relay_group_location_add">Přidat místo</string>
|
||||
<string name="relay_group_location_add_desc">Připněte svou skupinu na mapu, aby ji lidé v okolí mohli objevit.</string>
|
||||
<string name="relay_group_location_edit">Změnit místo</string>
|
||||
<string name="relay_group_location_clear">Odebrat místo</string>
|
||||
<string name="relay_group_location_manual">Zadat geohash ručně</string>
|
||||
<!-- Reusable geohash location picker (shared, not group-specific) -->
|
||||
<string name="location_picker_title">Zvolit místo</string>
|
||||
<string name="location_picker_hint">Posuňte mapu, vyhledejte místo nebo použijte svou aktuální polohu.</string>
|
||||
<string name="location_picker_search_hint">Vyhledejte město nebo adresu</string>
|
||||
<string name="location_picker_search_empty">Nebylo nalezeno žádné odpovídající místo.</string>
|
||||
<string name="location_picker_use_mine">Použít mou aktuální polohu</string>
|
||||
<string name="location_picker_area">Velikost oblasti</string>
|
||||
<string name="location_picker_confirm">Použít toto místo</string>
|
||||
<string name="relay_group_section_structure">Struktura</string>
|
||||
<string name="relay_group_parent_desc">Vnořte tuto skupinu pod nadřazenou a vytvořte hierarchii.</string>
|
||||
<string name="relay_group_parent_label">Nadřazená skupina</string>
|
||||
@@ -2211,6 +2359,8 @@
|
||||
<string name="relay_group_discovery_empty_filtered">Pro tento filtr zatím nebyly nalezeny žádné skupiny.</string>
|
||||
<string name="relay_group_favorite_relay">Oblíbit tento relay</string>
|
||||
<string name="relay_group_threads_empty">Zatím žádná vlákna. Založte jedno tlačítkem +.</string>
|
||||
<string name="relay_group_threads_loading_older">Načítání starších vláken…</string>
|
||||
<string name="relay_group_threads_all_caught_up">Žádná starší vlákna</string>
|
||||
<string name="relay_group_thread_new">Nové vlákno</string>
|
||||
<string name="relay_group_thread_untitled">Bez názvu</string>
|
||||
<string name="relay_group_thread_title_label">Název</string>
|
||||
@@ -2332,6 +2482,8 @@
|
||||
<string name="error_dialog_pay_withdraw_error">Nepodařilo se vybrat</string>
|
||||
<string name="cashu_failed_redemption">Nepodařilo se použít Cashu</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Mint poskytl následující chybovou zprávu: %1$s</string>
|
||||
<string name="cashu_unsafe_mint_url">Nebezpečná adresa Cashu mintu</string>
|
||||
<string name="cashu_unsafe_mint_url_explainer">Amethyst nekontaktoval mint tohoto tokenu. %1$s</string>
|
||||
<string name="cashu_successful_redemption">Obdrženo Cashu</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sats bylo odesláno do vaší peněženky. (Poplatek: %2$s sats)</string>
|
||||
<string name="cashu_no_wallet_found">V systému nebyla nalezena žádná kompatibilní peněženka Cashu</string>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<string name="referenced_event_not_found">Referenziertes Ereignis nicht gefunden</string>
|
||||
<string name="could_not_decrypt_the_message">Nachricht konnte nicht entschlüsselt werden</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="chat_preview_decrypting">Wird entschlüsselt…</string>
|
||||
<string name="group_picture">Gruppenbild</string>
|
||||
<string name="explicit_content">Anstößiger Inhalt</string>
|
||||
<string name="relay_notice">Relay-Hinweis</string>
|
||||
@@ -302,6 +303,10 @@
|
||||
<string name="concord_invite_failed_invalid">Dieser Einladungslink ist ungültig oder kann mit diesem Konto nicht geöffnet werden.</string>
|
||||
<string name="concord_invite_failed_incompatible">Dieser Einladungslink kann nicht geöffnet werden. Er ist möglicherweise veraltet, wurde bereits durch einen neueren ersetzt oder mit einer neueren Version der App erstellt. Bitte um einen neuen Einladungslink.</string>
|
||||
<string name="concord_invite_failed_revoked">Dieser Einladungslink wurde widerrufen und kann nicht mehr verwendet werden. Bitte um einen neuen.</string>
|
||||
<string name="concord_invite_failed_expired">Dieser Einladungslink ist abgelaufen und kann nicht mehr verwendet werden. Bitte um einen neuen Link.</string>
|
||||
<string name="concord_invite_preview_unknown_name">Der Name der Community wird erst nach dem Beitritt angezeigt</string>
|
||||
<string name="concord_invite_preview_explainer">Beim Beitreten verbindest du dich mit den Relays dieser Einladung, veröffentlichst eine mit deinem Konto signierte Beitrittsankündigung und fügst die Community deiner Liste hinzu. Es wird nichts gesendet, bis du auf Beitreten tippst.</string>
|
||||
<string name="concord_invite_preview_relays">Relays, die diese Einladung kontaktieren wird: %1$s</string>
|
||||
<string name="concord_home_title">Concord-Kanäle</string>
|
||||
<string name="concord_home_empty">Du bist noch keinen Concord-Kanälen beigetreten. Erstelle einen oder öffne einen Einladungslink.</string>
|
||||
<string name="concord_channels_empty">Noch keine Kanäle.</string>
|
||||
@@ -317,6 +322,10 @@
|
||||
<string name="concord_channel_delete_title">Kanal löschen?</string>
|
||||
<string name="concord_channel_delete_message">#%1$s löschen? Dies kann nicht rückgängig gemacht werden und der Kanal kann nicht mit derselben ID neu erstellt werden.</string>
|
||||
<string name="concord_channel_delete_confirm">Löschen</string>
|
||||
<string name="concord_leave_community">Community verlassen</string>
|
||||
<string name="concord_leave_title">Community verlassen?</string>
|
||||
<string name="concord_leave_message">%1$s verlassen? Sie wird aus der Liste dieses Kontos entfernt und hört auf, sich auf deinen Geräten zu synchronisieren. Die Community wird nicht benachrichtigt und du wirst nicht aus ihrer Mitgliederliste entfernt. Nachrichten, die du nicht mehr entschlüsseln kannst, sind möglicherweise nicht wiederherstellbar, und du kannst nur mit einer neuen Einladung zurückkehren.</string>
|
||||
<string name="concord_leave_owner_warning">Du hast diese Community erstellt. Beim Verlassen wird sie weder gelöscht noch an jemand anderen übergeben, aber der auf deiner Liste gespeicherte Eigentümerschlüssel wird verworfen — du könntest sie nicht mehr verwalten.</string>
|
||||
<string name="concord_edit_relays_desc">Wo die verschlüsselten Planes dieser Community veröffentlicht und gelesen werden.</string>
|
||||
<string name="concord_typing_one">%1$s schreibt…</string>
|
||||
<string name="concord_typing_two">%1$s und %2$s schreiben…</string>
|
||||
@@ -361,6 +370,13 @@
|
||||
<string name="concord_members_remove_title">Mitglied entfernen?</string>
|
||||
<string name="concord_members_remove_message">Dies rotiert den Verschlüsselungsschlüssel der Community, sodass dieses Mitglied nichts mehr lesen kann, was danach gesendet wird. Für alle anderen wird der Schlüssel automatisch erneuert. Dies kann nicht rückgängig gemacht werden.</string>
|
||||
<string name="concord_members_remove_confirm">Entfernen</string>
|
||||
<string name="concord_members_roles">Rollen…</string>
|
||||
<string name="concord_members_roles_title">Rollen zuweisen</string>
|
||||
<string name="concord_members_roles_message">Wähle jede Rolle aus, die dieses Mitglied haben soll. Das Abwählen einer Rolle entfernt sie.</string>
|
||||
<string name="concord_members_roles_save">Speichern</string>
|
||||
<string name="concord_members_roles_out_of_reach">Du stehst nicht über diesem Mitglied</string>
|
||||
<string name="concord_members_roles_none_assignable">Keine Rollen, die du zuweisen kannst</string>
|
||||
<string name="concord_members_roles_failed">Die Rollen dieses Mitglieds konnten nicht aktualisiert werden.</string>
|
||||
<string name="concord_role_owner">Eigentümer</string>
|
||||
<string name="concord_role_admin">Administrator</string>
|
||||
<string name="concord_role_banned">Gesperrt</string>
|
||||
@@ -485,6 +501,15 @@
|
||||
<string name="share_as_image_url">Als Bild-URL teilen</string>
|
||||
<string name="share_as_image_generating">Vorschau wird erstellt…</string>
|
||||
<string name="share_as_image_watermark">Geteilt über Amethyst</string>
|
||||
<string name="share_as_qr">Als QR teilen</string>
|
||||
<string name="share_as_qr_mode_web">Web-Link</string>
|
||||
<string name="share_as_qr_mode_nostr">Nostr-Link</string>
|
||||
<string name="share_as_qr_hint_web">Mit jeder Handykamera scannen</string>
|
||||
<string name="share_as_qr_hint_nostr">Mit einer Nostr-App scannen</string>
|
||||
<string name="share_as_qr_kind_picture">Bild</string>
|
||||
<string name="share_as_qr_code_description_web">QR-Code mit einem Web-Link zu dieser Notiz</string>
|
||||
<string name="share_as_qr_code_description_nostr">QR-Code mit einem Nostr-Link zu dieser Notiz</string>
|
||||
<string name="share_as_qr_thumbnail_hidden_sensitive">Vorschaubild bei sensiblen Inhalten ausgeblendet</string>
|
||||
<string name="quick_action_copy_user_id">Autoren-ID kopieren</string>
|
||||
<string name="quick_action_copy_note_id">Beitrags-ID kopieren</string>
|
||||
<string name="quick_action_copy_text">Text kopieren</string>
|
||||
@@ -812,6 +837,7 @@
|
||||
<string name="napplet_consent_storage">Dieses nApplet möchte seinen privaten Speicher verwenden.</string>
|
||||
<string name="napplet_consent_pay">Dieses nApplet möchte eine Lightning-Rechnung bezahlen.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_pay_no_amount">⚠ Dieses nApplet möchte eine Lightning-Rechnung bezahlen, die KEINEN Betrag angibt — der Zahlungsempfänger entscheidet, wie viel entnommen wird. Erlaube dies nur, wenn du ihm vertraust.</string>
|
||||
<string name="napplet_consent_resource">Dieses nApplet möchte eine Web-Ressource abrufen.</string>
|
||||
<string name="napplet_consent_upload">Dieses nApplet möchte eine Datei auf Ihren Medienserver hochladen.</string>
|
||||
<string name="napplet_consent_notify">Dieses nApplet möchte Ihnen Benachrichtigungen anzeigen.</string>
|
||||
@@ -824,11 +850,56 @@
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<plurals name="napplet_consent_diff_follow_added">
|
||||
<item quantity="one">folgt %1$d neuem Konto</item>
|
||||
<item quantity="other">folgt %1$d neuen Konten</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_follow_removed">
|
||||
<item quantity="one">ENTFOLGT %1$d Konto</item>
|
||||
<item quantity="other">ENTFOLGT %1$d Konten</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_relay_added">
|
||||
<item quantity="one">fügt %1$d Relay hinzu</item>
|
||||
<item quantity="other">fügt %1$d Relays hinzu</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_relay_removed">
|
||||
<item quantity="one">ENTFERNT %1$d Relay</item>
|
||||
<item quantity="other">ENTFERNT %1$d Relays</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_mute_added">
|
||||
<item quantity="one">schaltet %1$d weitere Person stumm</item>
|
||||
<item quantity="other">schaltet %1$d weitere Personen stumm</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_mute_removed">
|
||||
<item quantity="one">HEBT DIE STUMMSCHALTUNG von %1$d Person AUF</item>
|
||||
<item quantity="other">HEBT DIE STUMMSCHALTUNG von %1$d Personen AUF</item>
|
||||
</plurals>
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<string name="napplet_consent_diff_follow_one">⚠ Dies folgt %1$s.</string>
|
||||
<string name="napplet_consent_diff_unfollow_one">⚠ Dies ENTFOLGT %1$s.</string>
|
||||
<string name="napplet_consent_diff_mute_one">⚠ Dies stummschaltet %1$s.</string>
|
||||
<string name="napplet_consent_diff_unmute_one">⚠ Dies HEBT DIE STUMMSCHALTUNG von %1$s AUF.</string>
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<string name="napplet_consent_diff_follows">⚠ Dies überschreibt deine Folgeliste: %1$s.</string>
|
||||
<string name="napplet_consent_diff_relays">⚠ Dies überschreibt deine Relay-Liste: %1$s. Deine Beiträge und Lesevorgänge ziehen mit um.</string>
|
||||
<string name="napplet_consent_diff_mutes">⚠ Dies überschreibt deine Stummschaltungsliste: %1$s. Stummgeschaltete Wörter und Hashtags werden hier nicht angezeigt — tippe auf “Ereignis anzeigen” für die vollständige Liste.</string>
|
||||
<string name="napplet_consent_diff_joiner">%1$s und %2$s</string>
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<string name="napplet_consent_diff_none">Dies veröffentlicht deine bestehende Liste unverändert erneut.</string>
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<plurals name="napplet_consent_diff_no_baseline">
|
||||
<item quantity="one">⚠ Dies schreibt eine Liste mit %1$d Eintrag. Amethyst hat keine zwischengespeicherte Kopie zum Vergleich.</item>
|
||||
<item quantity="other">⚠ Dies schreibt eine Liste mit %1$d Einträgen. Amethyst hat keine zwischengespeicherte Kopie zum Vergleich.</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_effect_deletes">
|
||||
<item quantity="one">⚠ Dies fordert die Löschung von %1$d deiner Ereignisse an.</item>
|
||||
<item quantity="other">⚠ Dies fordert die Löschung von %1$d deiner Ereignisse an.</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_effect_tags">
|
||||
<item quantity="one">Enthält %1$d Tag. Tippe auf “Ereignis anzeigen”, um genau zu sehen, was signiert würde.</item>
|
||||
<item quantity="other">Enthält %1$d Tags. Tippe auf “Ereignis anzeigen”, um genau zu sehen, was signiert würde.</item>
|
||||
</plurals>
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Mit Nostr verbinden</string>
|
||||
<string name="napplet_connect_subtitle">möchte sich mit deinem Nostr-Konto verbinden</string>
|
||||
@@ -865,9 +936,13 @@
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">deine privaten Nachrichten lesen</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<string name="napplet_op_decrypt_from">deine privaten Nachrichten mit %1$s lesen</string>
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<string name="nip46_signer_allow_always_for">Immer erlauben für %1$s</string>
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<string name="nip46_signer_decrypt_failed">Amethyst konnte diese Nachricht nicht entschlüsseln. Sie ist möglicherweise nicht an dieses Konto adressiert.</string>
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<string name="nip46_signer_messages_with">Nachrichten mit</string>
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Verbundene Apps</string>
|
||||
<string name="napplet_permissions_revoke_all">Alle Berechtigungen widerrufen</string>
|
||||
@@ -1491,6 +1566,39 @@
|
||||
<string name="local_blossom_cache_profile_pics_only">Nur Profilbilder cachen</string>
|
||||
<string name="local_blossom_cache_profile_pics_only_caption">Den lokalen Cache auf Profilbilder beschränken. Feed-Bilder und -Videos werden direkt von den Originalservern geladen.</string>
|
||||
<string name="no_blossom_server_message">Du hast keine Blossom Server gesetzt. Du kannst die Amethyst-Liste verwenden oder unten einen hinzufügen ↓</string>
|
||||
<string name="media_servers_upload_section">Upload-Verhalten</string>
|
||||
<string name="blossom_mirror_uploads">Uploads spiegeln</string>
|
||||
<string name="blossom_mirror_uploads_caption">Nach dem Hochladen wird die Datei auf deine anderen Blossom-Server kopiert, damit sie verfügbar bleibt, falls einer offline geht.</string>
|
||||
<string name="blossom_optimize_media">Medien auf dem Server optimieren</string>
|
||||
<string name="blossom_optimize_media_caption">Lade über den /media-Endpunkt des Servers hoch, damit dieser Metadaten entfernen und die Datei komprimieren kann. Die gespeicherte Datei kann sich vom Original unterscheiden.</string>
|
||||
<string name="manage_stored_files">Gespeicherte Dateien verwalten</string>
|
||||
<string name="my_blossom_data">Meine Blossom-Dateien</string>
|
||||
<string name="blossom_refresh">Aktualisieren</string>
|
||||
<string name="blossom_sync_all">Alle synchronisieren</string>
|
||||
<string name="blossom_sync_gaps">Einige deiner Dateien liegen noch nicht auf allen deinen Servern.</string>
|
||||
<string name="blossom_syncing">Deine Dateien werden über die Server kopiert…</string>
|
||||
<string name="blossom_sync_done">Synchronisierung abgeschlossen</string>
|
||||
<string name="blossom_sync_cancel">Abbrechen</string>
|
||||
<string name="blossom_sync_channel_name">Blossom-Synchronisierung</string>
|
||||
<string name="blossom_sync_channel_description">Zeigt den Fortschritt beim Kopieren deiner Dateien über deine Blossom-Server hinweg.</string>
|
||||
<string name="manage_stored_files_empty">Keine gespeicherten Dateien auf deinen Blossom-Servern gefunden.</string>
|
||||
<string name="blossom_mirror_to_missing">Auf fehlende spiegeln</string>
|
||||
<string name="blossom_delete_from">Löschen von…</string>
|
||||
<string name="blossom_delete_from_host">Löschen von %1$s</string>
|
||||
<string name="blossom_report">Melden</string>
|
||||
<string name="blossom_open">Öffnen</string>
|
||||
<string name="blossom_payment_required">Dieser Server verlangt eine Zahlung zum Hochladen: %1$s</string>
|
||||
<string name="blossom_payment_title">Zahlung erforderlich</string>
|
||||
<string name="blossom_payment_message">%1$s verlangt eine Lightning-Zahlung, um diese Datei zu speichern. Bezahle aus deiner verbundenen Wallet, um fortzufahren.</string>
|
||||
<string name="blossom_payment_server_says">%1$s sagt: “%2$s”</string>
|
||||
<string name="blossom_pay">Bezahlen</string>
|
||||
<plurals name="blossom_pay_sats">
|
||||
<item quantity="one">%1$d Sat bezahlen</item>
|
||||
<item quantity="other">%1$d Sats bezahlen</item>
|
||||
</plurals>
|
||||
<string name="blossom_report_title">Blob melden</string>
|
||||
<string name="blossom_report_comment_hint">Grund (optional)</string>
|
||||
<string name="blossom_send">Senden</string>
|
||||
<string name="recommended_media_servers">Empfohlene Medienserver</string>
|
||||
<string name="built_in_servers_description">Amethysts Standardliste. Du kannst einzelne Server hinzufügen oder die gesamte Liste auf einmal übernehmen.</string>
|
||||
<string name="use_default_servers">Standardliste verwenden</string>
|
||||
@@ -1534,6 +1642,11 @@
|
||||
<string name="follow_list_kind3follows_users_only">Alle Benutzer Folgen</string>
|
||||
<string name="follow_list_kind3_follows_users_only">Standard-Folgenliste</string>
|
||||
<string name="follow_list_aroundme">In der Nähe</string>
|
||||
<string name="follow_list_teleport">Zu einem Ort teleportieren…</string>
|
||||
<string name="follow_geohash">Diesem Ort folgen</string>
|
||||
<string name="unfollow_geohash">Diesem Ort nicht mehr folgen</string>
|
||||
<string name="geo_post_posting_to">Veröffentlichen in</string>
|
||||
<string name="geo_post_change_place">Ändern</string>
|
||||
<string name="follow_list_global">Weltweit</string>
|
||||
<string name="follow_list_curated">Kuratiert</string>
|
||||
<string name="follow_list_mine">Meine</string>
|
||||
@@ -1944,6 +2057,10 @@
|
||||
<string name="default_relays_longer">Auf Standardeinstellung zurücksetzen</string>
|
||||
<string name="geohash_title">Ort preisgeben als </string>
|
||||
<string name="geohash_explainer">Fügt dem Beitrag einen Geohash Ihres Standorts hinzu. Die Öffentlichkeit wird wissen, dass du dich innerhalb von 5 km (3 mi) vom aktuellen Standort befinden</string>
|
||||
<string name="geohash_teleport_title">Teleportieren</string>
|
||||
<string name="geohash_teleport_action">✈ Hierher teleportieren</string>
|
||||
<string name="location_pick_on_map">Wähle einen Ort auf der Karte</string>
|
||||
<string name="location_change_place">Ort auf der Karte ändern</string>
|
||||
<string name="geohash_exclusive">Standort-exklusiver Beitrag</string>
|
||||
<string name="geohash_exclusive_explainer">Nur Follower des Ortes werden es sehen. Deine allgemeinen Follower werden es nicht sehen.</string>
|
||||
<string name="hashtag_exclusive">Hashtag-exklusive Beitrag</string>
|
||||
@@ -2136,7 +2253,19 @@
|
||||
<string name="relay_group_field_topics_hint">bitcoin, nostr, kunst</string>
|
||||
<string name="relay_group_field_geohash">Ort (Geohash)</string>
|
||||
<string name="relay_group_field_geohash_hint">u0nd</string>
|
||||
<string name="relay_group_location_add">Einen Ort hinzufügen</string>
|
||||
<string name="relay_group_location_add_desc">Markiere deine Gruppe auf einer Karte, damit Leute in der Nähe sie entdecken können.</string>
|
||||
<string name="relay_group_location_edit">Ort ändern</string>
|
||||
<string name="relay_group_location_clear">Ort entfernen</string>
|
||||
<string name="relay_group_location_manual">Einen Geohash manuell eingeben</string>
|
||||
<!-- Reusable geohash location picker (shared, not group-specific) -->
|
||||
<string name="location_picker_title">Ort wählen</string>
|
||||
<string name="location_picker_hint">Verschiebe die Karte, suche nach einem Ort oder verwende deinen aktuellen Standort.</string>
|
||||
<string name="location_picker_search_hint">Nach einer Stadt oder Adresse suchen</string>
|
||||
<string name="location_picker_search_empty">Kein passender Ort gefunden.</string>
|
||||
<string name="location_picker_use_mine">Meinen aktuellen Standort verwenden</string>
|
||||
<string name="location_picker_area">Gebietsgröße</string>
|
||||
<string name="location_picker_confirm">Diesen Ort verwenden</string>
|
||||
<string name="relay_group_section_structure">Struktur</string>
|
||||
<string name="relay_group_parent_desc">Ordne diese Gruppe einer übergeordneten Gruppe unter, um eine Hierarchie aufzubauen.</string>
|
||||
<string name="relay_group_parent_label">Übergeordnete Gruppe</string>
|
||||
@@ -2159,6 +2288,8 @@
|
||||
<string name="relay_group_discovery_empty_filtered">Noch keine Gruppen für diesen Filter gefunden.</string>
|
||||
<string name="relay_group_favorite_relay">Dieses Relay favorisieren</string>
|
||||
<string name="relay_group_threads_empty">Noch keine Threads. Starte einen mit der +-Schaltfläche.</string>
|
||||
<string name="relay_group_threads_loading_older">Ältere Threads werden geladen…</string>
|
||||
<string name="relay_group_threads_all_caught_up">Keine älteren Threads</string>
|
||||
<string name="relay_group_thread_new">Neuer Thread</string>
|
||||
<string name="relay_group_thread_untitled">Ohne Titel</string>
|
||||
<string name="relay_group_thread_title_label">Titel</string>
|
||||
@@ -2272,6 +2403,8 @@
|
||||
<string name="error_dialog_pay_withdraw_error">Konnte nicht abheben</string>
|
||||
<string name="cashu_failed_redemption">Cashu konnte nicht eingelöst werden</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Mint hat folgende Fehlermeldung geliefert: %1$s</string>
|
||||
<string name="cashu_unsafe_mint_url">Unsichere Cashu-Mint-Adresse</string>
|
||||
<string name="cashu_unsafe_mint_url_explainer">Amethyst hat die Mint dieses Tokens nicht kontaktiert. %1$s</string>
|
||||
<string name="cashu_successful_redemption">Cashu erhalten</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s Sats wurden an Ihre Wallet gesendet. (Gebühren: %2$s Sats)</string>
|
||||
<string name="cashu_no_wallet_found">Keine kompatible Cashu-Brieftasche im System gefunden</string>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<string name="referenced_event_not_found">उद्धृत घटना अप्राप्त</string>
|
||||
<string name="could_not_decrypt_the_message">सन्देश का अरहस्यीकरण असफल</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="chat_preview_decrypting">अरसहस्यीकरण…</string>
|
||||
<string name="group_picture">समूह चित्र</string>
|
||||
<string name="explicit_content">अभद्र विषयवस्तु</string>
|
||||
<string name="relay_notice">पुनःप्रसारक सूचना</string>
|
||||
@@ -302,6 +303,10 @@
|
||||
<string name="concord_invite_failed_invalid">यह आमन्त्रण योजक अमान्य है अथवा इस लेखा द्वारा खोला नहीं जा सकता।</string>
|
||||
<string name="concord_invite_failed_incompatible">इस आमन्त्रण को खोला नहीं जा सकता। सम्भावना है कि यह पुराना हो चुका है अथवा नवीनतर द्वारा प्रतिस्थापित अथवा एक नवीनतर क्रमक संस्करण द्वारा बनाया गया। एक नए आमन्त्रण योजक के लिए पूछें।</string>
|
||||
<string name="concord_invite_failed_revoked">यह आमन्त्रण योजक निराकृत है तथा अनुपयुक्तव्य। नए के लिए पूछें।</string>
|
||||
<string name="concord_invite_failed_expired">यह आमन्त्रण योजक पुराना हो चुका है तथा अनुपयुक्तव्य। नए योजक के लिए पूछें।</string>
|
||||
<string name="concord_invite_preview_unknown_name">समुदाय नाम आप जुडने के पश्चात ही बताया जाएगा</string>
|
||||
<string name="concord_invite_preview_explainer">जुडने से इस आमन्त्रण के पुनःप्रसारकों से संयोजन किया जाएगा। तथा आपके लेखा से हस्ताक्षरित एक जोड घोषणा प्रकाशित किया जाएगा। तथा समुदाय को आपकी सूची में जोडा जाएगा। कुछ नहीं भेजा जाएगा जुडें दबाने तक।</string>
|
||||
<string name="concord_invite_preview_relays">पुनःप्रसारक जिनसे सम्पर्क किया जाएगा इस आमन्त्रण द्वारा : %1$s</string>
|
||||
<string name="concord_home_title">कोंकोर्ड॰ प्रणाली</string>
|
||||
<string name="concord_home_empty">आप किसी भी कोंकोर्ड प्रणाली से जुडे नहीं अब तक। एक बनाएँ अथवा आमन्त्रण योजक खोलें।</string>
|
||||
<string name="concord_channels_empty">कोई प्रणाली नहीं अब तक।</string>
|
||||
@@ -317,6 +322,10 @@
|
||||
<string name="concord_channel_delete_title">क्या प्रणाली मिटा दें।</string>
|
||||
<string name="concord_channel_delete_message">क्या #%1$s को मिटा दें। इसको पूर्ववत नहीं किया जा सकता। तथा उसी विभेदक के साथ प्रणाली का पुनःउत्पादन नहीं किया जा सकता।</string>
|
||||
<string name="concord_channel_delete_confirm">मिटाएँ</string>
|
||||
<string name="concord_leave_community">समुदाय छोडें</string>
|
||||
<string name="concord_leave_title">क्या समुदाय छोडें।</string>
|
||||
<string name="concord_leave_message">क्या %1$s छोडें। इसे हटाया जाएगा इस लेखा की सूची से तथा आपके यन्त्रों पर समचरणीकरण रुक जाएगा। समुदाय को सूचित नहीं किया जाएगा। तथा आपको उसके सदस्य कार्यसूची से हटाया नहीं जाएगा। सन्देश जिनका आप अरहस्यीकरण नहीं कर सकेंगे सम्भाव्यतः पुनःप्राप्तव्य नहीं होंगे। तथा आप केवल नए आमन्त्रण के साथ लौट सकेंगे।</string>
|
||||
<string name="concord_leave_owner_warning">आपने इस समुदाय को बनाया। छोड जाने से यह मिटेगा नहीं। किसी अन्य के हाथ सौंपा नहीं जाएगा। परन्तु स्वत्वधारी कुंचिका जो आपकी सूची में हैं वह हटाया जाएगा। आप आगे से इसका प्रबन्धन नहीं कर पाएँगे।</string>
|
||||
<string name="concord_edit_relays_desc">जहाँ इस समुदाय के रहस्यीकृत पत्रों का प्रकाशन तथा पठन किया जाता है।</string>
|
||||
<string name="concord_typing_one">%1$s टंकण मध्य…</string>
|
||||
<string name="concord_typing_two">%1$s तथा %2$s टंकण मध्य…</string>
|
||||
@@ -361,6 +370,13 @@
|
||||
<string name="concord_members_remove_title">क्या सदस्य हटाएँ।</string>
|
||||
<string name="concord_members_remove_message">इस से समुदाय की रहस्यीकरण कुंचिका का वर्तन किया जाएगा जिससे कि यह सदस्य आगे से कुछ भी प्रेषित वस्तु पढ नहीं सकेगा। अन्य सभी स्वचालिततः पुनःकुंचिकायुक्त किए जाएँगे। इसको पूर्ववत नहीं किया जा सकता।</string>
|
||||
<string name="concord_members_remove_confirm">हटाएँ</string>
|
||||
<string name="concord_members_roles">भूमिकाएँ…</string>
|
||||
<string name="concord_members_roles_title">भूमिकाएँ सौंपें</string>
|
||||
<string name="concord_members_roles_message">सभी भूमिकाओं का चयन करें जो इस सदस्य को रखना चाहिए। भूमिका अचयनित करने से उसे हटाया जाएगा।</string>
|
||||
<string name="concord_members_roles_save">अभिलेखन</string>
|
||||
<string name="concord_members_roles_out_of_reach">आप श्रेणी में इस सदस्य के पद से ऊपर नहीं</string>
|
||||
<string name="concord_members_roles_none_assignable">कोई भूमिकाएँ नहीं जिन्हें आप सौंप सकते हैं</string>
|
||||
<string name="concord_members_roles_failed">इस सदस्य के भूमिकाओं का अद्यतन करने में असफल।</string>
|
||||
<string name="concord_role_owner">स्वत्वधारी</string>
|
||||
<string name="concord_role_admin">प्रशासक</string>
|
||||
<string name="concord_role_banned">प्रतिबन्धित</string>
|
||||
@@ -812,6 +828,7 @@
|
||||
<string name="napplet_consent_storage">यह नोस्टर संलग्नक्रमक अपना निजी भण्डार का प्रयेग करना चाहता है।</string>
|
||||
<string name="napplet_consent_pay">यह नोस्टर संलग्नक्रमक एक लैटनिंग चालान का भुगतान करना चाहता है।</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_pay_no_amount">सावधान। यह नोस्टरसंलग्नक्रमक एक लैटनिंग चालान का भुगतान करना चाहता है जिसमें कोई मात्रा स्पष्टीकृत नहीं। प्राप्तकर्ता निर्णय करेगा कितना लिया जाएगा। इसे तभी अनुमति दें यदि आप इस पर विश्वास करते हैं।</string>
|
||||
<string name="napplet_consent_resource">यह नोस्टर संलग्नक्रमक एक जाल संसाधन लाना चाहता है।</string>
|
||||
<string name="napplet_consent_upload">यह नोस्टर संलग्नक्रमक एक अभिलेख को आपके प्रसारसंगणक तक आरोहण करना चाहता है।</string>
|
||||
<string name="napplet_consent_notify">यह नोस्टर संलग्नक्रमक आपको सूचनाएँ दिखाना चाहता है।</string>
|
||||
@@ -824,11 +841,56 @@
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<plurals name="napplet_consent_diff_follow_added">
|
||||
<item quantity="one">%1$d नए लेखा का अनुचरण करता है</item>
|
||||
<item quantity="other">%1$d नए लेखाओं का अनुचरण करता है</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_follow_removed">
|
||||
<item quantity="one">%1$d लेखा का अनुचरण नहीं करता</item>
|
||||
<item quantity="other">%1$d लेखाओं का अनुचरण नहीं करता</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_relay_added">
|
||||
<item quantity="one">%1$d पुनःप्रसारक जोडता है</item>
|
||||
<item quantity="other">%1$d पुनःप्रसारकों को जोडता है</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_relay_removed">
|
||||
<item quantity="one">%1$d पुनःप्रसारक हटाता है</item>
|
||||
<item quantity="other">%1$d पुनःप्रसारकों को हटाता है</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_mute_added">
|
||||
<item quantity="one">%1$d अधिक व्यक्ति मौन करता है</item>
|
||||
<item quantity="other">%1$d अधिक लोगों को मौन करता है</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_mute_removed">
|
||||
<item quantity="one">%1$d व्यक्ति की मौन अवस्था हटाता है</item>
|
||||
<item quantity="other">%1$d लोगों की मौन अवस्था हटाता है</item>
|
||||
</plurals>
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<string name="napplet_consent_diff_follow_one">सावधान। यह %1$s का अनुचरण करता है।</string>
|
||||
<string name="napplet_consent_diff_unfollow_one">सावधान। यह %1$s का अनुचरण नहीं करता।</string>
|
||||
<string name="napplet_consent_diff_mute_one">सावधान। यह %1$s को मौन करता है।</string>
|
||||
<string name="napplet_consent_diff_unmute_one">सावधान। यह %1$s की मौन अवस्था हटाता है।</string>
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<string name="napplet_consent_diff_follows">सावधान। यह आपकी अनुचरण सूची का पुनःलेखन करता है : %1$s।</string>
|
||||
<string name="napplet_consent_diff_relays">सावधान। यह आपकी पुनःप्रसारक सूची का पुनःलेखन करता है : %1$s। आपके पत्र तथा पठनसूची इसके साथ विस्थापित होंगे।</string>
|
||||
<string name="napplet_consent_diff_mutes">सावधान। यह आपकी मौन सूची का पुनःलेखन करता है : %1$s। मौनकृत शब्द तथा विषयसूचक यहाँ नहीं दिखाया जाता। घटना दिखाओ दबाएँ पूर्ण सूची के लिए।</string>
|
||||
<string name="napplet_consent_diff_joiner">%1$s तथा %2$s</string>
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<string name="napplet_consent_diff_none">यह आपकी वर्तमान सूची का पुनःप्रकाशन करता है अपरिवर्तित।</string>
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<plurals name="napplet_consent_diff_no_baseline">
|
||||
<item quantity="one">सावधान। यह आपकी %1$d प्रविष्ट की सूची का लेखन करता है। अमेथिस्ट में कोई पूर्वस्मृत संस्करण नहीं तुलना करने के लिए।</item>
|
||||
<item quantity="other">सावधान। यह आपकी %1$d प्रविष्टों की सूची का लेखन करता है। अमेथिस्ट में कोई पूर्वस्मृत संस्करण नहीं तुलना करने के लिए।</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_effect_deletes">
|
||||
<item quantity="one">सावधान। यह आपके घटनाओं में से %1$d को मिटाने का अनुरोध करता है।</item>
|
||||
<item quantity="other">सावधान। यह आपके घटनाओं में से %1$d को मिटाने का अनुरोध करता है।</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_effect_tags">
|
||||
<item quantity="one">%1$d विषयसूचक धारी। घटना दिखाओ दबाएँ यह देखने के लिए कि वस्तुतः क्या हस्ताक्षरित होगा।</item>
|
||||
<item quantity="other">%1$d विषयसूचक धारी। घटना दिखाओ दबाएँ यह देखने के लिए कि वस्तुतः क्या हस्ताक्षरित होगा।</item>
|
||||
</plurals>
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">नोस्टर से संयोजन</string>
|
||||
<string name="napplet_connect_subtitle">आपके नोस्टर लेखा के साथ संयोजन करना चाहता है</string>
|
||||
@@ -865,9 +927,13 @@
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">आपके निजी सन्देशों का पठन</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<string name="napplet_op_decrypt_from">आपके निजी सन्देशों का पठन %1$s के द्वारा</string>
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<string name="nip46_signer_allow_always_for">सर्वदा अनुमत %1$s के लिए</string>
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<string name="nip46_signer_decrypt_failed">अमेथिस्ट द्वारा इस सन्देश का अरहस्यीकरण असफल। सम्भावना है कि यह इस लेखा के प्रति सम्बोधित नहीं।</string>
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<string name="nip46_signer_messages_with">सन्देश इनके साथ</string>
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">संयोजित क्रमक</string>
|
||||
<string name="napplet_permissions_revoke_all">सभी अनुमतियों का निराकरण</string>
|
||||
@@ -1515,6 +1581,7 @@
|
||||
<string name="blossom_payment_required">भुगतान आवश्यक इस सेवासंगणक तक आरोहण के लिए : %1$s</string>
|
||||
<string name="blossom_payment_title">भुगतान आवश्यक</string>
|
||||
<string name="blossom_payment_message">%1$s पर अभिलेख रखने के लिए लैटनिंग भुगतान आवश्यक। आपके संयोजित धनकोष से भुगतान करें आगे बढने के लिए।</string>
|
||||
<string name="blossom_payment_server_says">%1$s कहता है : %2$s</string>
|
||||
<string name="blossom_pay">भुगतान</string>
|
||||
<plurals name="blossom_pay_sats">
|
||||
<item quantity="one">%1$d साट् भुगतान</item>
|
||||
@@ -2330,6 +2397,8 @@
|
||||
<string name="error_dialog_pay_withdraw_error">निकाल नहीं पाए</string>
|
||||
<string name="cashu_failed_redemption">काशयू नहीं चुका पाए</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">टकसाल ने यह अपक्रम संदेश उपलब्ध किया : %1$s</string>
|
||||
<string name="cashu_unsafe_mint_url">असुरक्षित काशयू टकसाल पता</string>
|
||||
<string name="cashu_unsafe_mint_url_explainer">अमेथिस्ट ने इस अक्षरराशि की टकसाल से सम्पर्क नहीं किया। %1$s</string>
|
||||
<string name="cashu_successful_redemption">काशयू प्राप्त</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s साट्स भेजे गये आपके धनकोष में। (शुल्क : %2$s साट्स)</string>
|
||||
<string name="cashu_no_wallet_found">कोई अनुकूल काशयू धनकोष उपलब्ध नहीं यन्त्र में</string>
|
||||
@@ -2873,7 +2942,7 @@
|
||||
<string name="reactions_settings_reorder">पुनःव्यवस्थित करें</string>
|
||||
<string name="reactions_settings_reply">उत्तर</string>
|
||||
<string name="reactions_settings_reply_description">इस पत्र के लिए उत्तर लिखें</string>
|
||||
<string name="reactions_settings_boost">उद्धृत करें</string>
|
||||
<string name="reactions_settings_boost">उद्धरण</string>
|
||||
<string name="reactions_settings_boost_description">पत्र पुनःप्रकाशित करें अथवा टीका लिखें</string>
|
||||
<string name="reactions_settings_like">प्रिय</string>
|
||||
<string name="reactions_settings_like_description">प्रतिक्रिया करें इस पत्र पर भावचिह्न द्वारा</string>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<string name="referenced_event_not_found">Evento referenciado não encontrado</string>
|
||||
<string name="could_not_decrypt_the_message">Não foi possível descriptografar a mensagem</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="chat_preview_decrypting">Descriptografando…</string>
|
||||
<string name="group_picture">Imagem do grupo</string>
|
||||
<string name="explicit_content">Conteúdo explícito</string>
|
||||
<string name="relay_notice">Aviso do relay</string>
|
||||
@@ -302,6 +303,10 @@
|
||||
<string name="concord_invite_failed_invalid">Este link de convite é inválido ou não pode ser aberto com esta conta.</string>
|
||||
<string name="concord_invite_failed_incompatible">Não é possível abrir este link de convite. Ele pode estar desatualizado ou já ter sido substituído por um mais recente, ou ter sido criado com uma versão mais nova do app. Peça um novo link de convite.</string>
|
||||
<string name="concord_invite_failed_revoked">Este link de convite foi revogado e não pode mais ser usado. Peça um novo.</string>
|
||||
<string name="concord_invite_failed_expired">Este link de convite expirou e não pode mais ser usado. Peça um link novo.</string>
|
||||
<string name="concord_invite_preview_unknown_name">O nome da comunidade só é revelado depois que você entra</string>
|
||||
<string name="concord_invite_preview_explainer">Entrar conecta aos relays deste convite, publica um anúncio de entrada assinado pela sua conta e adiciona a comunidade à sua lista. Nada é enviado até você tocar em Entrar.</string>
|
||||
<string name="concord_invite_preview_relays">Relays que este convite vai contatar: %1$s</string>
|
||||
<string name="concord_home_title">Canais Concord</string>
|
||||
<string name="concord_home_empty">Você ainda não entrou em nenhum canal Concord. Crie um ou abra um link de convite.</string>
|
||||
<string name="concord_channels_empty">Nenhum canal ainda.</string>
|
||||
@@ -317,6 +322,10 @@
|
||||
<string name="concord_channel_delete_title">Excluir canal?</string>
|
||||
<string name="concord_channel_delete_message">Excluir #%1$s? Isso não pode ser desfeito e o canal não pode ser recriado com o mesmo id.</string>
|
||||
<string name="concord_channel_delete_confirm">Excluir</string>
|
||||
<string name="concord_leave_community">Sair da comunidade</string>
|
||||
<string name="concord_leave_title">Sair da comunidade?</string>
|
||||
<string name="concord_leave_message">Sair de %1$s? Ela é removida da lista desta conta e para de sincronizar nos seus dispositivos. A comunidade não é notificada e você não é removido da lista de membros. Mensagens que você não conseguir mais descriptografar podem ser irrecuperáveis, e você só poderá voltar com um novo convite.</string>
|
||||
<string name="concord_leave_owner_warning">Você criou esta comunidade. Sair não a exclui nem a entrega a mais ninguém, mas descarta a chave de proprietário armazenada na sua lista — você não conseguiria gerenciá-la novamente.</string>
|
||||
<string name="concord_edit_relays_desc">Onde os planos criptografados desta comunidade são publicados e lidos.</string>
|
||||
<string name="concord_typing_one">%1$s está digitando…</string>
|
||||
<string name="concord_typing_two">%1$s e %2$s estão digitando…</string>
|
||||
@@ -361,6 +370,13 @@
|
||||
<string name="concord_members_remove_title">Remover membro?</string>
|
||||
<string name="concord_members_remove_message">Isso rotaciona a chave de criptografia da comunidade para que este membro não possa mais ler nada enviado depois. Todos os outros recebem novas chaves automaticamente. Isso não pode ser desfeito.</string>
|
||||
<string name="concord_members_remove_confirm">Remover</string>
|
||||
<string name="concord_members_roles">Funções…</string>
|
||||
<string name="concord_members_roles_title">Atribuir funções</string>
|
||||
<string name="concord_members_roles_message">Escolha todas as funções que este membro deve ter. Desmarcar uma função a remove.</string>
|
||||
<string name="concord_members_roles_save">Salvar</string>
|
||||
<string name="concord_members_roles_out_of_reach">Você não tem posição superior a este membro</string>
|
||||
<string name="concord_members_roles_none_assignable">Nenhuma função que você possa atribuir</string>
|
||||
<string name="concord_members_roles_failed">Não foi possível atualizar as funções deste membro.</string>
|
||||
<string name="concord_role_owner">Dono</string>
|
||||
<string name="concord_role_admin">Administrador</string>
|
||||
<string name="concord_role_banned">Banido</string>
|
||||
@@ -485,6 +501,15 @@
|
||||
<string name="share_as_image_url">Compartilhar como URL de imagem</string>
|
||||
<string name="share_as_image_generating">Gerando prévia…</string>
|
||||
<string name="share_as_image_watermark">Compartilhado via Amethyst</string>
|
||||
<string name="share_as_qr">Compartilhar como QR</string>
|
||||
<string name="share_as_qr_mode_web">Link web</string>
|
||||
<string name="share_as_qr_mode_nostr">Link Nostr</string>
|
||||
<string name="share_as_qr_hint_web">Escaneie com a câmera de qualquer celular</string>
|
||||
<string name="share_as_qr_hint_nostr">Escaneie com um aplicativo Nostr</string>
|
||||
<string name="share_as_qr_kind_picture">Imagem</string>
|
||||
<string name="share_as_qr_code_description_web">Código QR contendo um link web para esta nota</string>
|
||||
<string name="share_as_qr_code_description_nostr">Código QR contendo um link Nostr para esta nota</string>
|
||||
<string name="share_as_qr_thumbnail_hidden_sensitive">Miniatura oculta por conteúdo sensível</string>
|
||||
<string name="quick_action_copy_user_id">ID do Autor</string>
|
||||
<string name="quick_action_copy_note_id">ID da Nota</string>
|
||||
<string name="quick_action_copy_text">Copiar texto</string>
|
||||
@@ -810,6 +835,7 @@
|
||||
<string name="napplet_consent_storage">Este nApplet quer usar seu armazenamento privado.</string>
|
||||
<string name="napplet_consent_pay">Este nApplet quer pagar uma fatura Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_pay_no_amount">⚠ Este nApplet quer pagar uma fatura Lightning que NÃO especifica um valor — quem recebe decide quanto é retirado. Só permita isto se você confiar nele.</string>
|
||||
<string name="napplet_consent_resource">Este nApplet quer buscar um recurso da web.</string>
|
||||
<string name="napplet_consent_upload">Este nApplet quer enviar um arquivo para o seu servidor de mídia.</string>
|
||||
<string name="napplet_consent_notify">Este nApplet quer mostrar notificações para você.</string>
|
||||
@@ -822,11 +848,56 @@
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<plurals name="napplet_consent_diff_follow_added">
|
||||
<item quantity="one">segue %1$d nova conta</item>
|
||||
<item quantity="other">segue %1$d novas contas</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_follow_removed">
|
||||
<item quantity="one">DEIXA DE SEGUIR %1$d conta</item>
|
||||
<item quantity="other">DEIXA DE SEGUIR %1$d contas</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_relay_added">
|
||||
<item quantity="one">adiciona %1$d relay</item>
|
||||
<item quantity="other">adiciona %1$d relays</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_relay_removed">
|
||||
<item quantity="one">REMOVE %1$d relay</item>
|
||||
<item quantity="other">REMOVE %1$d relays</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_mute_added">
|
||||
<item quantity="one">silencia mais %1$d pessoa</item>
|
||||
<item quantity="other">silencia mais %1$d pessoas</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_mute_removed">
|
||||
<item quantity="one">REMOVE O SILÊNCIO de %1$d pessoa</item>
|
||||
<item quantity="other">REMOVE O SILÊNCIO de %1$d pessoas</item>
|
||||
</plurals>
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<string name="napplet_consent_diff_follow_one">⚠ Isto segue %1$s.</string>
|
||||
<string name="napplet_consent_diff_unfollow_one">⚠ Isto DEIXA DE SEGUIR %1$s.</string>
|
||||
<string name="napplet_consent_diff_mute_one">⚠ Isto silencia %1$s.</string>
|
||||
<string name="napplet_consent_diff_unmute_one">⚠ Isto REMOVE O SILÊNCIO de %1$s.</string>
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<string name="napplet_consent_diff_follows">⚠ Isto reescreve sua lista de quem você segue: %1$s.</string>
|
||||
<string name="napplet_consent_diff_relays">⚠ Isto reescreve sua lista de relays: %1$s. Suas publicações e leituras vão junto.</string>
|
||||
<string name="napplet_consent_diff_mutes">⚠ Isto reescreve sua lista de silenciados: %1$s. Palavras e hashtags silenciadas não são mostradas aqui — toque em “Mostrar evento” para ver a lista completa.</string>
|
||||
<string name="napplet_consent_diff_joiner">%1$s e %2$s</string>
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<string name="napplet_consent_diff_none">Isto republica sua lista existente sem alterações.</string>
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<plurals name="napplet_consent_diff_no_baseline">
|
||||
<item quantity="one">⚠ Isto grava uma lista de %1$d entrada. O Amethyst não tem cópia em cache para comparar.</item>
|
||||
<item quantity="other">⚠ Isto grava uma lista de %1$d entradas. O Amethyst não tem cópia em cache para comparar.</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_effect_deletes">
|
||||
<item quantity="one">⚠ Isto solicita a exclusão de %1$d dos seus eventos.</item>
|
||||
<item quantity="other">⚠ Isto solicita a exclusão de %1$d dos seus eventos.</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_effect_tags">
|
||||
<item quantity="one">Carrega %1$d tag. Toque em “Mostrar evento” para ver exatamente o que seria assinado.</item>
|
||||
<item quantity="other">Carrega %1$d tags. Toque em “Mostrar evento” para ver exatamente o que seria assinado.</item>
|
||||
</plurals>
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Conectar ao Nostr</string>
|
||||
<string name="napplet_connect_subtitle">quer se conectar à sua conta Nostr</string>
|
||||
@@ -863,9 +934,13 @@
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">ler suas mensagens privadas</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<string name="napplet_op_decrypt_from">ler suas mensagens privadas com %1$s</string>
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<string name="nip46_signer_allow_always_for">Sempre permitir para %1$s</string>
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<string name="nip46_signer_decrypt_failed">O Amethyst não conseguiu descriptografar esta mensagem. Ela pode não ser destinada a esta conta.</string>
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<string name="nip46_signer_messages_with">Mensagens com</string>
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Apps conectados</string>
|
||||
<string name="napplet_permissions_revoke_all">Revogar todas as permissões</string>
|
||||
@@ -1489,6 +1564,39 @@
|
||||
<string name="local_blossom_cache_profile_pics_only">Apenas armazenar fotos de perfil em cache</string>
|
||||
<string name="local_blossom_cache_profile_pics_only_caption">Restringir o cache local apenas a fotos de perfil. Imagens e vídeos do feed serão buscados diretamente dos servidores originais.</string>
|
||||
<string name="no_blossom_server_message">Você não tem nenhum servidor de Blossom configurado. Você pode usar a lista de Amethyst, ou adicionar um abaixo ↓</string>
|
||||
<string name="media_servers_upload_section">Comportamento de envio</string>
|
||||
<string name="blossom_mirror_uploads">Espelhar envios</string>
|
||||
<string name="blossom_mirror_uploads_caption">Após o envio, copie o arquivo para seus outros servidores Blossom para que ele continue disponível caso um fique fora do ar.</string>
|
||||
<string name="blossom_optimize_media">Otimizar mídia no servidor</string>
|
||||
<string name="blossom_optimize_media_caption">Envie pelo endpoint /media do servidor para que ele possa remover metadados e compactar o arquivo. O arquivo armazenado pode diferir do original.</string>
|
||||
<string name="manage_stored_files">Gerenciar arquivos armazenados</string>
|
||||
<string name="my_blossom_data">Meus arquivos Blossom</string>
|
||||
<string name="blossom_refresh">Atualizar</string>
|
||||
<string name="blossom_sync_all">Sincronizar tudo</string>
|
||||
<string name="blossom_sync_gaps">Alguns dos seus arquivos ainda não estão em todos os seus servidores.</string>
|
||||
<string name="blossom_syncing">Copiando seus arquivos entre servidores…</string>
|
||||
<string name="blossom_sync_done">Sincronização concluída</string>
|
||||
<string name="blossom_sync_cancel">Cancelar</string>
|
||||
<string name="blossom_sync_channel_name">Sincronização Blossom</string>
|
||||
<string name="blossom_sync_channel_description">Mostra o progresso enquanto seus arquivos são copiados entre seus servidores Blossom.</string>
|
||||
<string name="manage_stored_files_empty">Nenhum arquivo armazenado encontrado nos seus servidores Blossom.</string>
|
||||
<string name="blossom_mirror_to_missing">Espelhar para os ausentes</string>
|
||||
<string name="blossom_delete_from">Excluir de…</string>
|
||||
<string name="blossom_delete_from_host">Excluir de %1$s</string>
|
||||
<string name="blossom_report">Denunciar</string>
|
||||
<string name="blossom_open">Abrir</string>
|
||||
<string name="blossom_payment_required">Este servidor exige pagamento para enviar: %1$s</string>
|
||||
<string name="blossom_payment_title">Pagamento necessário</string>
|
||||
<string name="blossom_payment_message">%1$s cobra um pagamento lightning para armazenar este arquivo. Pague pela sua carteira conectada para continuar.</string>
|
||||
<string name="blossom_payment_server_says">%1$s diz: “%2$s”</string>
|
||||
<string name="blossom_pay">Pagar</string>
|
||||
<plurals name="blossom_pay_sats">
|
||||
<item quantity="one">Pagar %1$d sat</item>
|
||||
<item quantity="other">Pagar %1$d sats</item>
|
||||
</plurals>
|
||||
<string name="blossom_report_title">Denunciar blob</string>
|
||||
<string name="blossom_report_comment_hint">Motivo (opcional)</string>
|
||||
<string name="blossom_send">Enviar</string>
|
||||
<string name="recommended_media_servers">Servidores de Mídia Recomendados</string>
|
||||
<string name="built_in_servers_description">Lista padrão do Amethyst. Você pode adicioná-los individualmente ou adicionar a lista.</string>
|
||||
<string name="use_default_servers">Usar Lista Padrão</string>
|
||||
@@ -1503,7 +1611,9 @@
|
||||
<string name="media_server_primary_badge">Principal</string>
|
||||
<string name="media_server_added">Adicionado</string>
|
||||
<string name="media_server_reorder">Reordenar servidor</string>
|
||||
<string name="media_server_status_online">No ar</string>
|
||||
<string name="media_server_status_slow">Lento</string>
|
||||
<string name="media_server_status_offline">Fora do ar</string>
|
||||
<string name="media_server_status_checking">Verificando…</string>
|
||||
<string name="payment_targets">Destinos de pagamento</string>
|
||||
<string name="payment_targets_explainer">Publique seus endereços de pagamento para que outros possam enviar fundos diretamente para você.</string>
|
||||
@@ -1532,6 +1642,11 @@
|
||||
<string name="follow_list_kind3follows_users_only">Seguindo do Usuários</string>
|
||||
<string name="follow_list_kind3_follows_users_only">Lista Padrão de Seguir</string>
|
||||
<string name="follow_list_aroundme">Perto de mim</string>
|
||||
<string name="follow_list_teleport">Teletransportar para um lugar…</string>
|
||||
<string name="follow_geohash">Seguir este local</string>
|
||||
<string name="unfollow_geohash">Deixar de seguir este local</string>
|
||||
<string name="geo_post_posting_to">Publicando em</string>
|
||||
<string name="geo_post_change_place">Alterar</string>
|
||||
<string name="follow_list_global">Global</string>
|
||||
<string name="follow_list_curated">Curada</string>
|
||||
<string name="follow_list_mine">Meus</string>
|
||||
@@ -1942,6 +2057,10 @@
|
||||
<string name="default_relays_longer">Redefinir para os Padrões</string>
|
||||
<string name="geohash_title">Expor localização como </string>
|
||||
<string name="geohash_explainer">Adicione um geohash da sua localização à postagem. O público saberá que você está a 5 km (3 milhas) do local atual</string>
|
||||
<string name="geohash_teleport_title">Teletransporte</string>
|
||||
<string name="geohash_teleport_action">✈ Teletransportar para cá</string>
|
||||
<string name="location_pick_on_map">Escolher um lugar no mapa</string>
|
||||
<string name="location_change_place">Alterar lugar no mapa</string>
|
||||
<string name="geohash_exclusive">Postagem exclusiva de localização</string>
|
||||
<string name="geohash_exclusive_explainer">Somente seguidores da localização verão isso. Seus seguidores gerais não verão isso.</string>
|
||||
<string name="hashtag_exclusive">Postagem exclusiva de Hashtag</string>
|
||||
@@ -2134,7 +2253,19 @@
|
||||
<string name="relay_group_field_topics_hint">bitcoin, nostr, arte</string>
|
||||
<string name="relay_group_field_geohash">Localização (geohash)</string>
|
||||
<string name="relay_group_field_geohash_hint">un0d</string>
|
||||
<string name="relay_group_location_add">Adicionar um local</string>
|
||||
<string name="relay_group_location_add_desc">Fixe seu grupo em um mapa para que pessoas próximas possam descobri-lo.</string>
|
||||
<string name="relay_group_location_edit">Alterar local</string>
|
||||
<string name="relay_group_location_clear">Remover local</string>
|
||||
<string name="relay_group_location_manual">Inserir um geohash manualmente</string>
|
||||
<!-- Reusable geohash location picker (shared, not group-specific) -->
|
||||
<string name="location_picker_title">Escolher local</string>
|
||||
<string name="location_picker_hint">Mova o mapa, pesquise um lugar ou use sua localização atual.</string>
|
||||
<string name="location_picker_search_hint">Pesquise uma cidade ou endereço</string>
|
||||
<string name="location_picker_search_empty">Nenhum lugar correspondente encontrado.</string>
|
||||
<string name="location_picker_use_mine">Usar minha localização atual</string>
|
||||
<string name="location_picker_area">Tamanho da área</string>
|
||||
<string name="location_picker_confirm">Usar este local</string>
|
||||
<string name="relay_group_section_structure">Estrutura</string>
|
||||
<string name="relay_group_parent_desc">Aninhe este grupo sob um grupo pai para construir uma hierarquia.</string>
|
||||
<string name="relay_group_parent_label">Grupo pai</string>
|
||||
@@ -2157,6 +2288,8 @@
|
||||
<string name="relay_group_discovery_empty_filtered">Nenhum grupo encontrado para este filtro ainda.</string>
|
||||
<string name="relay_group_favorite_relay">Favoritar este relay</string>
|
||||
<string name="relay_group_threads_empty">Nenhuma thread ainda. Comece uma com o botão +.</string>
|
||||
<string name="relay_group_threads_loading_older">Carregando tópicos mais antigos…</string>
|
||||
<string name="relay_group_threads_all_caught_up">Nenhum tópico mais antigo</string>
|
||||
<string name="relay_group_thread_new">Nova thread</string>
|
||||
<string name="relay_group_thread_untitled">Sem título</string>
|
||||
<string name="relay_group_thread_title_label">Título</string>
|
||||
@@ -2270,6 +2403,8 @@
|
||||
<string name="error_dialog_pay_withdraw_error">Não foi possível sacar</string>
|
||||
<string name="cashu_failed_redemption">Não foi possível resgatar o Cashu</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">O Mint forneceu a seguinte mensagem de erro: %1$s</string>
|
||||
<string name="cashu_unsafe_mint_url">Endereço de mint Cashu não seguro</string>
|
||||
<string name="cashu_unsafe_mint_url_explainer">O Amethyst não entrou em contato com o mint deste token. %1$s</string>
|
||||
<string name="cashu_successful_redemption">Cashu Recebido</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sats foram enviados para a sua carteira. (Taxas: %2$s sats)</string>
|
||||
<string name="cashu_no_wallet_found">Nenhuma carteira Cashu compatível encontrada no sistema</string>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<string name="referenced_event_not_found">Refererad händelse hittades inte</string>
|
||||
<string name="could_not_decrypt_the_message">Kunde inte dekryptera meddelandet</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="chat_preview_decrypting">Dekrypterar…</string>
|
||||
<string name="group_picture">Grupp bild</string>
|
||||
<string name="explicit_content">Explicit Innehåll</string>
|
||||
<string name="relay_notice">Relay-meddelande</string>
|
||||
@@ -302,6 +303,10 @@
|
||||
<string name="concord_invite_failed_invalid">Den här inbjudningslänken är ogiltig eller kan inte öppnas med det här kontot.</string>
|
||||
<string name="concord_invite_failed_incompatible">Den här inbjudningslänken kan inte öppnas. Den kan vara föråldrad eller redan ersatt av en nyare, eller skapad med en nyare version av appen. Be om en ny inbjudningslänk.</string>
|
||||
<string name="concord_invite_failed_revoked">Den här inbjudningslänken har återkallats och kan inte längre användas. Be om en ny.</string>
|
||||
<string name="concord_invite_failed_expired">Den här inbjudningslänken har upphört att gälla och kan inte längre användas. Be om en ny länk.</string>
|
||||
<string name="concord_invite_preview_unknown_name">Gemenskapens namn visas först efter att du gått med</string>
|
||||
<string name="concord_invite_preview_explainer">Att gå med ansluter till inbjudningens reläer, publicerar ett anslutningsmeddelande signerat av ditt konto och lägger till gemenskapen i din lista. Inget skickas förrän du trycker på Gå med.</string>
|
||||
<string name="concord_invite_preview_relays">Reläer som inbjudan kommer att kontakta: %1$s</string>
|
||||
<string name="concord_home_title">Concord-kanaler</string>
|
||||
<string name="concord_home_empty">Du har inte gått med i några Concord-kanaler än. Skapa en, eller öppna en inbjudningslänk.</string>
|
||||
<string name="concord_channels_empty">Inga kanaler än.</string>
|
||||
@@ -317,6 +322,10 @@
|
||||
<string name="concord_channel_delete_title">Radera kanal?</string>
|
||||
<string name="concord_channel_delete_message">Radera #%1$s? Detta kan inte ångras och kanalen kan inte återskapas med samma id.</string>
|
||||
<string name="concord_channel_delete_confirm">Radera</string>
|
||||
<string name="concord_leave_community">Lämna gemenskapen</string>
|
||||
<string name="concord_leave_title">Lämna gemenskapen?</string>
|
||||
<string name="concord_leave_message">Lämna %1$s? Den tas bort från det här kontots lista och slutar synkas på dina enheter. Gemenskapen meddelas inte och du tas inte bort från dess medlemslista. Meddelanden som du inte längre kan dekryptera kan gå förlorade, och du kan bara återvända med en ny inbjudan.</string>
|
||||
<string name="concord_leave_owner_warning">Du skapade den här gemenskapen. Att lämna raderar den inte och överlämnar den inte till någon annan, men det kasserar ägarnyckeln som lagras i din lista — du skulle inte kunna hantera den igen.</string>
|
||||
<string name="concord_edit_relays_desc">Var den här gemenskapens krypterade plan publiceras och läses.</string>
|
||||
<string name="concord_typing_one">%1$s skriver…</string>
|
||||
<string name="concord_typing_two">%1$s och %2$s skriver…</string>
|
||||
@@ -361,6 +370,13 @@
|
||||
<string name="concord_members_remove_title">Ta bort medlem?</string>
|
||||
<string name="concord_members_remove_message">Detta roterar gemenskapens krypteringsnyckel så att den här medlemmen inte längre kan läsa något som skickas därefter. Alla andra får automatiskt en ny nyckel. Detta kan inte ångras.</string>
|
||||
<string name="concord_members_remove_confirm">Ta bort</string>
|
||||
<string name="concord_members_roles">Roller…</string>
|
||||
<string name="concord_members_roles_title">Tilldela roller</string>
|
||||
<string name="concord_members_roles_message">Välj alla roller som den här medlemmen ska ha. Att avmarkera en roll tar bort den.</string>
|
||||
<string name="concord_members_roles_save">Spara</string>
|
||||
<string name="concord_members_roles_out_of_reach">Du har inte högre rang än den här medlemmen</string>
|
||||
<string name="concord_members_roles_none_assignable">Inga roller som du kan tilldela</string>
|
||||
<string name="concord_members_roles_failed">Kunde inte uppdatera den här medlemmens roller.</string>
|
||||
<string name="concord_role_owner">Ägare</string>
|
||||
<string name="concord_role_admin">Administratör</string>
|
||||
<string name="concord_role_banned">Bannlyst</string>
|
||||
@@ -485,6 +501,15 @@
|
||||
<string name="share_as_image_url">Dela som bild-URL</string>
|
||||
<string name="share_as_image_generating">Genererar förhandsvisning…</string>
|
||||
<string name="share_as_image_watermark">Delad via Amethyst</string>
|
||||
<string name="share_as_qr">Dela som QR</string>
|
||||
<string name="share_as_qr_mode_web">Webblänk</string>
|
||||
<string name="share_as_qr_mode_nostr">Nostr-länk</string>
|
||||
<string name="share_as_qr_hint_web">Skanna med valfri telefonkamera</string>
|
||||
<string name="share_as_qr_hint_nostr">Skanna med en Nostr-app</string>
|
||||
<string name="share_as_qr_kind_picture">Bild</string>
|
||||
<string name="share_as_qr_code_description_web">QR-kod med en webblänk till den här noten</string>
|
||||
<string name="share_as_qr_code_description_nostr">QR-kod med en Nostr-länk till den här noten</string>
|
||||
<string name="share_as_qr_thumbnail_hidden_sensitive">Miniatyrbild dold för känsligt innehåll</string>
|
||||
<string name="quick_action_copy_user_id">Användar ID</string>
|
||||
<string name="quick_action_copy_note_id">Antecknings ID</string>
|
||||
<string name="quick_action_copy_text">Kopiera Text</string>
|
||||
@@ -810,6 +835,7 @@
|
||||
<string name="napplet_consent_storage">Det här nApplet vill använda sin privata lagring.</string>
|
||||
<string name="napplet_consent_pay">Det här nApplet vill betala en Lightning-faktura.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_pay_no_amount">⚠ Den här nApplet vill betala en Lightning-faktura som anger INGET belopp — betalningsmottagaren avgör hur mycket som tas. Tillåt detta endast om du litar på den.</string>
|
||||
<string name="napplet_consent_resource">Det här nApplet vill hämta en webbresurs.</string>
|
||||
<string name="napplet_consent_upload">Det här nApplet vill ladda upp en fil till din medieserver.</string>
|
||||
<string name="napplet_consent_notify">Det här nApplet vill visa dig aviseringar.</string>
|
||||
@@ -822,11 +848,56 @@
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<plurals name="napplet_consent_diff_follow_added">
|
||||
<item quantity="one">följer %1$d nytt konto</item>
|
||||
<item quantity="other">följer %1$d nya konton</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_follow_removed">
|
||||
<item quantity="one">SLUTAR FÖLJA %1$d konto</item>
|
||||
<item quantity="other">SLUTAR FÖLJA %1$d konton</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_relay_added">
|
||||
<item quantity="one">lägger till %1$d relä</item>
|
||||
<item quantity="other">lägger till %1$d reläer</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_relay_removed">
|
||||
<item quantity="one">TAR BORT %1$d relä</item>
|
||||
<item quantity="other">TAR BORT %1$d reläer</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_mute_added">
|
||||
<item quantity="one">tystar %1$d person till</item>
|
||||
<item quantity="other">tystar %1$d personer till</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_mute_removed">
|
||||
<item quantity="one">SLUTAR TYSTA %1$d person</item>
|
||||
<item quantity="other">SLUTAR TYSTA %1$d personer</item>
|
||||
</plurals>
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<string name="napplet_consent_diff_follow_one">⚠ Detta följer %1$s.</string>
|
||||
<string name="napplet_consent_diff_unfollow_one">⚠ Detta SLUTAR FÖLJA %1$s.</string>
|
||||
<string name="napplet_consent_diff_mute_one">⚠ Detta tystar %1$s.</string>
|
||||
<string name="napplet_consent_diff_unmute_one">⚠ Detta SLUTAR TYSTA %1$s.</string>
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<string name="napplet_consent_diff_follows">⚠ Detta skriver om din följlista: %1$s.</string>
|
||||
<string name="napplet_consent_diff_relays">⚠ Detta skriver om din relälista: %1$s. Dina inlägg och läsningar flyttas med den.</string>
|
||||
<string name="napplet_consent_diff_mutes">⚠ Detta skriver om din tystningslista: %1$s. Tystade ord och hashtaggar visas inte här — tryck på “Visa händelse” för hela listan.</string>
|
||||
<string name="napplet_consent_diff_joiner">%1$s och %2$s</string>
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<string name="napplet_consent_diff_none">Detta återpublicerar din befintliga lista oförändrad.</string>
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<plurals name="napplet_consent_diff_no_baseline">
|
||||
<item quantity="one">⚠ Detta skriver en lista med %1$d post. Amethyst har ingen cachad kopia att jämföra med.</item>
|
||||
<item quantity="other">⚠ Detta skriver en lista med %1$d poster. Amethyst har ingen cachad kopia att jämföra med.</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_effect_deletes">
|
||||
<item quantity="one">⚠ Detta begär radering av %1$d av dina händelser.</item>
|
||||
<item quantity="other">⚠ Detta begär radering av %1$d av dina händelser.</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_effect_tags">
|
||||
<item quantity="one">Bär %1$d tagg. Tryck på “Visa händelse” för att se exakt vad som skulle signeras.</item>
|
||||
<item quantity="other">Bär %1$d taggar. Tryck på “Visa händelse” för att se exakt vad som skulle signeras.</item>
|
||||
</plurals>
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Anslut till Nostr</string>
|
||||
<string name="napplet_connect_subtitle">vill ansluta till ditt Nostr-konto</string>
|
||||
@@ -863,9 +934,13 @@
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">läsa dina privata meddelanden</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<string name="napplet_op_decrypt_from">läsa dina privata meddelanden med %1$s</string>
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<string name="nip46_signer_allow_always_for">Tillåt alltid för %1$s</string>
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<string name="nip46_signer_decrypt_failed">Amethyst kunde inte dekryptera det här meddelandet. Det kanske inte är adresserat till det här kontot.</string>
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<string name="nip46_signer_messages_with">Meddelanden med</string>
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Anslutna appar</string>
|
||||
<string name="napplet_permissions_revoke_all">Återkalla alla behörigheter</string>
|
||||
@@ -1489,6 +1564,39 @@
|
||||
<string name="local_blossom_cache_profile_pics_only">Cacha endast profilbilder</string>
|
||||
<string name="local_blossom_cache_profile_pics_only_caption">Begränsa den lokala cachen till profilbilder. Bilder och videor i flödet hämtas direkt från originalservrarna.</string>
|
||||
<string name="no_blossom_server_message">Du har inga Blossom servrar satta. Du kan använda Amethyst\'s lista, eller lägga till en nedan ↓</string>
|
||||
<string name="media_servers_upload_section">Uppladdningsbeteende</string>
|
||||
<string name="blossom_mirror_uploads">Spegla uppladdningar</string>
|
||||
<string name="blossom_mirror_uploads_caption">Efter uppladdning kopieras filen till dina andra Blossom-servrar så att den förblir tillgänglig om en går offline.</string>
|
||||
<string name="blossom_optimize_media">Optimera media på servern</string>
|
||||
<string name="blossom_optimize_media_caption">Ladda upp via serverns /media-slutpunkt så att den kan ta bort metadata och komprimera filen. Den lagrade filen kan skilja sig från originalet.</string>
|
||||
<string name="manage_stored_files">Hantera lagrade filer</string>
|
||||
<string name="my_blossom_data">Mina Blossom-filer</string>
|
||||
<string name="blossom_refresh">Uppdatera</string>
|
||||
<string name="blossom_sync_all">Synka alla</string>
|
||||
<string name="blossom_sync_gaps">Några av dina filer finns inte på alla dina servrar ännu.</string>
|
||||
<string name="blossom_syncing">Kopierar dina filer mellan servrar…</string>
|
||||
<string name="blossom_sync_done">Synkning klar</string>
|
||||
<string name="blossom_sync_cancel">Avbryt</string>
|
||||
<string name="blossom_sync_channel_name">Blossom-synk</string>
|
||||
<string name="blossom_sync_channel_description">Visar förloppet medan dina filer kopieras mellan dina Blossom-servrar.</string>
|
||||
<string name="manage_stored_files_empty">Inga lagrade filer hittades på dina Blossom-servrar.</string>
|
||||
<string name="blossom_mirror_to_missing">Spegla till saknade</string>
|
||||
<string name="blossom_delete_from">Ta bort från…</string>
|
||||
<string name="blossom_delete_from_host">Ta bort från %1$s</string>
|
||||
<string name="blossom_report">Rapportera</string>
|
||||
<string name="blossom_open">Öppna</string>
|
||||
<string name="blossom_payment_required">Den här servern kräver betalning för uppladdning: %1$s</string>
|
||||
<string name="blossom_payment_title">Betalning krävs</string>
|
||||
<string name="blossom_payment_message">%1$s tar ut en Lightning-betalning för att lagra den här filen. Betala från din anslutna plånbok för att fortsätta.</string>
|
||||
<string name="blossom_payment_server_says">%1$s säger: “%2$s”</string>
|
||||
<string name="blossom_pay">Betala</string>
|
||||
<plurals name="blossom_pay_sats">
|
||||
<item quantity="one">Betala %1$d sat</item>
|
||||
<item quantity="other">Betala %1$d sats</item>
|
||||
</plurals>
|
||||
<string name="blossom_report_title">Rapportera blob</string>
|
||||
<string name="blossom_report_comment_hint">Anledning (valfritt)</string>
|
||||
<string name="blossom_send">Skicka</string>
|
||||
<string name="recommended_media_servers">Rekommenderade Mediaservrar</string>
|
||||
<string name="built_in_servers_description">Amethysts standardlista. Du kan lägga till dem individuellt eller lägga till listan.</string>
|
||||
<string name="use_default_servers">Använd standardlistan</string>
|
||||
@@ -1532,6 +1640,11 @@
|
||||
<string name="follow_list_kind3follows_users_only">Alla Användare Följare</string>
|
||||
<string name="follow_list_kind3_follows_users_only">Standard Följ-Lista</string>
|
||||
<string name="follow_list_aroundme">Runt mig</string>
|
||||
<string name="follow_list_teleport">Teleportera till en plats…</string>
|
||||
<string name="follow_geohash">Följ den här platsen</string>
|
||||
<string name="unfollow_geohash">Sluta följa den här platsen</string>
|
||||
<string name="geo_post_posting_to">Publicerar till</string>
|
||||
<string name="geo_post_change_place">Ändra</string>
|
||||
<string name="follow_list_global">Global</string>
|
||||
<string name="follow_list_curated">Kurerad</string>
|
||||
<string name="follow_list_mine">Mina</string>
|
||||
@@ -1942,6 +2055,10 @@
|
||||
<string name="default_relays_longer">Återställ till standardinställningar</string>
|
||||
<string name="geohash_title">Exponera plats som </string>
|
||||
<string name="geohash_explainer">Lägger till en Geohash av din plats i inlägget. Allmänheten kommer att veta att du befinner dig inom 5 km från nuvarande plats</string>
|
||||
<string name="geohash_teleport_title">Teleportera</string>
|
||||
<string name="geohash_teleport_action">✈ Teleportera hit</string>
|
||||
<string name="location_pick_on_map">Välj en plats på kartan</string>
|
||||
<string name="location_change_place">Ändra plats på kartan</string>
|
||||
<string name="geohash_exclusive">Plats-exklusivt inlägg</string>
|
||||
<string name="geohash_exclusive_explainer">Endast anhängare av platsen kommer att se den. Dina allmänna anhängare kommer inte att se den.</string>
|
||||
<string name="hashtag_exclusive">Hashtag-exklusivt inlägg</string>
|
||||
@@ -2134,7 +2251,19 @@
|
||||
<string name="relay_group_field_topics_hint">bitcoin, nostr, konst</string>
|
||||
<string name="relay_group_field_geohash">Plats (geohash)</string>
|
||||
<string name="relay_group_field_geohash_hint">u0nd</string>
|
||||
<string name="relay_group_location_add">Lägg till en plats</string>
|
||||
<string name="relay_group_location_add_desc">Fäst din grupp på en karta så att folk i närheten kan upptäcka den.</string>
|
||||
<string name="relay_group_location_edit">Ändra plats</string>
|
||||
<string name="relay_group_location_clear">Ta bort plats</string>
|
||||
<string name="relay_group_location_manual">Ange en geohash manuellt</string>
|
||||
<!-- Reusable geohash location picker (shared, not group-specific) -->
|
||||
<string name="location_picker_title">Välj plats</string>
|
||||
<string name="location_picker_hint">Flytta kartan, sök efter en plats eller använd din nuvarande plats.</string>
|
||||
<string name="location_picker_search_hint">Sök efter en stad eller adress</string>
|
||||
<string name="location_picker_search_empty">Ingen matchande plats hittades.</string>
|
||||
<string name="location_picker_use_mine">Använd min nuvarande plats</string>
|
||||
<string name="location_picker_area">Områdesstorlek</string>
|
||||
<string name="location_picker_confirm">Använd den här platsen</string>
|
||||
<string name="relay_group_section_structure">Struktur</string>
|
||||
<string name="relay_group_parent_desc">Placera den här gruppen under en överordnad för att bygga en hierarki.</string>
|
||||
<string name="relay_group_parent_label">Överordnad grupp</string>
|
||||
@@ -2157,6 +2286,8 @@
|
||||
<string name="relay_group_discovery_empty_filtered">Inga grupper hittades för det här filtret ännu.</string>
|
||||
<string name="relay_group_favorite_relay">Favoritmarkera det här reläet</string>
|
||||
<string name="relay_group_threads_empty">Inga trådar ännu. Starta en med +-knappen.</string>
|
||||
<string name="relay_group_threads_loading_older">Läser in äldre trådar…</string>
|
||||
<string name="relay_group_threads_all_caught_up">Inga äldre trådar</string>
|
||||
<string name="relay_group_thread_new">Ny tråd</string>
|
||||
<string name="relay_group_thread_untitled">Namnlös</string>
|
||||
<string name="relay_group_thread_title_label">Titel</string>
|
||||
@@ -2270,6 +2401,8 @@
|
||||
<string name="error_dialog_pay_withdraw_error">Kunde inte ta ut</string>
|
||||
<string name="cashu_failed_redemption">Kunde inte lösa in Cashu</string>
|
||||
<string name="cashu_failed_redemption_explainer_error_msg">Mint gav följande felmeddelande: %1$s</string>
|
||||
<string name="cashu_unsafe_mint_url">Osäker Cashu-mint-adress</string>
|
||||
<string name="cashu_unsafe_mint_url_explainer">Amethyst kontaktade inte den här tokenens mint. %1$s</string>
|
||||
<string name="cashu_successful_redemption">Cashu mottagen</string>
|
||||
<string name="cashu_successful_redemption_explainer">%1$s sats skickades till din plånbok. (Avgifter: %2$s sats)</string>
|
||||
<string name="cashu_no_wallet_found">Ingen kompatibel Cashu-plånbok hittades på systemet</string>
|
||||
|
||||
@@ -531,6 +531,15 @@
|
||||
<string name="share_as_image_url">Share as Image Url</string>
|
||||
<string name="share_as_image_generating">Generating preview…</string>
|
||||
<string name="share_as_image_watermark">Shared via Amethyst</string>
|
||||
<string name="share_as_qr">Share as QR</string>
|
||||
<string name="share_as_qr_mode_web">Web link</string>
|
||||
<string name="share_as_qr_mode_nostr">Nostr link</string>
|
||||
<string name="share_as_qr_hint_web">Scan with any phone camera</string>
|
||||
<string name="share_as_qr_hint_nostr">Scan with a Nostr app</string>
|
||||
<string name="share_as_qr_kind_picture">Picture</string>
|
||||
<string name="share_as_qr_code_description_web">QR code containing a web link to this note</string>
|
||||
<string name="share_as_qr_code_description_nostr">QR code containing a Nostr link to this note</string>
|
||||
<string name="share_as_qr_thumbnail_hidden_sensitive">Thumbnail hidden for sensitive content</string>
|
||||
<string name="quick_action_copy_user_id">Author ID</string>
|
||||
<string name="quick_action_copy_note_id">Note ID</string>
|
||||
<string name="quick_action_copy_text">Copy Text</string>
|
||||
|
||||
@@ -215,9 +215,9 @@ class BlossomPaymentSafetyTest {
|
||||
|
||||
@Test
|
||||
fun reasonBidiOverridesAreRemoved() {
|
||||
val clean = challenge(invoice1000Sats, reason = "fee reversed text").sanitizedReason()!!
|
||||
assertFalse(clean.contains(''))
|
||||
assertFalse(clean.contains(''))
|
||||
val clean = challenge(invoice1000Sats, reason = "fee \u202Ereversed\u202C text").sanitizedReason()!!
|
||||
assertFalse(clean.contains('\u202E'))
|
||||
assertFalse(clean.contains('\u202C'))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.ui.note.share
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class QrPayloadTest {
|
||||
private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007"
|
||||
private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray()))
|
||||
|
||||
private suspend fun textNote(): Note {
|
||||
val event = TextNoteEvent.build("hello qr") {}.let { aliceSigner.sign(it) }
|
||||
val note = Note(event.id)
|
||||
note.event = event
|
||||
return note
|
||||
}
|
||||
|
||||
@Test
|
||||
fun webMode_returnsAnHttpsNjumpLink() =
|
||||
runTest {
|
||||
val payload = qrPayloadFor(textNote(), QrPayloadMode.Web)
|
||||
|
||||
assertTrue(
|
||||
"web mode must produce an https URL so a stock phone camera can action it, got: $payload",
|
||||
payload.startsWith("https://"),
|
||||
)
|
||||
assertTrue(payload.contains("nevent1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nostrMode_returnsANostrUriWithNevent() =
|
||||
runTest {
|
||||
val payload = qrPayloadFor(textNote(), QrPayloadMode.Nostr)
|
||||
|
||||
assertTrue(payload.startsWith("nostr:nevent1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bothModes_encodeTheSameNote() =
|
||||
runTest {
|
||||
val note = textNote()
|
||||
val web = qrPayloadFor(note, QrPayloadMode.Web)
|
||||
val nostr = qrPayloadFor(note, QrPayloadMode.Nostr)
|
||||
|
||||
// The bech32 body must be identical; only the wrapper differs.
|
||||
assertEquals(nostr.removePrefix("nostr:"), web.substringAfterLast('/'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addressableNote_nostrMode_yieldsNaddrNotNevent() =
|
||||
runTest {
|
||||
// build(description, title, summary, image, publishedAt, dTag, createdAt, init)
|
||||
// — `title` is a required positional; `dTag` must be named.
|
||||
// LongTextNoteEvent.kt:148-157.
|
||||
val event =
|
||||
LongTextNoteEvent
|
||||
.build("body", "My Article", dTag = "my-article") {}
|
||||
.let { aliceSigner.sign(it) }
|
||||
val note = AddressableNote(event.address())
|
||||
note.event = event
|
||||
|
||||
val payload = qrPayloadFor(note, QrPayloadMode.Nostr)
|
||||
|
||||
assertTrue(
|
||||
"AddressableNote must encode as naddr via the toNEvent() override, got: $payload",
|
||||
payload.startsWith("nostr:naddr1"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.ui.note.share
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SharedNoteCardTest {
|
||||
private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007"
|
||||
private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray()))
|
||||
|
||||
private val blossomUrl = "https://blossom.primal.net/66332885d206714439e39e441b6539622a07c108"
|
||||
|
||||
private suspend fun kind1(
|
||||
content: String,
|
||||
imetaUrl: String? = null,
|
||||
mime: String? = null,
|
||||
alt: String? = null,
|
||||
): Event =
|
||||
TextNoteEvent
|
||||
.build(content) {
|
||||
if (imetaUrl != null) {
|
||||
imetas(
|
||||
listOf(
|
||||
IMetaTagBuilder(imetaUrl)
|
||||
.apply {
|
||||
mime?.let { add("m", it) }
|
||||
alt?.let { add("alt", it) }
|
||||
}.build(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}.let { aliceSigner.sign(it) }
|
||||
|
||||
// ---- firstContentImage ----
|
||||
|
||||
@Test
|
||||
fun imageImeta_isPickedAsTheThumbnailImage() =
|
||||
runTest {
|
||||
val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg")
|
||||
|
||||
val image = firstContentImage(note)
|
||||
|
||||
assertEquals(blossomUrl, image?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainTextNote_hasNoContentImage() =
|
||||
runTest {
|
||||
val note = kind1(content = "just some words, no media")
|
||||
|
||||
assertNull(firstContentImage(note))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoImeta_isNotRenderedAsAnImage() =
|
||||
runTest {
|
||||
// A video-only imeta must not be fed to the image loader; the card falls back to the avatar.
|
||||
val note = kind1(content = "clip", imetaUrl = "https://cdn.example/v", mime = "video/mp4")
|
||||
|
||||
assertNull(firstContentImage(note))
|
||||
}
|
||||
|
||||
// ---- secondaryBodyTextFor ----
|
||||
|
||||
@Test
|
||||
fun imageOnlyPost_stripsTheBareUrlToNothing() =
|
||||
runTest {
|
||||
// content is exactly the media URL — the whole point of the fix: never show the raw CDN URL.
|
||||
val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg")
|
||||
|
||||
assertNull(secondaryBodyTextFor(note, isGated = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imageOnlyPostWithAlt_fallsBackToAltText() =
|
||||
runTest {
|
||||
val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg", alt = "A sunset")
|
||||
|
||||
assertEquals("A sunset", secondaryBodyTextFor(note, isGated = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun textPlusImage_keepsTheTextWithoutTheUrl() =
|
||||
runTest {
|
||||
val note = kind1(content = "check this out $blossomUrl", imetaUrl = blossomUrl, mime = "image/jpeg")
|
||||
|
||||
assertEquals("check this out", secondaryBodyTextFor(note, isGated = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatedNote_yieldsNoBodyText() =
|
||||
runTest {
|
||||
val note = kind1(content = "something sensitive", imetaUrl = blossomUrl, mime = "image/jpeg", alt = "nsfw")
|
||||
|
||||
assertNull(secondaryBodyTextFor(note, isGated = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun article_prefersItsTitle() =
|
||||
runTest {
|
||||
val note =
|
||||
LongTextNoteEvent
|
||||
.build("the body", "My Article", dTag = "a1") {}
|
||||
.let { aliceSigner.sign(it) }
|
||||
|
||||
assertEquals("My Article", secondaryBodyTextFor(note, isGated = false))
|
||||
}
|
||||
|
||||
// ---- IMetaTag.isImage ----
|
||||
|
||||
@Test
|
||||
fun isImage_trueForImageMime_falseForVideoMime() =
|
||||
runTest {
|
||||
val img = IMetaTagBuilder("https://x/y").add("m", "image/png").build()
|
||||
val vid = IMetaTagBuilder("https://x/y").add("m", "video/mp4").build()
|
||||
|
||||
assertTrue(img.isImage())
|
||||
assertTrue(!vid.isImage())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isImage_fallsBackToUrlExtensionWhenNoMime() =
|
||||
runTest {
|
||||
val withExt = IMetaTagBuilder("https://x/photo.jpg").build()
|
||||
val extensionless = IMetaTagBuilder("https://blossom.example/deadbeef").build()
|
||||
|
||||
assertTrue(withExt.isImage())
|
||||
assertTrue(!extensionless.isImage())
|
||||
}
|
||||
}
|
||||
@@ -62,8 +62,17 @@ enum class ConcordIngestOutcome {
|
||||
* a chat/reaction/reply/delete message landing, or a duplicate wrap. Must NOT bump the revision. */
|
||||
NON_STRUCTURAL,
|
||||
|
||||
/** Ours and changed structure: a Control-Plane fold (metadata/channels/membership/authority), a
|
||||
* guestbook membership change, or a buffered base-rekey. Bumps the revision. */
|
||||
/** Ours and re-folded the Control Plane. The fold republishes [ConcordCommunitySession.state],
|
||||
* so the session's own state watcher is what bumps the revision — and, because the folded state
|
||||
* compares by value, only when the fold actually *changed* something. A control wrap that folds
|
||||
* to an identical state (a prior-epoch wrap that doesn't move the anti-rollback floor, a role
|
||||
* edition that touches nothing we subscribe on) therefore costs no bump at all. The manager must
|
||||
* NOT bump on this outcome as well, or every control wrap counts twice. */
|
||||
STRUCTURAL_FOLD,
|
||||
|
||||
/** Ours and changed structure *without* touching [ConcordCommunitySession.state]: a guestbook
|
||||
* membership change (which republishes `members`) or a buffered base-rekey. No state watcher
|
||||
* covers these, so the manager bumps the revision directly. */
|
||||
STRUCTURAL,
|
||||
;
|
||||
|
||||
@@ -145,6 +154,22 @@ class ConcordCommunitySession(
|
||||
// Deduped inbound wraps.
|
||||
private val controlWraps = LinkedHashMap<HexKey, Event>()
|
||||
|
||||
/**
|
||||
* Decrypted control editions memoized by wrap id.
|
||||
*
|
||||
* Both [refold] and [controlFloorsLocked] fold their WHOLE buffer on every inbound control
|
||||
* wrap, and turning a wrap into an edition is a NIP-44 open + parse. Re-deriving them each
|
||||
* time made a cold-boot backfill quadratic in decryptions — one measured boot did ~8.6k opens
|
||||
* to ingest 93 control wraps for a single community. Memoizing makes it one open per wrap.
|
||||
*
|
||||
* Wrap ids are unique and a wrap only ever belongs to one plane (it is routed by `pubKey`), so
|
||||
* a single id-keyed map is safe across the current and prior-epoch Control Planes even though
|
||||
* they open under different keys. A wrap that fails to open caches `null` so it is not retried
|
||||
* on every subsequent fold. The wrap buffers are only ever added to, so this tracks their
|
||||
* lifetime exactly and needs no separate eviction.
|
||||
*/
|
||||
private val editionByWrapId = HashMap<HexKey, ControlEdition?>()
|
||||
|
||||
// Prior-epoch Control Plane address -> (wrapId -> wrap). Kept apart from [controlWraps]: these
|
||||
// never join the live fold, they only produce the anti-rollback floor.
|
||||
private val historicalControlWraps = HashMap<HexKey, LinkedHashMap<HexKey, Event>>()
|
||||
@@ -280,7 +305,7 @@ class ConcordCommunitySession(
|
||||
fun auxStreamKeys(): List<GroupKey> = listOf(guestbookKey, nextBaseRekeyKey)
|
||||
|
||||
/** The community's current Control Plane editions — the input a moderation edition chains onto. */
|
||||
fun controlEditions(): List<ControlEdition> = lock.withLock { ConcordActions.controlEditions(controlWraps.values.toList(), controlPlaneKey) }
|
||||
fun controlEditions(): List<ControlEdition> = lock.withLock { editionsLocked(controlWraps.values.toList(), controlPlaneKey) }
|
||||
|
||||
/** The raw Control Plane wraps buffered so far — the input a Refounding compacts (CORD-06 §3). */
|
||||
fun controlPlaneWraps(): List<Event> = lock.withLock { controlWraps.values.toList() }
|
||||
@@ -314,7 +339,7 @@ class ConcordCommunitySession(
|
||||
if (controlWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup
|
||||
}
|
||||
refold()
|
||||
return ConcordIngestOutcome.STRUCTURAL
|
||||
return ConcordIngestOutcome.STRUCTURAL_FOLD
|
||||
}
|
||||
guestbookAddress -> {
|
||||
lock.withLock {
|
||||
@@ -341,7 +366,7 @@ class ConcordCommunitySession(
|
||||
if (buffer.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup
|
||||
}
|
||||
refold()
|
||||
return ConcordIngestOutcome.STRUCTURAL
|
||||
return ConcordIngestOutcome.STRUCTURAL_FOLD
|
||||
}
|
||||
val current = lock.withLock { channelKeysByAddress[wrap.pubKey] }
|
||||
if (current != null) {
|
||||
@@ -422,7 +447,7 @@ class ConcordCommunitySession(
|
||||
val wraps = controlWraps.values.toList()
|
||||
val folded =
|
||||
ConcordCommunityState.fold(
|
||||
ConcordActions.controlEditions(wraps, controlPlaneKey),
|
||||
editionsLocked(wraps, controlPlaneKey),
|
||||
entry.owner,
|
||||
controlFloorsLocked(),
|
||||
)
|
||||
@@ -455,6 +480,24 @@ class ConcordCommunitySession(
|
||||
for (channelIdHex in newChannels) reprojectChannel(channelIdHex)
|
||||
}
|
||||
|
||||
/**
|
||||
* [wraps] opened into editions through [editionByWrapId], so a wrap is only ever decrypted
|
||||
* once no matter how many folds it participates in. Caller must hold [lock].
|
||||
*/
|
||||
private fun editionsLocked(
|
||||
wraps: Collection<Event>,
|
||||
planeKey: GroupKey,
|
||||
): List<ControlEdition> =
|
||||
wraps.mapNotNull { wrap ->
|
||||
if (editionByWrapId.containsKey(wrap.id)) {
|
||||
editionByWrapId[wrap.id]
|
||||
} else {
|
||||
val edition = ConcordStreamEnvelope.openOrNull(wrap, planeKey)?.let { ControlEdition.fromRumor(it.rumor) }
|
||||
editionByWrapId[wrap.id] = edition
|
||||
edition
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-entity anti-rollback floor: the authority-gated heads of every prior epoch's
|
||||
* Control Plane we still hold a root for, folded **oldest epoch first** so each epoch is
|
||||
@@ -472,7 +515,7 @@ class ConcordCommunitySession(
|
||||
var floors = emptyMap<String, EntityFloor>()
|
||||
for ((address, keyAtEpoch) in historicalControlKeys.entries.sortedBy { it.value.second }) {
|
||||
val wraps = historicalControlWraps[address]?.values?.toList() ?: continue
|
||||
val editions = ConcordActions.controlEditions(wraps, keyAtEpoch.first)
|
||||
val editions = editionsLocked(wraps, keyAtEpoch.first)
|
||||
if (editions.isEmpty()) continue
|
||||
floors = ConcordCommunityState.authorizedHeads(editions, entry.owner, floors)
|
||||
}
|
||||
|
||||
@@ -150,6 +150,9 @@ class ConcordSessionManager(
|
||||
seenOnRelays: Set<NormalizedRelayUrl> = emptySet(),
|
||||
): Boolean {
|
||||
val outcome = registry.ingest(wrap, seenOnRelays)
|
||||
// Only the planes that change structure *without* republishing `state`. A control-plane
|
||||
// fold returns STRUCTURAL_FOLD and is bumped by the per-session state watcher instead, which
|
||||
// (since the folded state compares by value) fires only when the fold genuinely changed.
|
||||
if (outcome == ConcordIngestOutcome.STRUCTURAL) bumpRevision()
|
||||
return outcome.claimed
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.commons.moderation.notifications
|
||||
|
||||
private val CONTROL_CHARS = Regex("\\p{Cntrl}")
|
||||
private val RTL_OVERRIDES = Regex("[--]")
|
||||
private val ZERO_WIDTH = Regex("[-]")
|
||||
private val RTL_OVERRIDES = Regex("[\u202A-\u202E\u2066-\u2069]")
|
||||
private val ZERO_WIDTH = Regex("[\u200B-\u200D\uFEFF]")
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
private val URL_PATTERN = Regex("https?://\\S+")
|
||||
|
||||
|
||||
@@ -161,7 +161,14 @@ class FeedContentState(
|
||||
if (noteEvent != null) {
|
||||
!cacheProvider.hasBeenDeleted(noteEvent)
|
||||
} else {
|
||||
false
|
||||
// An event-less row is a placeholder the filter synthesized for a room
|
||||
// that has no message yet — a just-joined Concord channel, NIP-29 group,
|
||||
// Marmot group or geohash cell. It carries no event, so it cannot have
|
||||
// been deleted, and dropping it here deleted every such row from the
|
||||
// Messages list the moment ANY kind-5 landed in an unrelated batch. The
|
||||
// row then stayed gone until the next full rebuild, which is why a quiet
|
||||
// community looked like it had never loaded at all.
|
||||
true
|
||||
}
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ class ConcordCommunitySessionTest {
|
||||
|
||||
// Feed the genesis control wraps → state folds, channels + membership resolve. A fold is
|
||||
// STRUCTURAL (it moves the subscription set), so it's allowed to bump the revision.
|
||||
community.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL, session.ingest(it)) }
|
||||
community.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL_FOLD, session.ingest(it)) }
|
||||
val state = session.state.value
|
||||
assertEquals("Nostrichs", state?.metadata?.name)
|
||||
assertTrue(state!!.channels.containsKey(community.generalChannelIdHex))
|
||||
|
||||
@@ -70,7 +70,7 @@ class ConcordSessionRegistryTest {
|
||||
assertTrue(registry.subscribeAddresses().contains(beta.controlPlane.publicKeyHex))
|
||||
|
||||
// A genesis control wrap routes to Alpha's session and folds it (STRUCTURAL).
|
||||
alpha.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL, registry.ingest(it)) }
|
||||
alpha.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL_FOLD, registry.ingest(it)) }
|
||||
val alphaState = registry.sessionFor(alpha.communityIdHex)!!.state.value
|
||||
assertEquals("Alpha", alphaState?.metadata?.name)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -42,6 +43,18 @@ class LocalRelayStore(
|
||||
) : AutoCloseable {
|
||||
companion object {
|
||||
val LOCAL_RELAY_URL: NormalizedRelayUrl = NormalizedRelayUrl("ws://localhost/amethyst-local/")
|
||||
|
||||
/**
|
||||
* Client defaults plus the authors-without-kinds index: shared
|
||||
* ViewModels (`Nip65RelayListViewModel`, `PrivateOutboxRelayListViewModel`,
|
||||
* `VanishRequestsState`) replay `authors`-only filters against this
|
||||
* store, which full-scan without `(pubkey, created_at)` — the
|
||||
* `(kind, pubkey, …)` index can't serve them, pubkey is its second
|
||||
* column. A personal store is small, so the extra insert cost is
|
||||
* negligible; existing DBs get the index built on next open via
|
||||
* `ensureOptionalIndexes`.
|
||||
*/
|
||||
val INDEX_STRATEGY = DefaultIndexingStrategy(indexEventsByPubkeyAlone = true)
|
||||
}
|
||||
|
||||
private fun dbDir(pubKeyHex: String): File = File(homeDir, ".amethyst/accounts/${pubKeyHex.take(8)}")
|
||||
@@ -87,14 +100,14 @@ class LocalRelayStore(
|
||||
dir.mkdirs()
|
||||
val path = File(dir, "events.db").absolutePath
|
||||
try {
|
||||
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL)
|
||||
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL, indexStrategy = INDEX_STRATEGY)
|
||||
_lastError.value = null
|
||||
refreshStats()
|
||||
} catch (e: Exception) {
|
||||
Log.w("LocalRelayStore") { "DB open failed, recreating: ${e.message}" }
|
||||
try {
|
||||
deleteDbFiles(path)
|
||||
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL)
|
||||
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL, indexStrategy = INDEX_STRATEGY)
|
||||
_lastError.value = "Database was recreated: ${e.message}"
|
||||
} catch (e2: Exception) {
|
||||
_lastError.value = "Cannot open local store: ${e2.message}"
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
"markkks": "",
|
||||
"helokuntzer": "",
|
||||
"Kevin_1": "",
|
||||
"crowdin.pretended462": ""
|
||||
"crowdin.pretended462": "",
|
||||
"rchk": ""
|
||||
},
|
||||
"sinceLastTag": {
|
||||
"tag": "v1.12.6",
|
||||
@@ -366,6 +367,10 @@
|
||||
{
|
||||
"user": "Kevin_1",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "rchk",
|
||||
"languages": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -57,6 +57,14 @@ fun relayIndexingStrategy(
|
||||
// index unconditionally; without it the filter walks the whole
|
||||
// time index.
|
||||
indexEventsByPubkeyAlone = true,
|
||||
// The tag ∩ author ∩ kind shape (DM rooms, reports-by-follows,
|
||||
// follows-scoped community feeds — 65 client assembler call sites)
|
||||
// otherwise reads every row for the tag/kind before filtering the
|
||||
// author. TagAuthorIndexBenchmark @ 1M events: 14.2 ms -> 0.66 ms
|
||||
// (~21x, growing with corpus size) with insert cost inside run noise
|
||||
// (49.0 vs 47.4 µs/event). Existing DBs build the index on next open
|
||||
// via ensureOptionalIndexes.
|
||||
indexTagsWithKindAndPubkey = true,
|
||||
indexFullTextSearch = fullTextSearch,
|
||||
// Tokenize off the commit path; NostrServer drives the catch-up
|
||||
// worker and search queries drain it first, so NIP-50 stays
|
||||
|
||||
@@ -83,6 +83,8 @@ val store = EventStore(
|
||||
|
||||
By default, all single-letter tags with values are indexed. Override `shouldIndex(kind, tag)` for custom behavior. More indexes = faster queries but larger database.
|
||||
|
||||
Flag flips are safe on existing databases: any flag-gated index the strategy wants but the on-disk schema lacks is created on the next open (idempotent `CREATE INDEX IF NOT EXISTS`, one-time build cost) — no schema version bump involved. Disabling a flag never drops an existing index.
|
||||
|
||||
`indexFullTextSearch` defaults to `true` and controls the NIP-50 full-text index (`event_fts`). Set it to `false` when search is served elsewhere (e.g. a Vespa backend, or a `SearchEventSource` as shown below): inserts skip the FTS tokenization cost, no `event_fts` table/trigger is created, and any filter carrying a non-empty `search` term returns no matches.
|
||||
|
||||
## Non-Storage Relays (search, redirector, computed)
|
||||
|
||||
@@ -94,6 +94,8 @@ kotlin {
|
||||
// Forward the negentropy-benchmark corpus size to the test JVM.
|
||||
System.getProperty("negBenchN")?.let { systemProperty("negBenchN", it) }
|
||||
System.getProperty("followBenchScale")?.let { systemProperty("followBenchScale", it) }
|
||||
System.getProperty("tagBenchScale")?.let { systemProperty("tagBenchScale", it) }
|
||||
System.getProperty("fsBenchScale")?.let { systemProperty("fsBenchScale", it) }
|
||||
// Opt-in JFR profiling of a benchmark run (-PnegProfile=/tmp/neg.jfr).
|
||||
(project.findProperty("negProfile") as? String)?.let {
|
||||
jvmArgs("-XX:+FlightRecorder", "-XX:StartFlightRecording=filename=$it,settings=profile,dumponexit=true")
|
||||
|
||||
@@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.asFloor
|
||||
|
||||
/** A channel id paired with its current folded definition. */
|
||||
class ConcordChannel(
|
||||
data class ConcordChannel(
|
||||
val channelIdHex: String,
|
||||
val definition: ChannelEntity,
|
||||
)
|
||||
@@ -49,7 +49,7 @@ class ConcordChannel(
|
||||
* "Every member keeps the entire Control Plane in sync — it is small and must
|
||||
* stay complete." Recompute this whenever the known editions change.
|
||||
*/
|
||||
class ConcordCommunityState(
|
||||
data class ConcordCommunityState(
|
||||
val ownerPubKey: String,
|
||||
val metadata: MetadataEntity?,
|
||||
val channels: Map<String, ConcordChannel>,
|
||||
|
||||
@@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
* assigned Role and holds [ConcordPermissions.MANAGE_ROLES]. Cycles that never
|
||||
* touch the owner can never bootstrap themselves.
|
||||
*/
|
||||
class AuthorityResolver private constructor(
|
||||
data class AuthorityResolver private constructor(
|
||||
private val ownerLower: String,
|
||||
private val roles: Map<String, RoleEntity>,
|
||||
private val memberRoles: Map<String, Set<String>>,
|
||||
|
||||
@@ -64,7 +64,7 @@ object ConcordJson {
|
||||
* drops the role, and with it every authority (grant) that depends on it.
|
||||
*/
|
||||
@Serializable
|
||||
class RoleScope(
|
||||
data class RoleScope(
|
||||
val kind: String = "server",
|
||||
@SerialName("channel_id") val channelId: String? = null,
|
||||
)
|
||||
@@ -75,7 +75,7 @@ class RoleScope(
|
||||
* ranks higher; no role may claim position 0 (reserved for the owner).
|
||||
*/
|
||||
@Serializable
|
||||
class RoleEntity(
|
||||
data class RoleEntity(
|
||||
val name: String = "",
|
||||
val position: Long = 0,
|
||||
/** u64 permission bitfield as a decimal string. */
|
||||
@@ -94,7 +94,7 @@ class RoleEntity(
|
||||
* terminates at the owner (see [AuthorityResolver]).
|
||||
*/
|
||||
@Serializable
|
||||
class GrantEntity(
|
||||
data class GrantEntity(
|
||||
val member: String = "",
|
||||
@SerialName("role_ids") val roleIds: List<String> = emptyList(),
|
||||
)
|
||||
@@ -105,7 +105,7 @@ class GrantEntity(
|
||||
* A [deleted] channel is terminal — its id is never reused.
|
||||
*/
|
||||
@Serializable
|
||||
class ChannelEntity(
|
||||
data class ChannelEntity(
|
||||
val name: String = "",
|
||||
val private: Boolean = false,
|
||||
val voice: Boolean = false,
|
||||
@@ -122,7 +122,7 @@ class ChannelEntity(
|
||||
* community name too.
|
||||
*/
|
||||
@Serializable
|
||||
class MetadataEntity(
|
||||
data class MetadataEntity(
|
||||
val name: String = "",
|
||||
val icon: ImagePointer? = null,
|
||||
val banner: ImagePointer? = null,
|
||||
|
||||
@@ -25,6 +25,22 @@ class EoseMessage(
|
||||
) : Message {
|
||||
override fun label() = LABEL
|
||||
|
||||
/**
|
||||
* Wire form is `["EOSE","<subId>"]` — sent once per REQ, so it is on
|
||||
* the per-subscription floor. Splice it directly when [subId] needs no
|
||||
* escaping (the common case: client-chosen sub ids are short ASCII),
|
||||
* skipping the generic serializer's node tree. Byte-identical output;
|
||||
* any exotic subId falls back.
|
||||
*/
|
||||
override fun toJson(): String {
|
||||
if (!isEscapeFreeAscii(subId)) return super.toJson()
|
||||
return buildString(subId.length + 12) {
|
||||
append("[\"EOSE\",\"")
|
||||
append(subId)
|
||||
append("\"]")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LABEL = "EOSE"
|
||||
}
|
||||
|
||||
@@ -29,6 +29,25 @@ class OkMessage(
|
||||
) : Message {
|
||||
override fun label() = LABEL
|
||||
|
||||
/**
|
||||
* Wire form is `["OK","<eventId>",<true|false>,"<message>"]` — sent
|
||||
* once per published EVENT. [eventId] is validated hex (always
|
||||
* escape-free); splice directly when [message] also needs no escaping,
|
||||
* which covers the empty-string success ack and the plain-ASCII
|
||||
* rejection reasons. Byte-identical output; a reason with quotes or
|
||||
* non-ASCII falls back to the generic serializer.
|
||||
*/
|
||||
override fun toJson(): String {
|
||||
if (!isEscapeFreeAscii(message)) return super.toJson()
|
||||
return buildString(eventId.length + message.length + 20) {
|
||||
append("[\"OK\",\"")
|
||||
append(eventId)
|
||||
append(if (success) "\",true,\"" else "\",false,\"")
|
||||
append(message)
|
||||
append("\"]")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LABEL = "OK"
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.quartz.nip01Core.relay.commands.toClient
|
||||
|
||||
/**
|
||||
* True when every char of [s] is printable ASCII (0x20–0x7e) and not a JSON
|
||||
* metacharacter (`"` / `\`) — i.e. exactly the bytes a JSON string encoder
|
||||
* would emit verbatim between the quotes. Frame builders use this to gate a
|
||||
* direct-`buildString` fast path against the generic serializer: when it holds
|
||||
* the spliced output is byte-identical, and any exotic value (control chars,
|
||||
* quotes, non-ASCII) falls back to the escaping serializer.
|
||||
*/
|
||||
internal fun isEscapeFreeAscii(s: String): Boolean {
|
||||
for (c in s) {
|
||||
if (c < ' ' || c > '~' || c == '"' || c == '\\') return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -22,6 +22,11 @@ package com.vitorpamplona.quartz.nip01Core.relay.filters
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.collections.immutable.PersistentMap
|
||||
import kotlinx.collections.immutable.PersistentSet
|
||||
import kotlinx.collections.immutable.persistentHashMapOf
|
||||
import kotlinx.collections.immutable.persistentHashSetOf
|
||||
import kotlinx.collections.immutable.toPersistentHashSet
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
@@ -62,9 +67,10 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
* copy-on-write CAS loops, mirroring the
|
||||
* `nip86RelayManagement.server.BanStore` pattern. Reads in
|
||||
* [candidatesFor] and [forEach] are wait-free single-load atomic.
|
||||
* Writes (subscription register / unregister) copy the inner maps
|
||||
* — fine for this workload because writes are subscription-rate
|
||||
* (rare) while reads are event-rate (frequent).
|
||||
* Writes (subscription register / unregister) build the next snapshot
|
||||
* from persistent (HAMT) maps — O(keys × log S) with structural
|
||||
* sharing rather than a full O(S) copy of both maps, since on a relay
|
||||
* a write happens on every REQ open and close.
|
||||
*
|
||||
* ## What the index does NOT cover
|
||||
*
|
||||
@@ -109,14 +115,26 @@ class FilterIndex<S : Any> {
|
||||
private object Unindexed : BucketKey
|
||||
|
||||
/**
|
||||
* Single immutable snapshot. [buckets] maps a key to the set of
|
||||
* subscribers registered under it; [assignments] is the reverse
|
||||
* map used by [unregister] to find a subscriber's keys without
|
||||
* scanning every bucket.
|
||||
* Single immutable snapshot. Subscribers are held in one map per
|
||||
* indexable dimension so [candidatesFor] — called once per accepted
|
||||
* ingest event, the hot read — can probe each dimension with the
|
||||
* event's own field (`event.id`, `event.pubKey`, `tag[0]`/`tag[1]`,
|
||||
* `event.kind`) and allocate no key-wrapper objects. [assignments] is
|
||||
* the reverse map ([S] → the [BucketKey]s it occupies) used by
|
||||
* [unregister]; the wrappers live only here, built on the rare
|
||||
* register path.
|
||||
*
|
||||
* Persistent (HAMT) maps/sets: a register/unregister produces the
|
||||
* next snapshot in O(keys × log S) with structural sharing, instead
|
||||
* of copying full maps — registration happens on every REQ open/close.
|
||||
*/
|
||||
private data class State<S>(
|
||||
val buckets: Map<BucketKey, Set<S>> = emptyMap(),
|
||||
val assignments: Map<S, Set<BucketKey>> = emptyMap(),
|
||||
val ids: PersistentMap<HexKey, PersistentSet<S>> = persistentHashMapOf(),
|
||||
val authors: PersistentMap<HexKey, PersistentSet<S>> = persistentHashMapOf(),
|
||||
val tags: PersistentMap<String, PersistentMap<String, PersistentSet<S>>> = persistentHashMapOf(),
|
||||
val kinds: PersistentMap<Int, PersistentSet<S>> = persistentHashMapOf(),
|
||||
val unindexed: PersistentSet<S> = persistentHashSetOf(),
|
||||
val assignments: PersistentMap<S, PersistentSet<BucketKey>> = persistentHashMapOf(),
|
||||
)
|
||||
|
||||
private val state: AtomicReference<State<S>> = AtomicReference(State())
|
||||
@@ -181,18 +199,23 @@ class FilterIndex<S : Any> {
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
val keys = current.assignments[subscriber] ?: return
|
||||
val newBuckets = current.buckets.toMutableMap()
|
||||
var ids = current.ids
|
||||
var authors = current.authors
|
||||
var tags = current.tags
|
||||
var kinds = current.kinds
|
||||
var unindexed = current.unindexed
|
||||
for (key in keys) {
|
||||
val cur = newBuckets[key] ?: continue
|
||||
val next = cur - subscriber
|
||||
if (next.isEmpty()) {
|
||||
newBuckets.remove(key)
|
||||
} else {
|
||||
newBuckets[key] = next
|
||||
when (key) {
|
||||
is IdKey -> ids = ids.removeSub(key.id, subscriber)
|
||||
is AuthorKey -> authors = authors.removeSub(key.author, subscriber)
|
||||
is KindKey -> kinds = kinds.removeSub(key.kind, subscriber)
|
||||
is TagKey -> tags = tags.removeTagSub(key.letter, key.value, subscriber)
|
||||
Unindexed -> unindexed = unindexed.remove(subscriber)
|
||||
}
|
||||
}
|
||||
val newAssignments = current.assignments - subscriber
|
||||
if (state.compareAndSet(current, State(newBuckets, newAssignments))) return
|
||||
val next =
|
||||
State(ids, authors, tags, kinds, unindexed, current.assignments.remove(subscriber))
|
||||
if (state.compareAndSet(current, next)) return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,19 +225,22 @@ class FilterIndex<S : Any> {
|
||||
* candidate to handle negative constraints.
|
||||
*
|
||||
* Iteration order is insertion-stable per call but otherwise
|
||||
* unspecified.
|
||||
* unspecified. Allocates only the result set — dimensions are
|
||||
* probed with the event's own fields, no key wrappers.
|
||||
*/
|
||||
fun candidatesFor(event: Event): Set<S> {
|
||||
val s = state.load()
|
||||
if (s.buckets.isEmpty()) return emptySet()
|
||||
if (s.assignments.isEmpty()) return emptySet()
|
||||
val result = LinkedHashSet<S>()
|
||||
s.buckets[Unindexed]?.let { result.addAll(it) }
|
||||
s.buckets[IdKey(event.id)]?.let { result.addAll(it) }
|
||||
s.buckets[AuthorKey(event.pubKey)]?.let { result.addAll(it) }
|
||||
s.buckets[KindKey(event.kind)]?.let { result.addAll(it) }
|
||||
for (tag in event.tags) {
|
||||
if (tag.size >= 2 && tag[0].length == 1) {
|
||||
s.buckets[TagKey(tag[0], tag[1])]?.let { result.addAll(it) }
|
||||
if (s.unindexed.isNotEmpty()) result.addAll(s.unindexed)
|
||||
s.ids[event.id]?.let { result.addAll(it) }
|
||||
s.authors[event.pubKey]?.let { result.addAll(it) }
|
||||
s.kinds[event.kind]?.let { result.addAll(it) }
|
||||
if (s.tags.isNotEmpty()) {
|
||||
for (tag in event.tags) {
|
||||
if (tag.size >= 2 && tag[0].length == 1) {
|
||||
s.tags[tag[0]]?.get(tag[1])?.let { result.addAll(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -235,19 +261,75 @@ class FilterIndex<S : Any> {
|
||||
keys: List<BucketKey>,
|
||||
) {
|
||||
if (keys.isEmpty()) return
|
||||
val keySet = keys.toSet()
|
||||
val keySet = keys.toPersistentHashSet()
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
val newBuckets = current.buckets.toMutableMap()
|
||||
var ids = current.ids
|
||||
var authors = current.authors
|
||||
var tags = current.tags
|
||||
var kinds = current.kinds
|
||||
var unindexed = current.unindexed
|
||||
for (key in keySet) {
|
||||
val cur = newBuckets[key] ?: emptySet()
|
||||
if (subscriber in cur) continue
|
||||
newBuckets[key] = cur + subscriber
|
||||
when (key) {
|
||||
is IdKey -> ids = ids.addSub(key.id, subscriber)
|
||||
is AuthorKey -> authors = authors.addSub(key.author, subscriber)
|
||||
is KindKey -> kinds = kinds.addSub(key.kind, subscriber)
|
||||
is TagKey -> tags = tags.addTagSub(key.letter, key.value, subscriber)
|
||||
Unindexed -> unindexed = unindexed.add(subscriber)
|
||||
}
|
||||
}
|
||||
val existing = current.assignments[subscriber]
|
||||
val merged = if (existing == null) keySet else existing + keySet
|
||||
val newAssignments = current.assignments + (subscriber to merged)
|
||||
if (state.compareAndSet(current, State(newBuckets, newAssignments))) return
|
||||
val merged = existing?.addAll(keySet) ?: keySet
|
||||
val next = State(ids, authors, tags, kinds, unindexed, current.assignments.put(subscriber, merged))
|
||||
if (state.compareAndSet(current, next)) return
|
||||
}
|
||||
}
|
||||
|
||||
// Per-dimension add/remove of one subscriber, returning the same map
|
||||
// instance when nothing changed so the CAS builds minimal new nodes.
|
||||
private fun <K> PersistentMap<K, PersistentSet<S>>.addSub(
|
||||
key: K,
|
||||
sub: S,
|
||||
): PersistentMap<K, PersistentSet<S>> {
|
||||
val cur = this[key] ?: persistentHashSetOf()
|
||||
val next = cur.add(sub)
|
||||
return if (next === cur) this else put(key, next)
|
||||
}
|
||||
|
||||
private fun <K> PersistentMap<K, PersistentSet<S>>.removeSub(
|
||||
key: K,
|
||||
sub: S,
|
||||
): PersistentMap<K, PersistentSet<S>> {
|
||||
val cur = this[key] ?: return this
|
||||
val next = cur.remove(sub)
|
||||
return when {
|
||||
next === cur -> this
|
||||
next.isEmpty() -> remove(key)
|
||||
else -> put(key, next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PersistentMap<String, PersistentMap<String, PersistentSet<S>>>.addTagSub(
|
||||
letter: String,
|
||||
value: String,
|
||||
sub: S,
|
||||
): PersistentMap<String, PersistentMap<String, PersistentSet<S>>> {
|
||||
val inner = this[letter] ?: persistentHashMapOf()
|
||||
val newInner = inner.addSub(value, sub)
|
||||
return if (newInner === inner) this else put(letter, newInner)
|
||||
}
|
||||
|
||||
private fun PersistentMap<String, PersistentMap<String, PersistentSet<S>>>.removeTagSub(
|
||||
letter: String,
|
||||
value: String,
|
||||
sub: S,
|
||||
): PersistentMap<String, PersistentMap<String, PersistentSet<S>>> {
|
||||
val inner = this[letter] ?: return this
|
||||
val newInner = inner.removeSub(value, sub)
|
||||
return when {
|
||||
newInner === inner -> this
|
||||
newInner.isEmpty() -> remove(letter)
|
||||
else -> put(letter, newInner)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.ClosedSendChannelException
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -291,8 +292,16 @@ class RelaySession(
|
||||
// Policy may rewrite filters to match the user's access level.
|
||||
val filters = (result as PolicyResult.Accepted).cmd.filters
|
||||
|
||||
// UNDISPATCHED: the stored replay runs inline on this coroutine —
|
||||
// the reader-pool acquire doesn't suspend when a connection is
|
||||
// free, so EVENT frames and EOSE go out without a scheduler hop
|
||||
// (SmallReqFloorBenchmark: the hop was most of the dispatch
|
||||
// slice on small REQs). The coroutine first parks at the live
|
||||
// tail (awaitCancellation), which is when launch returns and the
|
||||
// job lands in [subscriptions]; commands on this connection are
|
||||
// processed sequentially, so nothing can target the sub earlier.
|
||||
val job =
|
||||
scope.launch {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
try {
|
||||
if (policy.filtersOutgoingEvents) {
|
||||
// Screened path: every event is materialized so the
|
||||
@@ -331,7 +340,20 @@ class RelaySession(
|
||||
},
|
||||
)
|
||||
},
|
||||
onEachLive = { event -> send(EventMessage(cmd.subId, event)) },
|
||||
// Live events arrive with their wire body already
|
||||
// serialized (once per event, shared across every
|
||||
// matching subscription): splice it into the same
|
||||
// per-sub frame prefix as the stored replay, no
|
||||
// per-event EventMessage or re-serialize.
|
||||
onEachLive = { _, body ->
|
||||
sendRaw(
|
||||
buildString(framePrefix.length + body.length + 1) {
|
||||
append(framePrefix)
|
||||
append(body)
|
||||
append(']')
|
||||
},
|
||||
)
|
||||
},
|
||||
onEose = { send(EoseMessage(cmd.subId)) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,13 +74,57 @@ class LiveEventStore(
|
||||
* One live REQ subscription. Carries the filters (for the
|
||||
* post-index `match` re-check needed for negative constraints
|
||||
* like `since` / `until` / `tagsAll`) and the delivery callback
|
||||
* the index dispatches into. Identity-keyed inside [FilterIndex].
|
||||
* the index dispatches into. [deliver] receives the event and its
|
||||
* pre-serialized wire body (memoized once per fanout across all
|
||||
* matching subscribers). Identity-keyed inside [FilterIndex].
|
||||
*/
|
||||
private class LiveSubscription(
|
||||
val filters: List<Filter>,
|
||||
val deliver: (Event) -> Unit,
|
||||
val deliver: (Event, String) -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Replay-dedupe set for one REQ. During the historical replay the
|
||||
* store's ids are [record]ed here so the concurrent live path can
|
||||
* drop an event the replay also emitted; after EOSE the set is
|
||||
* [release]d and the live path forwards everything.
|
||||
*
|
||||
* Written from the replay coroutine and read from the [IngestQueue]
|
||||
* drain coroutine (via `fanout`), so every access takes a tiny spin
|
||||
* lock — [locked] is `inline`, so the per-row `record` / `isDuplicate`
|
||||
* calls allocate no closure. The backing `HashSet` is created empty
|
||||
* up front (so the register-before-replay race guarantee holds) but
|
||||
* the JVM defers its table allocation to the first `add`, so a
|
||||
* zero-row replay costs only the empty set object, not a sized table.
|
||||
* It MUST stay a mutable set under a lock, never a copy-on-add
|
||||
* immutable set — `set + id` per row made large replays O(n²).
|
||||
*/
|
||||
private class SeenIds {
|
||||
private val lock = AtomicBoolean(false)
|
||||
private var ids: HashSet<String>? = HashSet()
|
||||
|
||||
private inline fun <R> locked(block: () -> R): R {
|
||||
while (lock.exchange(true)) {
|
||||
while (lock.load()) { }
|
||||
}
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
lock.store(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun record(id: String) {
|
||||
locked { ids?.add(id) }
|
||||
}
|
||||
|
||||
fun isDuplicate(id: String): Boolean = locked { ids?.contains(id) ?: false }
|
||||
|
||||
fun release() {
|
||||
locked { ids = null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget enqueue: hand [event] to the [IngestQueue] and
|
||||
* fire [onComplete] once the writer's batch has a per-row
|
||||
@@ -149,9 +193,17 @@ class LiveEventStore(
|
||||
* batch writer.
|
||||
*/
|
||||
private fun fanout(event: Event) {
|
||||
for (sub in index.candidatesFor(event)) {
|
||||
val candidates = index.candidatesFor(event)
|
||||
if (candidates.isEmpty()) return
|
||||
// Serialize the wire body at most once for this event, no matter
|
||||
// how many subscriptions match it — the old path re-serialized the
|
||||
// whole event per matching subscriber, so a note landing in N live
|
||||
// feeds paid N identical Jackson passes. Lazy so a fanout that
|
||||
// matches nothing (index over-approximates) serializes nothing.
|
||||
var body: String? = null
|
||||
for (sub in candidates) {
|
||||
if (sub.filters.any { it.match(event) }) {
|
||||
sub.deliver(event)
|
||||
sub.deliver(event, body ?: event.toJson().also { body = it })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,61 +230,33 @@ class LiveEventStore(
|
||||
onEose: () -> Unit,
|
||||
) {
|
||||
drainFtsIfSearching(filters)
|
||||
// During the historical replay, record ids the store has
|
||||
// emitted so the live path can dedupe. The index registers
|
||||
// *before* the replay starts (otherwise an event accepted
|
||||
// mid-replay would slip past the live path entirely — same
|
||||
// race the previous SharedFlow-based implementation closed
|
||||
// with `onSubscription`).
|
||||
//
|
||||
// The set is read from the [IngestQueue] drain coroutine (in
|
||||
// `deliver`, called synchronously from `fanout`) and written
|
||||
// from this coroutine (the historical-replay closure below),
|
||||
// so access is guarded by a tiny spin lock (contains/add,
|
||||
// never I/O). It MUST be a mutable set under a lock, not an
|
||||
// immutable Set under an AtomicReference with copy-on-add:
|
||||
// `set + id` copies the whole set per streamed event, which
|
||||
// made large replays accidentally O(n²) — a 100k-event REQ
|
||||
// crawled at ~700 events/s and the rate degraded as the
|
||||
// response grew (see the plan doc's giant-REQ finding).
|
||||
//
|
||||
// Once cleared to null after EOSE, `deliver` short-circuits
|
||||
// and every live event is forwarded.
|
||||
val seenLock = AtomicBoolean(false)
|
||||
var seenIds: HashSet<String>? = HashSet(1024)
|
||||
|
||||
fun <R> seenLocked(block: () -> R): R {
|
||||
while (seenLock.exchange(true)) {
|
||||
while (seenLock.load()) { }
|
||||
}
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
seenLock.store(false)
|
||||
}
|
||||
}
|
||||
// The index registers *before* the replay starts (otherwise an
|
||||
// event accepted mid-replay would slip past the live path entirely
|
||||
// — same race the previous SharedFlow-based implementation closed
|
||||
// with `onSubscription`), and [SeenIds] bridges the two coroutines:
|
||||
// the replay records ids here, the live `deliver` drops duplicates,
|
||||
// and after EOSE the set is released so every live event forwards.
|
||||
val seen = SeenIds()
|
||||
|
||||
val sub =
|
||||
LiveSubscription(
|
||||
filters = filters,
|
||||
deliver = { event ->
|
||||
val duplicate = seenLocked { seenIds?.contains(event.id) ?: false }
|
||||
if (duplicate) return@LiveSubscription
|
||||
onEach(event)
|
||||
deliver = { event, _ ->
|
||||
if (!seen.isDuplicate(event.id)) onEach(event)
|
||||
},
|
||||
)
|
||||
|
||||
index.register(filters, sub)
|
||||
try {
|
||||
store.query<Event>(filters.strippingSearchExtensions()) { event ->
|
||||
seenLocked { seenIds?.add(event.id) }
|
||||
seen.record(event.id)
|
||||
onEach(event)
|
||||
}
|
||||
onEose()
|
||||
// Drop the dedupe set so the live path stops paying for
|
||||
// it. From this point the index drives delivery and
|
||||
// duplicates are no longer possible.
|
||||
seenLocked { seenIds = null }
|
||||
seen.release()
|
||||
// Suspend until the caller's coroutine is cancelled
|
||||
// (e.g. NIP-01 CLOSE or connection drop). The `finally`
|
||||
// unregisters from the index.
|
||||
@@ -255,42 +279,28 @@ class LiveEventStore(
|
||||
ctx: RequestContext,
|
||||
filters: List<Filter>,
|
||||
onEachStored: (RawEvent) -> Unit,
|
||||
onEachLive: (Event) -> Unit,
|
||||
onEachLive: (Event, String) -> Unit,
|
||||
onEose: () -> Unit,
|
||||
) {
|
||||
drainFtsIfSearching(filters)
|
||||
val seenLock = AtomicBoolean(false)
|
||||
var seenIds: HashSet<String>? = HashSet(1024)
|
||||
|
||||
fun <R> seenLocked(block: () -> R): R {
|
||||
while (seenLock.exchange(true)) {
|
||||
while (seenLock.load()) { }
|
||||
}
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
seenLock.store(false)
|
||||
}
|
||||
}
|
||||
val seen = SeenIds()
|
||||
|
||||
val sub =
|
||||
LiveSubscription(
|
||||
filters = filters,
|
||||
deliver = { event ->
|
||||
val duplicate = seenLocked { seenIds?.contains(event.id) ?: false }
|
||||
if (duplicate) return@LiveSubscription
|
||||
onEachLive(event)
|
||||
deliver = { event, body ->
|
||||
if (!seen.isDuplicate(event.id)) onEachLive(event, body)
|
||||
},
|
||||
)
|
||||
|
||||
index.register(filters, sub)
|
||||
try {
|
||||
store.rawQuery(filters.strippingSearchExtensions()) { raw ->
|
||||
seenLocked { seenIds?.add(raw.id) }
|
||||
seen.record(raw.id)
|
||||
onEachStored(raw)
|
||||
}
|
||||
onEose()
|
||||
seenLocked { seenIds = null }
|
||||
seen.release()
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
index.unregister(sub)
|
||||
|
||||
@@ -78,9 +78,9 @@ interface SessionBackend {
|
||||
ctx: RequestContext,
|
||||
filters: List<Filter>,
|
||||
onEachStored: (RawEvent) -> Unit,
|
||||
onEachLive: (Event) -> Unit,
|
||||
onEachLive: (Event, String) -> Unit,
|
||||
onEose: () -> Unit,
|
||||
): Unit = query(ctx, filters, onEachLive, onEose)
|
||||
): Unit = query(ctx, filters, { onEachLive(it, it.toJson()) }, onEose)
|
||||
|
||||
/** Answers a NIP-45 COUNT with an exact cardinality for the caller in [ctx]. */
|
||||
suspend fun count(
|
||||
|
||||
@@ -147,13 +147,38 @@ class EventIndexesModule(
|
||||
*/
|
||||
fun migrateV2AddPubkeyIndex(db: SQLiteConnection) {
|
||||
if (!indexStrategy.indexEventsByPubkeyAlone) return
|
||||
val orderBy =
|
||||
if (indexStrategy.useAndIndexIdOnOrderBy) {
|
||||
"created_at DESC, id ASC"
|
||||
} else {
|
||||
"created_at DESC"
|
||||
}
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_pubkey_created ON event_headers (pubkey, $orderBy)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_pubkey_created ON event_headers (pubkey, ${orderByColumns()})")
|
||||
}
|
||||
|
||||
private fun orderByColumns() =
|
||||
if (indexStrategy.useAndIndexIdOnOrderBy) {
|
||||
"created_at DESC, id ASC"
|
||||
} else {
|
||||
"created_at DESC"
|
||||
}
|
||||
|
||||
/**
|
||||
* Materializes any flag-gated index the current [indexStrategy] wants
|
||||
* but the on-disk schema predates. Flags are runtime configuration, not
|
||||
* schema — a deployment can flip one without a `user_version` bump — so
|
||||
* this runs idempotently on every open. The first open after enabling a
|
||||
* flag pays a one-time index build over the existing rows; subsequent
|
||||
* opens are no-ops. A disabled flag never drops an existing index (that
|
||||
* stays an operator decision).
|
||||
*/
|
||||
fun ensureOptionalIndexes(db: SQLiteConnection) {
|
||||
if (indexStrategy.indexEventsByCreatedAtAlone) {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_created_at_id ON event_headers (${orderByColumns()})")
|
||||
}
|
||||
if (indexStrategy.indexEventsByPubkeyAlone) {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_pubkey_created ON event_headers (pubkey, ${orderByColumns()})")
|
||||
}
|
||||
if (indexStrategy.indexTagsByCreatedAtAlone) {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_tags_hash ON event_tags (tag_hash, created_at DESC)")
|
||||
}
|
||||
if (indexStrategy.indexTagsWithKindAndPubkey) {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_tags_hash_kind_pubkey ON event_tags (tag_hash, kind, pubkey_hash, created_at DESC)")
|
||||
}
|
||||
}
|
||||
|
||||
val sqlInsertHeader =
|
||||
|
||||
@@ -74,9 +74,21 @@ interface IndexingStrategy {
|
||||
* Activate this if you see too many Tag-centric Filters without
|
||||
* kind AND pubkey at the same time.
|
||||
*
|
||||
* This is a rarely used index (reports by your follows or
|
||||
* NIP-04 DMs for instance) that becomes quite large without
|
||||
* major gains.
|
||||
* This shape (reports by your follows, NIP-04 DM rooms, follows-scoped
|
||||
* community feeds) is not rare on the client side: the 2026-07 filter
|
||||
* assembler survey counted 65 call sites building
|
||||
* `kinds + authors + tags`. Without this index the plan seeks
|
||||
* `(tag_hash, kind)` and reads every row for that tag/kind before
|
||||
* filtering the author.
|
||||
*
|
||||
* Measured by `TagAuthorIndexBenchmark` (jvmTest prodbench): the
|
||||
* DM-room query drops 9.4 ms → 0.6 ms (~15×) at 200k events and
|
||||
* 14.2 ms → 0.66 ms (~21×) at 1M — the gap grows with corpus size —
|
||||
* while batch-insert cost stays inside run noise (49.0 vs 47.4
|
||||
* µs/event at 1M). geode enables it; the client default stays off
|
||||
* because a client store's per-tag row counts are bounded by one
|
||||
* user's data. Flipping it on an existing DB is safe: the index is
|
||||
* built on next open by `EventIndexesModule.ensureOptionalIndexes`.
|
||||
*
|
||||
* Keep in mind that activating too many indexes increases the size of the
|
||||
* DB so much that the indexes themselves won't fit in memory, requiring
|
||||
|
||||
@@ -52,6 +52,17 @@ import androidx.sqlite.SQLiteStatement
|
||||
* exactly at a same-second boundary.
|
||||
*/
|
||||
internal object MergeQueryExecutor {
|
||||
// TODO: the same collect-all + TEMP-B-TREE-sort pattern exists one index
|
||||
// over, on the tag path: `kinds + tags(#e IN [hundreds]) + limit` (the
|
||||
// reactions/replies watcher archetype) unions per-value streams that are
|
||||
// each sorted off `(tag_hash, kind, created_at)` and sorts the union.
|
||||
// `streamCount` currently rejects any filter with tags, so those queries
|
||||
// never merge. Measured by `TagAuthorIndexBenchmark`: `#e IN 300,
|
||||
// limit 500` costs 12.8 ms cold at 200k events and 14.2 ms at 1M
|
||||
// (6.7 ms with indexTagsWithKindAndPubkey on) — tolerable, but it
|
||||
// scales with matching history like the follow-feed shape did; extend
|
||||
// the merge to per-tag-value streams if the relayBench
|
||||
// `reactions-watch` scenario shows it in the profile vs strfry.
|
||||
const val COLS = "id, pubkey, created_at, kind, tags, content, sig"
|
||||
|
||||
/**
|
||||
|
||||
@@ -160,6 +160,11 @@ class SQLiteEventStore(
|
||||
setUserVersion(this, DATABASE_VERSION)
|
||||
}
|
||||
}
|
||||
// Flag-gated indexes are runtime config, not schema: a
|
||||
// deployment that flips an IndexingStrategy flag on an
|
||||
// existing DB gets the index built here (idempotent,
|
||||
// one-time cost), with no user_version bump involved.
|
||||
eventIndexModule.ensureOptionalIndexes(db)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -202,8 +202,20 @@ fun Filter.strippingSearchExtensions(): Filter {
|
||||
/**
|
||||
* Applies [strippingSearchExtensions] to every filter, returning this
|
||||
* same list when no filter carried extension tokens.
|
||||
*
|
||||
* This runs on every REQ/COUNT/snapshot, and the overwhelming majority
|
||||
* carry no `search` term at all, so the no-search case must not allocate:
|
||||
* bail before building any list when nothing could be stripped.
|
||||
*/
|
||||
fun List<Filter>.strippingSearchExtensions(): List<Filter> {
|
||||
var hasSearch = false
|
||||
for (i in indices) {
|
||||
if (!this[i].search.isNullOrEmpty()) {
|
||||
hasSearch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!hasSearch) return this
|
||||
var changed = false
|
||||
val out =
|
||||
map {
|
||||
|
||||
@@ -74,7 +74,7 @@ data class BlossomPaymentRequired(
|
||||
const val MAX_REASON_LENGTH = 200
|
||||
|
||||
/** LRE/RLE/PDF/LRO/RLO and the isolate family — invisible, and they reorder what follows. */
|
||||
private val BIDI_OVERRIDES = charArrayOf('', '', '', '', '', '', '', '', '', '', '')
|
||||
private val BIDI_OVERRIDES = charArrayOf('\u202A', '\u202B', '\u202C', '\u202D', '\u202E', '\u2066', '\u2067', '\u2068', '\u2069', '\u200F', '\u200E')
|
||||
|
||||
private val WHITESPACE_RUN = Regex("\\s+")
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.isAddressable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher
|
||||
import java.nio.file.DirectoryStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
@@ -36,16 +37,23 @@ import kotlin.io.path.exists
|
||||
*
|
||||
* Step-2 coverage:
|
||||
* - `ids` → direct canonical opens
|
||||
* - `tagsAll`/`tags` → tag index union (first key)
|
||||
* - `kinds` → kind index union
|
||||
* - `authors` → author index union
|
||||
* - `tagsAll`/`tags`/`kinds`/`authors` → cheapest index tree drives
|
||||
* (capped entry-count comparison), the rest post-filter
|
||||
* - otherwise → full scan via every `idx/kind/<k>/` subtree
|
||||
*
|
||||
* The planner is intentionally dumb about selectivity — "first available
|
||||
* driver wins". A cost-based picker (smallest listing) can slot in
|
||||
* later without changing callers. All FilterMatcher semantics (tag
|
||||
* AND/OR, since/until, id, author, kind cross-checks) are enforced in
|
||||
* the orchestrator, so picking a loose driver is correctness-safe.
|
||||
* Driver choice is cost-based: every legal driver (each `tagsAll` value
|
||||
* alone — AND semantics make any single value a complete driver — each
|
||||
* `tags` key's value union, the kind set, the author set) opens a lazy
|
||||
* directory iterator, all are drained in lockstep, and the first to
|
||||
* exhaust — the smallest listing — drives. A giant tree (`idx/kind/1/`
|
||||
* with a million entries) is therefore never read past ~the smallest
|
||||
* candidate's size. Before the pick, the fixed tags → kinds → authors
|
||||
* order sent `authors + kinds + limit` — the most common CLI shape —
|
||||
* through the kind tree: 149 ms fixed-order vs 4.0 ms cost-based at 30k
|
||||
* events per `FsDriverSelectionBenchmark` (floor: author-only at
|
||||
* 1.4 ms). All FilterMatcher semantics (tag AND/OR,
|
||||
* since/until, id, author, kind cross-checks) are enforced in the
|
||||
* orchestrator, so any driver pick is correctness-safe.
|
||||
*/
|
||||
internal class FsQueryPlanner(
|
||||
private val layout: FsLayout,
|
||||
@@ -74,19 +82,100 @@ internal class FsQueryPlanner(
|
||||
return ftsDriver(search)
|
||||
}
|
||||
|
||||
firstTagKey(filter)?.let { (name, values) ->
|
||||
return mergeDesc(values.map { v -> walkDir(layout.tagValueDir(name, v, hasher.hash(name, v))) })
|
||||
val candidates = driverCandidates(filter)
|
||||
if (candidates.isEmpty()) return allKindsDriver()
|
||||
|
||||
return mergeDesc(cheapestDriver(candidates).map { walkDir(it) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Every set of index directories that, walked and post-filtered, yields
|
||||
* a superset of the filter's matches:
|
||||
* - each `tagsAll` value alone (AND semantics — every match carries it),
|
||||
* - each `tags` key's full value union (OR within a key, AND across),
|
||||
* - the kind set, and the author set.
|
||||
* Listed in the old fixed-priority order so [cheapestDriver] keeps that
|
||||
* order on cost ties.
|
||||
*/
|
||||
private fun driverCandidates(filter: Filter): List<List<Path>> {
|
||||
val out = ArrayList<List<Path>>()
|
||||
filter.tagsAll?.forEach { (name, values) ->
|
||||
values.forEach { v -> out.add(listOf(layout.tagValueDir(name, v, hasher.hash(name, v)))) }
|
||||
}
|
||||
filter.tags?.forEach { (name, values) ->
|
||||
if (values.isNotEmpty()) {
|
||||
out.add(values.map { v -> layout.tagValueDir(name, v, hasher.hash(name, v)) })
|
||||
}
|
||||
}
|
||||
filter.kinds?.takeIf { it.isNotEmpty() }?.let { kinds ->
|
||||
out.add(kinds.map { layout.kindDir(it) })
|
||||
}
|
||||
filter.authors?.takeIf { it.isNotEmpty() }?.let { authors ->
|
||||
out.add(authors.map { layout.authorDir(it) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Smallest candidate by lockstep listing drain: one lazy directory
|
||||
* iterator per candidate, all advanced [COST_BATCH] entries per round —
|
||||
* the first to exhaust its listing is the smallest, so a giant tree is
|
||||
* never read past ~the smallest candidate's size (a candidate that
|
||||
* exhausts on round one costs the others one batch each). A candidate
|
||||
* whose dirs are all missing exhausts immediately: driving from an empty
|
||||
* mandatory predicate correctly yields an empty result. If every
|
||||
* candidate survives [COST_CAP] entries, all are huge and relative
|
||||
* driver choice stops mattering — the first (old fixed-priority order)
|
||||
* wins.
|
||||
*/
|
||||
private fun cheapestDriver(candidates: List<List<Path>>): List<Path> {
|
||||
if (candidates.size == 1) return candidates[0]
|
||||
val cursors = candidates.map { EntryCursor(it) }
|
||||
try {
|
||||
var advanced = 0L
|
||||
while (advanced < COST_CAP) {
|
||||
for (i in cursors.indices) {
|
||||
if (!cursors[i].skip(COST_BATCH)) return candidates[i]
|
||||
}
|
||||
advanced += COST_BATCH
|
||||
}
|
||||
return candidates[0]
|
||||
} finally {
|
||||
cursors.forEach { it.close() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazy entry iterator over a candidate's directories, in order. */
|
||||
private class EntryCursor(
|
||||
dirs: List<Path>,
|
||||
) : AutoCloseable {
|
||||
private val remaining = ArrayDeque(dirs)
|
||||
private var stream: DirectoryStream<Path>? = null
|
||||
private var iter: Iterator<Path> = emptyList<Path>().iterator()
|
||||
|
||||
/** Advances up to [n] entries; false when the listing ends first. */
|
||||
fun skip(n: Int): Boolean {
|
||||
var left = n
|
||||
while (left > 0) {
|
||||
if (iter.hasNext()) {
|
||||
iter.next()
|
||||
left--
|
||||
continue
|
||||
}
|
||||
close()
|
||||
val dir = remaining.removeFirstOrNull() ?: return false
|
||||
if (!Files.isDirectory(dir)) continue
|
||||
stream = Files.newDirectoryStream(dir)
|
||||
iter = stream!!.iterator()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
filter.kinds?.let { kinds ->
|
||||
return mergeDesc(kinds.map { walkDir(layout.kindDir(it)) })
|
||||
override fun close() {
|
||||
stream?.close()
|
||||
stream = null
|
||||
iter = emptyList<Path>().iterator()
|
||||
}
|
||||
|
||||
filter.authors?.let { authors ->
|
||||
return mergeDesc(authors.map { walkDir(layout.authorDir(it)) })
|
||||
}
|
||||
|
||||
return allKindsDriver()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -301,14 +390,15 @@ internal class FsQueryPlanner(
|
||||
var top: Candidate,
|
||||
)
|
||||
|
||||
// ---- helpers ------------------------------------------------------
|
||||
private companion object {
|
||||
/** Entries each candidate's cursor advances per lockstep round. */
|
||||
const val COST_BATCH = 64
|
||||
|
||||
/** First tag filter with at least one value, preferring `tagsAll`. */
|
||||
private fun firstTagKey(filter: Filter): Pair<String, List<String>>? {
|
||||
filter.tagsAll?.firstNonEmpty()?.let { return it }
|
||||
filter.tags?.firstNonEmpty()?.let { return it }
|
||||
return null
|
||||
/**
|
||||
* Stop draining once every candidate has survived this many
|
||||
* entries: past it they are all huge, relative choice stops
|
||||
* mattering, and the first candidate in priority order wins.
|
||||
*/
|
||||
const val COST_CAP = 65_536L
|
||||
}
|
||||
|
||||
private fun Map<String, List<String>>.firstNonEmpty(): Pair<String, List<String>>? = entries.firstOrNull { it.value.isNotEmpty() }?.let { it.key to it.value }
|
||||
}
|
||||
|
||||
@@ -32,11 +32,14 @@ import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@@ -65,6 +68,8 @@ class SmallReqFloorBenchmark {
|
||||
const val AUTHORS = 2_500 // ~20 events per author, matching author-archive
|
||||
const val ROUNDS = 400
|
||||
const val WARMUP = 100
|
||||
const val IDLE_SUBS = 1_000
|
||||
const val FANOUT_SUBS = 200
|
||||
}
|
||||
|
||||
private fun hexId(seed: Int): String = seed.toString(16).padStart(64, '0')
|
||||
@@ -122,16 +127,19 @@ class SmallReqFloorBenchmark {
|
||||
}
|
||||
|
||||
// --- B: backend queryRaw to EOSE (live machinery included) ---
|
||||
// UNDISPATCHED mirrors the production path (RelaySession.handleReq
|
||||
// starts the query coroutine undispatched), so B−A is the live
|
||||
// machinery itself, not a benchmark-only scheduler hop.
|
||||
suspend fun timeBackend(round: Int): Long {
|
||||
val eose = CompletableDeferred<Long>()
|
||||
val t0 = System.nanoTime()
|
||||
val job =
|
||||
scope.launch {
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
live.queryRaw(
|
||||
ctx = ctx,
|
||||
filters = listOf(filterFor(round)),
|
||||
onEachStored = {},
|
||||
onEachLive = {},
|
||||
onEachLive = { _, _ -> },
|
||||
onEose = { eose.complete(System.nanoTime() - t0) },
|
||||
)
|
||||
}
|
||||
@@ -143,6 +151,33 @@ class SmallReqFloorBenchmark {
|
||||
val b = LongArray(ROUNDS)
|
||||
repeat(ROUNDS) { b[it] = timeBackend(it) }
|
||||
|
||||
// --- B@1k: same, with 1000 idle live subscriptions parked ---
|
||||
// Register/unregister cost scales with the live population
|
||||
// (FilterIndex mutates a shared snapshot per REQ open/close),
|
||||
// which the single-sub stage can't see. Each idle sub filters
|
||||
// on an author absent from the corpus: 0-row replay, then parks
|
||||
// at the live tail and stays registered.
|
||||
val idleJobs =
|
||||
(0 until IDLE_SUBS).map { i ->
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
val job =
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
live.queryRaw(
|
||||
ctx = ctx,
|
||||
filters = listOf(Filter(authors = listOf(hexId(1_000_000 + i)), kinds = listOf(1), limit = 1)),
|
||||
onEachStored = {},
|
||||
onEachLive = { _, _ -> },
|
||||
onEose = { ready.complete(Unit) },
|
||||
)
|
||||
}
|
||||
ready.await()
|
||||
job
|
||||
}
|
||||
repeat(WARMUP) { timeBackend(it) }
|
||||
val b1k = LongArray(ROUNDS)
|
||||
repeat(ROUNDS) { b1k[it] = timeBackend(it) }
|
||||
idleJobs.forEach { it.cancel() }
|
||||
|
||||
// --- C: full session dispatch, REQ json in → EOSE frame out ---
|
||||
suspend fun timeSession(round: Int): Long {
|
||||
val eose = CompletableDeferred<Long>()
|
||||
@@ -160,15 +195,67 @@ class SmallReqFloorBenchmark {
|
||||
val c = LongArray(ROUNDS)
|
||||
repeat(ROUNDS) { c[it] = timeSession(it) }
|
||||
|
||||
// --- fanout: one live event → FANOUT_SUBS live subscriptions ---
|
||||
// All subs register on `live` directly (via queryRaw, same backend
|
||||
// we submit into) and filter an author with no stored events (0-row
|
||||
// replay, then park live). Submitting one matching event fans out to
|
||||
// every sub; the body is serialized once and spliced per sub, so
|
||||
// this measures the shared-serialization path (#2). skipVerify so
|
||||
// the synthetic sig is accepted. Fewer rounds than A–C: each round
|
||||
// is FANOUT_SUBS deliveries and a real group-commit insert.
|
||||
val fanAuthor = hexId(9_000_001)
|
||||
val delivered = AtomicInteger(0)
|
||||
var fanDone = CompletableDeferred<Long>()
|
||||
var fanStart = 0L
|
||||
val fanJobs =
|
||||
(0 until FANOUT_SUBS).map {
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
val job =
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
live.queryRaw(
|
||||
ctx = ctx,
|
||||
filters = listOf(Filter(authors = listOf(fanAuthor), kinds = listOf(1))),
|
||||
onEachStored = {},
|
||||
onEachLive = { _, _ ->
|
||||
if (delivered.incrementAndGet() == FANOUT_SUBS) {
|
||||
fanDone.complete(System.nanoTime() - fanStart)
|
||||
}
|
||||
},
|
||||
onEose = { ready.complete(Unit) },
|
||||
)
|
||||
}
|
||||
ready.await()
|
||||
job
|
||||
}
|
||||
val fanRounds = 60
|
||||
val fanWarmup = 15
|
||||
val fan = LongArray(fanRounds)
|
||||
var fanSeq = 0
|
||||
repeat(fanWarmup + fanRounds) { r ->
|
||||
delivered.set(0)
|
||||
fanDone = CompletableDeferred()
|
||||
val ev = EventFactory.create<Event>(hexId(9_500_000 + fanSeq), fanAuthor, 1_700_000_000L + fanSeq, 1, emptyArray(), "fanout $fanSeq", sig)
|
||||
fanSeq++
|
||||
fanStart = System.nanoTime()
|
||||
live.submit(ev, skipVerify = true) {}
|
||||
val nanos = withTimeout(30_000) { fanDone.await() }
|
||||
if (r >= fanWarmup) fan[r - fanWarmup] = nanos
|
||||
}
|
||||
fanJobs.forEach { it.cancel() }
|
||||
|
||||
assertEquals(true, rowsA > 0, "author filters must return rows")
|
||||
|
||||
val mA = median(a)
|
||||
val mB = median(b)
|
||||
val mB1k = median(b1k)
|
||||
val mC = median(c)
|
||||
println("SmallReqFloorBenchmark @ ${EVENTS / 1000}k events, ~${rowsA / ROUNDS} rows/req, medians of $ROUNDS")
|
||||
println(" A raw store query: ${"%6.3f".format(mA)} ms")
|
||||
println(" B backend queryRaw→EOSE: ${"%6.3f".format(mB)} ms (live machinery +${"%6.3f".format(mB - mA)})")
|
||||
println(" B@${IDLE_SUBS} idle subs: ${"%6.3f".format(mB1k)} ms (population cost +${"%6.3f".format(mB1k - mB)})")
|
||||
println(" C session REQ→EOSE: ${"%6.3f".format(mC)} ms (dispatch+frames +${"%6.3f".format(mC - mB)})")
|
||||
val mFan = median(fan)
|
||||
println(" fanout 1→$FANOUT_SUBS live subs: ${"%6.3f".format(mFan)} ms (${"%.2f".format(mFan * 1000 / FANOUT_SUBS)} µs/sub; body serialized once)")
|
||||
|
||||
server.close()
|
||||
scope.cancel()
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.quartz.nip01Core.relay.prodbench
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
|
||||
/**
|
||||
* Measures the two tag-path query shapes the client filter-assembler survey
|
||||
* (2026-07) found hot but that no existing benchmark covers:
|
||||
*
|
||||
* 1. **tag ∩ author (DM-room shape)** — `kinds=[4] AND authors=[peer] AND
|
||||
* #p=[me] LIMIT n`. 65 assembler call sites build this shape (every
|
||||
* NIP-04 chat room, reports-by-follows, follows-scoped community feeds).
|
||||
* [com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy.indexTagsWithKindAndPubkey]
|
||||
* gates a covering `(tag_hash, kind, pubkey_hash, created_at)` index for
|
||||
* it, but the flag is off everywhere (including geode). Without it the
|
||||
* plan seeks `(tag_hash, kind)` and reads EVERY DM the user has ever
|
||||
* received before filtering to the one peer. This compares query latency
|
||||
* with the flag off vs on, and the batch-insert cost the extra index adds.
|
||||
*
|
||||
* 2. **large-IN tag watcher (reactions shape)** — `kinds=[7] AND
|
||||
* #e=[hundreds of note ids] LIMIT n`. The per-value streams come sorted
|
||||
* off `(tag_hash, kind, created_at)`, but their union does not, so SQLite
|
||||
* collects every matching row and TEMP-B-TREE sorts to the limit — the
|
||||
* tag-index analogue of the follow-feed regression
|
||||
* [MergeQueryExecutor] fixed for author streams. Reported with and
|
||||
* without a `since` bound to show what EOSE-warm steady state hides.
|
||||
*
|
||||
* Size the seed with `-DtagBenchScale=N` (default 1 ≈ ~200k events).
|
||||
*/
|
||||
class TagAuthorIndexBenchmark {
|
||||
companion object {
|
||||
val SCALE = System.getProperty("tagBenchScale")?.toInt() ?: 1
|
||||
}
|
||||
|
||||
private val hex = "0123456789abcdef"
|
||||
|
||||
private fun mix(seed: Long): Long {
|
||||
var z = seed + -0x61c8864680b583ebL
|
||||
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
|
||||
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
|
||||
return z xor (z ushr 31)
|
||||
}
|
||||
|
||||
private fun hex64(
|
||||
salt: Long,
|
||||
index: Int,
|
||||
): String {
|
||||
val out = CharArray(64)
|
||||
for (w in 0 until 4) {
|
||||
val v = mix(salt * 1_000_003 + index.toLong() * 4 + w)
|
||||
for (b in 0 until 8) {
|
||||
val byte = ((v ushr (b * 8)) and 0xFF).toInt()
|
||||
out[(w * 8 + b) * 2] = hex[byte ushr 4]
|
||||
out[(w * 8 + b) * 2 + 1] = hex[byte and 0xF]
|
||||
}
|
||||
}
|
||||
return String(out)
|
||||
}
|
||||
|
||||
private val sig = "0".repeat(128)
|
||||
private var idSeq = 0
|
||||
|
||||
private fun ev(
|
||||
pubkey: String,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
): Event = EventFactory.create(hex64(7, idSeq++), pubkey, createdAt, kind, tags, "", sig)
|
||||
|
||||
private fun seedEvents(): List<Event> {
|
||||
idSeq = 0
|
||||
val base = 1_700_000_000L
|
||||
val span = 3_000_000L // ~35 days
|
||||
val me = hex64(9, 0)
|
||||
val events = ArrayList<Event>(220_000 * SCALE)
|
||||
|
||||
// DM inbox: 200 peers, 300 DMs each → 60k kind-4 rows sharing the
|
||||
// same (p:me) tag hash. The room query wants one peer's 300.
|
||||
val peers = (0 until 200).map { hex64(2, it) }
|
||||
for ((i, peer) in peers.withIndex()) {
|
||||
repeat(300 * SCALE) {
|
||||
val ts = base + (mix(i * 131L + it) and 0x7fffffff) % span
|
||||
events.add(ev(peer, ts, 4, arrayOf(arrayOf("p", me))))
|
||||
}
|
||||
}
|
||||
|
||||
// Notification noise: 2000 authors mention me in kind-1 notes, so
|
||||
// (p:me) spans multiple kinds like a real inbox does.
|
||||
repeat(40_000 * SCALE) {
|
||||
val author = hex64(3, it % 2_000)
|
||||
val ts = base + (mix(it * 17L) and 0x7fffffff) % span
|
||||
events.add(ev(author, ts, 1, arrayOf(arrayOf("p", me))))
|
||||
}
|
||||
|
||||
// Reactions: 100k kind-7 events spread over 5000 target notes, for
|
||||
// the large-IN watcher shape.
|
||||
val noteIds = (0 until 5_000).map { hex64(5, it) }
|
||||
repeat(100_000 * SCALE) {
|
||||
val author = hex64(4, it % 3_000)
|
||||
val ts = base + (mix(it * 29L) and 0x7fffffff) % span
|
||||
events.add(ev(author, ts, 7, arrayOf(arrayOf("e", noteIds[it % noteIds.size]))))
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compareTagAuthorIndex() =
|
||||
runBlocking {
|
||||
val events = seedEvents()
|
||||
val me = hex64(9, 0)
|
||||
val peers = (0 until 200).map { hex64(2, it) }
|
||||
val noteIds = (0 until 5_000).map { hex64(5, it) }
|
||||
|
||||
println("─ TagAuthorIndexBenchmark: ${events.size} events (scale=$SCALE) ─")
|
||||
|
||||
val strategies =
|
||||
listOf(
|
||||
"flag-off" to DefaultIndexingStrategy(indexFullTextSearch = false),
|
||||
"flag-on " to DefaultIndexingStrategy(indexFullTextSearch = false, indexTagsWithKindAndPubkey = true),
|
||||
)
|
||||
|
||||
for ((label, strategy) in strategies) {
|
||||
val store = EventStore(dbName = null, indexStrategy = strategy)
|
||||
|
||||
val t0 = System.nanoTime()
|
||||
events.chunked(10_000).forEach { store.batchInsert(it) }
|
||||
val insertMs = (System.nanoTime() - t0) / 1e6
|
||||
println(" ═ $label ═ insert: %.0f ms (%.1f µs/event)".format(insertMs, insertMs * 1000 / events.size))
|
||||
|
||||
// 1. DM room: one peer's DMs out of the whole (p:me) inbox.
|
||||
val room = Filter(kinds = listOf(4), authors = listOf(peers[42]), tags = mapOf("p" to listOf(me)), limit = 100)
|
||||
time(store, "dm-room (#p ∩ author ∩ kind, limit 100)", room)
|
||||
|
||||
// 2. Reactions watcher: 300 note ids, cold (no since).
|
||||
val watcher = Filter(kinds = listOf(7), tags = mapOf("e" to noteIds.take(300)), limit = 500)
|
||||
time(store, "reactions (#e IN 300, limit 500, cold)", watcher)
|
||||
|
||||
// 3. Same watcher, EOSE-warm (since bounds the window).
|
||||
val warm = watcher.copy(since = 1_700_000_000L + 2_900_000L)
|
||||
time(store, "reactions (#e IN 300, limit 500, since)", warm)
|
||||
|
||||
store.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun time(
|
||||
store: EventStore,
|
||||
label: String,
|
||||
filter: Filter,
|
||||
) {
|
||||
repeat(3) { store.query<Event>(filter) }
|
||||
val runs = 10
|
||||
var rows = 0
|
||||
val start = System.nanoTime()
|
||||
repeat(runs) { rows = store.query<Event>(filter).size }
|
||||
val ms = (System.nanoTime() - start) / 1e6 / runs
|
||||
println(" %-42s %8.2f ms (%d rows)".format(label, ms, rows))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.quartz.nip01Core.store.fs
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.test.Test
|
||||
|
||||
/**
|
||||
* Guards [FsQueryPlanner]'s cost-based driver pick on the
|
||||
* `authors + kinds + limit` shape — the most common CLI query (27
|
||||
* assembler call sites; every `amy feed`-style author timeline over
|
||||
* non-replaceable kinds).
|
||||
*
|
||||
* Under the pre-pick fixed order (tags → kinds → authors),
|
||||
* `Filter(authors=[pk], kinds=[1], limit=n)` drove from `idx/kind/1/`
|
||||
* (the biggest tree in any real store) and post-filtered the author:
|
||||
* 149 ms at 30k events. The lockstep pick drives from the author tree
|
||||
* and runs at ~4 ms. The benchmark times:
|
||||
*
|
||||
* - **planner (cost-based pick)**: the filter as the planner runs it —
|
||||
* should sit near the floor, far below a kind-tree walk.
|
||||
* - **author-driver emulation**: the author tree walked via an
|
||||
* authors-only query with the kind check applied by the caller — the
|
||||
* reference the pick is expected to match or beat.
|
||||
* - **author-only floor**: `authors + limit` with no kind, the cheapest
|
||||
* possible walk of the same tree.
|
||||
*
|
||||
* Size the seed with `-DfsBenchScale=N` (default 1 ≈ ~30k events; each
|
||||
* event is a file + ~3 hardlinks, so seeding dominates wall time).
|
||||
*/
|
||||
class FsDriverSelectionBenchmark {
|
||||
companion object {
|
||||
val SCALE = System.getProperty("fsBenchScale")?.toInt() ?: 1
|
||||
}
|
||||
|
||||
private val hex = "0123456789abcdef"
|
||||
|
||||
private fun mix(seed: Long): Long {
|
||||
var z = seed + -0x61c8864680b583ebL
|
||||
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
|
||||
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
|
||||
return z xor (z ushr 31)
|
||||
}
|
||||
|
||||
private fun hex64(
|
||||
salt: Long,
|
||||
index: Int,
|
||||
): String {
|
||||
val out = CharArray(64)
|
||||
for (w in 0 until 4) {
|
||||
val v = mix(salt * 1_000_003 + index.toLong() * 4 + w)
|
||||
for (b in 0 until 8) {
|
||||
val byte = ((v ushr (b * 8)) and 0xFF).toInt()
|
||||
out[(w * 8 + b) * 2] = hex[byte ushr 4]
|
||||
out[(w * 8 + b) * 2 + 1] = hex[byte and 0xF]
|
||||
}
|
||||
}
|
||||
return String(out)
|
||||
}
|
||||
|
||||
private val sig = "0".repeat(128)
|
||||
private var idSeq = 0
|
||||
|
||||
private fun ev(
|
||||
pubkey: String,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
): Event = EventFactory.create(hex64(7, idSeq++), pubkey, createdAt, kind, emptyArray(), "", sig)
|
||||
|
||||
@Test
|
||||
fun compareDrivers() =
|
||||
runBlocking {
|
||||
val root: Path = Files.createTempDirectory("fs-driver-bench-")
|
||||
val store = FsEventStore(root)
|
||||
try {
|
||||
val base = 1_700_000_000L
|
||||
val span = 3_000_000L
|
||||
val target = hex64(9, 0)
|
||||
|
||||
// Background: 300 authors × 100 kind-1 notes.
|
||||
val bg = ArrayList<Event>(30_000 * SCALE + 300)
|
||||
repeat(30_000 * SCALE) {
|
||||
val author = hex64(1, it % 300)
|
||||
bg.add(ev(author, base + (mix(it * 31L) and 0x7fffffff) % span, 1))
|
||||
}
|
||||
// Target author: 200 kind-1 notes + 50 kind-7 reactions.
|
||||
repeat(200) { bg.add(ev(target, base + (mix(it * 131L) and 0x7fffffff) % span, 1)) }
|
||||
repeat(50) { bg.add(ev(target, base + (mix(it * 61L) and 0x7fffffff) % span, 7)) }
|
||||
|
||||
val t0 = System.nanoTime()
|
||||
store.transaction { bg.forEach { insert(it) } }
|
||||
val insertMs = (System.nanoTime() - t0) / 1e6
|
||||
println("─ FsDriverSelectionBenchmark: ${bg.size} events (scale=$SCALE), seed %.0f ms ─".format(insertMs))
|
||||
|
||||
// The planner's own pick — expected to choose the author
|
||||
// tree over the ~30k-entry kind-1 tree.
|
||||
val kindDriven = Filter(authors = listOf(target), kinds = listOf(1), limit = 50)
|
||||
time(store, "planner (cost-based pick)") { store.query<Event>(kindDriven).size }
|
||||
|
||||
// Reference: author tree walked explicitly, kind checked
|
||||
// by the caller — the pick should match or beat this.
|
||||
time(store, "author-driver emulation") {
|
||||
store
|
||||
.query<Event>(Filter(authors = listOf(target), limit = 250))
|
||||
.asSequence()
|
||||
.filter { it.kind == 1 }
|
||||
.take(50)
|
||||
.count()
|
||||
}
|
||||
|
||||
// Floor: author-only shape, the cheapest walk of the tree.
|
||||
val authorOnly = Filter(authors = listOf(target), limit = 50)
|
||||
time(store, "author-only floor") { store.query<Event>(authorOnly).size }
|
||||
} finally {
|
||||
store.close()
|
||||
if (root.exists()) {
|
||||
Files.walk(root).use { it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun time(
|
||||
store: FsEventStore,
|
||||
label: String,
|
||||
run: () -> Int,
|
||||
) {
|
||||
repeat(3) { run() }
|
||||
val runs = 10
|
||||
var rows = 0
|
||||
val start = System.nanoTime()
|
||||
repeat(runs) { rows = run() }
|
||||
val ms = (System.nanoTime() - start) / 1e6 / runs
|
||||
println(" %-32s %8.2f ms (%d rows)".format(label, ms, rows))
|
||||
}
|
||||
}
|
||||
@@ -70,11 +70,11 @@ object Scenarios {
|
||||
}
|
||||
|
||||
val topAuthors = notesByAuthor.entries.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key }).map { it.key }
|
||||
val hottestThread =
|
||||
val hotNotes =
|
||||
eTagRefs.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
|
||||
.firstOrNull()
|
||||
?.key
|
||||
.map { it.key }
|
||||
val hottestThread = hotNotes.firstOrNull()
|
||||
val mostMentioned =
|
||||
pTagRefs.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
|
||||
@@ -86,6 +86,23 @@ object Scenarios {
|
||||
.firstOrNull()
|
||||
?.key
|
||||
|
||||
// The author that most often tags the most-mentioned pubkey — a
|
||||
// conversation pair for the tag ∩ author (DM-room) query shape.
|
||||
val conversationPeer =
|
||||
mostMentioned?.let { me ->
|
||||
val byAuthor = HashMap<String, Int>()
|
||||
for (e in events) {
|
||||
if (e.kind != 1 || e.pubKey == me) continue
|
||||
if (e.tags.any { it.size >= 2 && it[0] == "p" && it[1] == me }) {
|
||||
byAuthor.merge(e.pubKey, 1, Int::plus)
|
||||
}
|
||||
}
|
||||
byAuthor.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
|
||||
.firstOrNull()
|
||||
?.key
|
||||
}
|
||||
|
||||
// Evenly spread sample of note ids — a "fetch these 100 events" batch.
|
||||
val idSample =
|
||||
if (noteIds.size <= 100) {
|
||||
@@ -159,6 +176,33 @@ object Scenarios {
|
||||
),
|
||||
)
|
||||
}
|
||||
if (mostMentioned != null && conversationPeer != null) {
|
||||
// The tag ∩ author ∩ kind shape (65 client assembler call
|
||||
// sites: NIP-04 DM rooms, reports-by-follows, follows-scoped
|
||||
// community feeds). Modeled on kind 1 because public corpora
|
||||
// carry no DMs; the index path exercised is identical.
|
||||
add(
|
||||
Scenario(
|
||||
"conversation",
|
||||
"notes by one author tagging the most-mentioned pubkey (DM-room shape)",
|
||||
Filter(kinds = listOf(1), authors = listOf(conversationPeer), tags = mapOf("p" to listOf(mostMentioned)), limit = 500),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hotNotes.size > 1) {
|
||||
// Large-IN tag watcher: per-value streams come sorted off the
|
||||
// tag index but their union does not, exposing whether the
|
||||
// store collects+sorts or merges. 150 values stays inside
|
||||
// strfry's default 200-element filter cap.
|
||||
val watched = hotNotes.take(150)
|
||||
add(
|
||||
Scenario(
|
||||
"reactions-watch",
|
||||
"reactions on the ${watched.size} hottest notes (visible-feed reaction watcher)",
|
||||
Filter(kinds = listOf(7), tags = mapOf("e" to watched), limit = 500),
|
||||
),
|
||||
)
|
||||
}
|
||||
topHashtag?.let {
|
||||
add(
|
||||
Scenario(
|
||||
|
||||
Reference in New Issue
Block a user