mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge remote-tracking branch 'origin/main' into claude/buzz-amethyst-support-nfxog7
# Conflicts: # amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# NWC BOLT12 payments (nostr-wallet-connect/nwc#2)
|
||||
|
||||
Status: **scoping** — no code yet. Depends on NIP-2421 (this branch) and the
|
||||
Status: **scoping** — no code yet. Depends on NIP-B1 (this branch) and the
|
||||
unmerged `nostr-wallet-connect/nwc#2` (adds `pay`/`receive` to NIP-47).
|
||||
|
||||
## Ecosystem status (2026-07-25) — the wallet gap for real testing
|
||||
@@ -10,7 +10,7 @@ A real NWC-321 (`pay`/`receive`) service exists —
|
||||
— which confirms the protocol we built (Phase 0) is real and adopted. **But it is
|
||||
LND-backed:** its `pay` "selects and pays a BOLT11 `lightning` instruction from a
|
||||
BIP-321 URI" and "BOLT12 `lno` instructions are not supported by this LND backend."
|
||||
So it returns no BOLT12 invoice and no `payer_proof`, and can't drive the NIP-2421
|
||||
So it returns no BOLT12 invoice and no `payer_proof`, and can't drive the NIP-B1
|
||||
zap loop. The blocker is the **node backend**, not the protocol: a CLN- or
|
||||
LDK-backed NWC-321 service (CLN has full BOLT12 and leads the payer-proof draft
|
||||
bolts#1346) would support `lno` + return `payer_proof`. Until one exists, the
|
||||
@@ -97,7 +97,7 @@ but is **not** wired into the pay path — we'd add the gate ourselves.
|
||||
kind-10058 offer and the wallet supports `pay`, offer a BOLT12 zap.
|
||||
1. Build + sign kind-9737 intent (amount, offer, p, e/a/k, zap_id, content).
|
||||
2. `pay` with `payment=bitcoin:?lno=<offer>`, `amount`,
|
||||
`payer_note="nostr:nipXX:<intent-id>"`.
|
||||
`payer_note="nostr:nipB1:<intent-id>"`.
|
||||
3. On success with a `payer_proof`, `Bolt12ZapEvent.build(intent, payerProof,
|
||||
payerPubKey = own | null for anon)`, sign, publish to the recipient's inbox
|
||||
relays.
|
||||
@@ -114,8 +114,8 @@ but is **not** wired into the pay path — we'd add the gate ourselves.
|
||||
1. **`payer_note` → `invreq_payer_note` is NOT guaranteed by nwc#2.** The spec only
|
||||
says "if `payer_note` is not empty, the selected instruction MUST support
|
||||
payer-provided messages" — it never states the note lands in the BOLT12
|
||||
`invreq_payer_note`. NIP-2421 binds the zap to the intent through exactly that
|
||||
field (`invreq_payer_note == nostr:nipXX:<intent-id>`). If a wallet routes
|
||||
`invreq_payer_note`. NIP-B1 binds the zap to the intent through exactly that
|
||||
field (`invreq_payer_note == nostr:nipB1:<intent-id>`). If a wallet routes
|
||||
`payer_note` elsewhere, the returned `payer_proof` fails our validator and the
|
||||
9736 is worthless. **Phase 2 feasibility hinges on this** — needs confirmation
|
||||
in the nwc thread / a reference wallet, or a follow-up to nwc#2 to nail it down.
|
||||
@@ -129,7 +129,7 @@ but is **not** wired into the pay path — we'd add the gate ourselves.
|
||||
`quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md`). Real wallet
|
||||
(selective-disclosure) proofs now reconstruct and verify, so a bound zap counts
|
||||
locally as `cryptoVerified = true`.
|
||||
4. **Maturity.** Both nwc#2 and NIP-2421 are unmerged; few/no wallets implement
|
||||
4. **Maturity.** Both nwc#2 and NIP-B1 are unmerged; few/no wallets implement
|
||||
`pay` today. Gate hard on capability (Phase 3) and keep the intent fallback.
|
||||
|
||||
## Resolution of risks #1/#2 (nwc#2 maintainer, 2026-07-24)
|
||||
@@ -138,7 +138,7 @@ Asked on the nwc#2 thread. Maintainer confirmed:
|
||||
|
||||
- **`payer_note` → `invreq_payer_note`**: "that is the intention" for BOLT12 (the
|
||||
field doubles as a general memo). So our zap binding
|
||||
(`invreq_payer_note == nostr:nipXX:<intent-id>`) is the intended target.
|
||||
(`invreq_payer_note == nostr:nipB1:<intent-id>`) is the intended target.
|
||||
- **`payer_proof`**: "should be returned for successful bolt12 payments"; the
|
||||
"optional if unavailable" wording only covers non-BOLT12 instruction types.
|
||||
|
||||
@@ -160,7 +160,7 @@ Chosen: full integration into the zap pipeline, preferring BOLT12 when the
|
||||
recipient offers it.
|
||||
|
||||
- `Account.sendBolt12Zap(...)` — signs a 9737 intent (ephemeral key for anonymous),
|
||||
pays over NWC with `payer_note = nostr:nipXX:<intent-id>`, and only on a proof
|
||||
pays over NWC with `payer_note = nostr:nipB1:<intent-id>`, and only on a proof
|
||||
that passes `Bolt12ZapValidator` self-consumes + publishes the 9736 via
|
||||
`computeRelayListToBroadcast`. No proof / invalid proof → "paid, no receipt".
|
||||
- `ZapPaymentHandler.zap()` resolves each recipient's kind:10058 offer and
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.audioSquare
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The inline audio player sizes itself as a square so the visualizer and controls get room, which is
|
||||
* taller than the 16:9 box [ZoomableContentView] builds for a video of unknown dimensions. That box
|
||||
* is centre-aligned and nothing between it and NoteComposeLayout clips, so caging the square in it
|
||||
* makes the player paint over the note header above and the reactions row below.
|
||||
*
|
||||
* These tests recreate that enclosure — the real [mediaSizingModifier] over the real
|
||||
* [unknownMediaAspectRatio], holding the real [audioSquare] — and pin both halves of the contract:
|
||||
* audio must not be given a ratio, and video must keep the 16:9 assumption that stops live streams
|
||||
* from letterboxing on first play.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AudioPlayerBoxOverflowTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
private class Bounds {
|
||||
var top = 0f
|
||||
var bottom = 0f
|
||||
var width = 0
|
||||
var height = 0
|
||||
|
||||
fun modifier() =
|
||||
Modifier.onGloballyPositioned {
|
||||
top = it.positionInRoot().y
|
||||
width = it.size.width
|
||||
height = it.size.height
|
||||
bottom = top + height
|
||||
}
|
||||
}
|
||||
|
||||
private fun measure(
|
||||
url: String,
|
||||
square: Boolean,
|
||||
): Pair<Bounds, Bounds> {
|
||||
val box = Bounds()
|
||||
val player = Bounds()
|
||||
|
||||
rule.setContent {
|
||||
Box(Modifier.size(width = 400.dp, height = 1200.dp)) {
|
||||
Box(
|
||||
modifier = mediaSizingModifier(unknownMediaAspectRatio(null, url), ContentScale.Fit).then(box.modifier()),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box((if (square) Modifier.audioSquare() else Modifier).then(player.modifier()))
|
||||
}
|
||||
}
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
||||
return box to player
|
||||
}
|
||||
|
||||
@Test
|
||||
fun squareAudioPlayerStaysInsideItsMediaBox() {
|
||||
val (box, player) = measure("https://haven.sdbitcoiners.com/f28a5a2e.mp3", square = true)
|
||||
|
||||
assertTrue(
|
||||
"player overflows above the media box by ${box.top - player.top}px",
|
||||
player.top >= box.top,
|
||||
)
|
||||
assertTrue(
|
||||
"player overflows below the media box by ${player.bottom - box.bottom}px",
|
||||
player.bottom <= box.bottom,
|
||||
)
|
||||
assertEquals("the box should wrap the square", player.height, box.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoOfUnknownSizeKeeps16by9() {
|
||||
val (box, _) = measure("https://example.com/a.mp4", square = false)
|
||||
|
||||
assertEquals(16f / 9f, box.width.toFloat() / box.height, 0.01f)
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -206,6 +206,7 @@ private object PrefKeys {
|
||||
const val SIGNER_PACKAGE_NAME = "signer_package_name"
|
||||
const val HAS_DONATED_IN_VERSION = "has_donated_in_version"
|
||||
const val DISMISSED_POLL_NOTE_IDS = "dismissed_poll_note_ids"
|
||||
const val DISMISSED_CHANNEL_INVITES = "dismissed_channel_invites"
|
||||
const val VIEWED_POLL_RESULT_NOTE_IDS = "viewed_poll_result_note_ids"
|
||||
const val PENDING_ATTESTATIONS = "pending_attestations"
|
||||
|
||||
@@ -630,6 +631,7 @@ object LocalPreferences {
|
||||
)
|
||||
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
|
||||
putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.value)
|
||||
putStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, settings.dismissedChannelInvites.value)
|
||||
putString(
|
||||
PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS,
|
||||
JsonMapper.toJson(settings.viewedPollResultNoteIds.value),
|
||||
@@ -744,6 +746,7 @@ object LocalPreferences {
|
||||
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
|
||||
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
|
||||
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf()
|
||||
val dismissedChannelInvites = getStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, null) ?: setOf()
|
||||
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
|
||||
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
|
||||
|
||||
@@ -1000,6 +1003,7 @@ object LocalPreferences {
|
||||
lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved),
|
||||
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
|
||||
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),
|
||||
dismissedChannelInvites = MutableStateFlow(dismissedChannelInvites),
|
||||
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved),
|
||||
pendingAttestations = MutableStateFlow(pendingAttestationsResolved),
|
||||
backupNipA3PaymentTargets = latestPaymentTargetsResolved,
|
||||
|
||||
@@ -168,7 +168,6 @@ import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
||||
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
|
||||
@@ -380,9 +379,9 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.builder.Bolt12ZapBuilder
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
@@ -1468,7 +1467,7 @@ class Account(
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a NIP-XX BOLT12 zap to [recipientPubKey] over the default NWC wallet.
|
||||
* Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet.
|
||||
*
|
||||
* Signs a kind 9737 intent, pays [offer] via the nwc#2 `pay` method with the
|
||||
* intent-bound `payer_note`, then — only if the wallet returns a payer proof that
|
||||
@@ -2450,12 +2449,24 @@ class Account(
|
||||
val hostRelay = group.groupId.relayUrl
|
||||
val signed =
|
||||
if (BuzzRelayDialect.isBuzz(hostRelay)) {
|
||||
// Buzz rejects kind-1111, so its minichat threads with a 40002 marked at the message's
|
||||
// root (never `broadcast` — a minichat reply always lives in the thread). Attached
|
||||
// media is carried as URLs appended to the content (no `imeta` on the stream event).
|
||||
// Buzz rejects kind-1111, so its minichat threads with a NIP-10 `reply`-marked `e`
|
||||
// on a plain kind-9 chat — byte-identical to `_buildReplyTags` in Buzz's own client
|
||||
// (direct reply -> one `reply` marker; nested -> `root` + `reply`), which is what
|
||||
// [buzzThread] emits.
|
||||
//
|
||||
// This used to write kind-40002. Nothing in Buzz writes 40002 any more — every send
|
||||
// path in their mobile, desktop and CLI clients emits kind 9, and their NOSTR.md
|
||||
// grades 40002 "Buzz-only — no standard NIP-29 client renders these" against kind 9's
|
||||
// blessed status. 40002 survives only as a read-compat tail from the
|
||||
// 10002 -> 40001 -> 40002 migration, so we were the last active writer of a kind
|
||||
// their clients no longer thread on. Reading 40002 stays supported (see
|
||||
// [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply]).
|
||||
//
|
||||
// Attached media rides as URLs appended to the content.
|
||||
val root = rootEvent.tags.buzzThreadRoot() ?: rootEvent.tags.buzzThreadReply() ?: rootEvent.id
|
||||
signer.sign(
|
||||
StreamMessageV2Event.build(group.groupId.id, finalText) {
|
||||
ChatEvent.build(finalText) {
|
||||
hTag(group.groupId.id)
|
||||
buzzThread(root, rootEvent.id)
|
||||
rootNote.author?.pubkeyHex?.let { pTag(PTag(it)) }
|
||||
previous(group.previousEventRefs(pubKey))
|
||||
|
||||
@@ -75,7 +75,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -320,6 +320,12 @@ class AccountSettings(
|
||||
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
|
||||
val hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
|
||||
val dismissedPollNoteIds: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
|
||||
/**
|
||||
* Channel ids the viewer chose NOT to show on Messages after somebody added them to the channel
|
||||
* (kind-44100). Local-only: it records a display preference, not membership — the relay roster
|
||||
* still lists you, and Leave (kind 9022) is the separate action that actually removes you.
|
||||
*/
|
||||
val dismissedChannelInvites: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
|
||||
val viewedPollResultNoteIds: MutableStateFlow<Map<String, Long>> = MutableStateFlow(mapOf()),
|
||||
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
|
||||
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
|
||||
@@ -1521,6 +1527,27 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// dismissed channel invites (somebody added me to a channel; I don't want it on Messages)
|
||||
// ---
|
||||
|
||||
fun isDismissedChannelInvite(channelId: String) = dismissedChannelInvites.value.contains(channelId)
|
||||
|
||||
fun dismissChannelInvite(channelId: String) {
|
||||
if (!dismissedChannelInvites.value.contains(channelId)) {
|
||||
dismissedChannelInvites.update { it + channelId }
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
/** Undo a dismissal — used when the viewer accepts the channel after all, so it can re-prompt later. */
|
||||
fun undismissChannelInvite(channelId: String) {
|
||||
if (dismissedChannelInvites.value.contains(channelId)) {
|
||||
dismissedChannelInvites.update { it - channelId }
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// pinned chatrooms
|
||||
// ---
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
|
||||
@@ -391,6 +392,10 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
|
||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidator
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
@@ -400,10 +405,6 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
@@ -472,7 +473,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
val onchainZapResolver = OnchainZapResolver(this)
|
||||
|
||||
/**
|
||||
* NIP-XX BOLT12 zap validator. Unlike onchain zaps, BOLT12 proof verification
|
||||
* NIP-B1 BOLT12 zap validator. Unlike onchain zaps, BOLT12 proof verification
|
||||
* is synchronous (a self-contained `lnp` payer proof), so `consume(Bolt12ZapEvent)`
|
||||
* validates inline and needs no async resolver.
|
||||
*/
|
||||
@@ -1293,7 +1294,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
|
||||
is Bolt12ZapEvent -> {
|
||||
// NIP-XX BOLT12 zaps target an event (e), an addressable event (a),
|
||||
// NIP-B1 BOLT12 zaps target an event (e), an addressable event (a),
|
||||
// or just the recipient profile (p) — same shape as onchain zaps.
|
||||
buildList {
|
||||
event.zappedEvent()?.let { checkGetOrCreateNote(it)?.let { add(it) } }
|
||||
@@ -2292,6 +2293,22 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A kind-44101 "you were removed from a channel". Consumed like any other Buzz event, then used to
|
||||
* withdraw any pending add-prompt for that channel: once the relay has taken the membership away
|
||||
* there is nothing left to accept, so leaving the card up would offer an action that cannot succeed.
|
||||
*/
|
||||
private fun consume(
|
||||
event: MemberRemovedNotificationEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
consumeBuzzRegularEvent(event, relay, wasVerified).also {
|
||||
val target = event.target() ?: return@also
|
||||
val channelId = event.channel() ?: return@also
|
||||
BuzzChannelInvites.remove(target, channelId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
|
||||
* carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic
|
||||
@@ -2701,7 +2718,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
if (!(wasVerified || justVerify(event))) return false
|
||||
|
||||
// NIP-XX validation is fully synchronous: zap-event structure, the embedded
|
||||
// NIP-B1 validation is fully synchronous: zap-event structure, the embedded
|
||||
// kind:9737 intent match, and the `lnp` payer-proof binding + crypto. A failed
|
||||
// validation drops the zap entirely — it never contributes to a zap total.
|
||||
// The outer event signature was already verified above (wasVerified/justVerify),
|
||||
@@ -4821,7 +4838,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is MemberAddedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is MemberRemovedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is MemberRemovedNotificationEvent -> consume(event, relay, wasVerified)
|
||||
is RelayMembershipListEvent -> consume(event, relay, wasVerified)
|
||||
is RelayAddMemberEvent -> consume(event, relay, wasVerified)
|
||||
is RelayRemoveMemberEvent -> consume(event, relay, wasVerified)
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
@@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The logged-in user's NIP-XX BOLT12 offer list (kind 10058) as live account state,
|
||||
* The logged-in user's NIP-B1 BOLT12 offer list (kind 10058) as live account state,
|
||||
* mirroring [com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState].
|
||||
* Exposes the current offers as a [flow], persists them across restarts (via
|
||||
* [AccountSettings]), and publishes updates with [saveOffers].
|
||||
|
||||
@@ -77,7 +77,7 @@ class ZapPaymentHandler(
|
||||
val user: User? = null,
|
||||
)
|
||||
|
||||
/** A recipient routed over BOLT12 (NIP-XX): they publish a kind:10058 [offer] and we hold an NWC wallet. */
|
||||
/** A recipient routed over BOLT12 (NIP-B1): they publish a kind:10058 [offer] and we hold an NWC wallet. */
|
||||
data class Bolt12Recipient(
|
||||
val user: User,
|
||||
val offer: String,
|
||||
@@ -442,7 +442,7 @@ class ZapPaymentHandler(
|
||||
}
|
||||
|
||||
/**
|
||||
* BOLT12 zap rail (NIP-XX). For each recipient that publishes a kind:10058 offer,
|
||||
* BOLT12 zap rail (NIP-B1). For each recipient that publishes a kind:10058 offer,
|
||||
* signs a 9737 intent, pays the offer over NWC with the intent-bound `payer_note`,
|
||||
* and (if the returned proof validates) publishes a 9736 zap — see
|
||||
* [Account.sendBolt12Zap]. Fire-and-forget like [payViaNWC]: dispatch is optimistic
|
||||
|
||||
+1
-1
@@ -61,9 +61,9 @@ import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
+1
-1
@@ -52,8 +52,8 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
|
||||
val AccountInfoAndListsFromKeyKinds =
|
||||
listOf(
|
||||
|
||||
+13
-57
@@ -20,8 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
|
||||
@@ -29,7 +27,6 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -84,45 +81,17 @@ class AccountNotificationsEoseFromInboxRelaysManager(
|
||||
)
|
||||
}
|
||||
|
||||
// NIP-29 group activity (reactions/replies to my messages) lives on the group's host relay,
|
||||
// not my inbox relays — poll each host relay for those, scoped by `#h` to my joined groups.
|
||||
val groups =
|
||||
key.account.relayGroupList.liveRelayGroupList.value
|
||||
.groupBy({ it.relayUrl }, { it.groupId })
|
||||
.flatMap { (relayUrl, groupIds) ->
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@flatMap emptyList()
|
||||
filterGroupNotificationsToPubkey(
|
||||
relay = relay,
|
||||
pubkey = user(key).pubkeyHex,
|
||||
groupIds = groupIds.distinct(),
|
||||
// Same reasoning as the inbox filters above: `#p` + `#h` + `limit = 200`
|
||||
// already bound this, so a week floor only hides older group activity.
|
||||
since = since?.get(relay)?.time ?: pagingBoundary,
|
||||
)
|
||||
}
|
||||
|
||||
// Buzz DM channels are NOT in the published group list (membership is server-side, tracked in
|
||||
// BuzzDmChannels), so the joined-group query above skips them. Poll each DM host relay the same
|
||||
// way — `#p` = me + `#h` = my DM channels — so a reaction/zap/repost on my DM message surfaces
|
||||
// in notifications, not only as a chip inside the open conversation. Hidden DMs are excluded.
|
||||
val myPubkey = user(key).pubkeyHex
|
||||
val hiddenDms = BuzzDmRegistry.hiddenFor(myPubkey)
|
||||
val dmGroups =
|
||||
BuzzDmChannels
|
||||
.channelsFor(myPubkey)
|
||||
.filterKeys { it !in hiddenDms }
|
||||
.entries
|
||||
.groupBy({ it.value }, { it.key })
|
||||
.flatMap { (relay, channelIds) ->
|
||||
filterGroupNotificationsToPubkey(
|
||||
relay = relay,
|
||||
pubkey = myPubkey,
|
||||
groupIds = channelIds.distinct(),
|
||||
since = since?.get(relay)?.time ?: pagingBoundary,
|
||||
)
|
||||
}
|
||||
|
||||
return inbox + groups + dmGroups
|
||||
// NIP-29 group activity (reactions/replies to my messages) is deliberately NOT requested here.
|
||||
// It lives on the group's host relay and used to be one `#h` filter per relay carrying every
|
||||
// joined group id — but this subscription also carries the inbox filters above, which have no
|
||||
// `#h` at all, and `block/buzz` downgrades any subscription with a channel-less (or multi-
|
||||
// channel) filter to "global", a class that by design never receives channel-scoped events. So
|
||||
// those filters answered the stored query at EOSE and then went deaf until the next launch.
|
||||
//
|
||||
// Group and Buzz-DM activity now rides the per-channel subscriptions that are already scoped to
|
||||
// exactly one channel — RelayGroupJoinedChatTailSubAssembler and BuzzDmJoinedChatTailSubAssembler,
|
||||
// both mounted app-wide from LoggedInPage, so coverage is unchanged and delivery is live.
|
||||
return inbox
|
||||
}
|
||||
|
||||
val userJobMap = mutableMapOf<User, List<Job>>()
|
||||
@@ -138,21 +107,8 @@ class AccountNotificationsEoseFromInboxRelaysManager(
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
// Re-subscribe when I join/leave a group so its host relay is added to (or dropped
|
||||
// from) the notification query.
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.account.relayGroupList.liveRelayGroupList.sample(1000).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
// Re-subscribe when a Buzz DM is discovered/hidden so its host relay is polled for
|
||||
// reactions/zaps on my DM messages.
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
BuzzDmChannels.flow.sample(1000).collectLatest { invalidateFilters() }
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
BuzzDmRegistry.hidden.sample(1000).collectLatest { invalidateFilters() }
|
||||
},
|
||||
// No group/Buzz-DM watchers here any more: those filters moved to the per-channel
|
||||
// subscriptions, which mount and unmount with the channel itself.
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
|
||||
invalidateFilters()
|
||||
|
||||
+1
-1
@@ -56,9 +56,9 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
/**
|
||||
* Kinds that notify me about activity on MY messages inside a NIP-29 group — reactions, replies
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
val RepliesAndReactionsToAddressesKinds1 =
|
||||
|
||||
+1
-1
@@ -43,8 +43,8 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
val RepliesAndReactionsKinds =
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
|
||||
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
val UserMetadataForKeyKinds =
|
||||
|
||||
+2
-2
@@ -25,14 +25,14 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Edits the logged-in user's NIP-XX BOLT12 offer list (kind 10058). Mirrors
|
||||
* Edits the logged-in user's NIP-B1 BOLT12 offer list (kind 10058). Mirrors
|
||||
* [com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsViewModel];
|
||||
* each entry is a canonical raw `lno1...` offer string.
|
||||
*/
|
||||
|
||||
+28
-1
@@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
@@ -144,6 +145,23 @@ private const val SHARED_VIDEO_CLEANUP_DELAY_MS = 120_000L
|
||||
// place; [MediaAspectRatioCache] then corrects anything unusual once the decoder reports its size.
|
||||
private const val DEFAULT_VIDEO_ASPECT_RATIO = 16f / 9f
|
||||
|
||||
/**
|
||||
* Shape to assume for a [MediaUrlVideo] nobody has reported dimensions for — no imeta `dim` and
|
||||
* nothing in [MediaAspectRatioCache].
|
||||
*
|
||||
* Video gets [DEFAULT_VIDEO_ASPECT_RATIO]. Audio gets null, meaning "no ratio, wrap whatever the
|
||||
* player asks for": audio has no picture, and the inline player sizes itself as a square (see
|
||||
* `AudioPlayerSquare.audioSquare`) so the visualizer and controls get room. A square is taller than
|
||||
* 16:9, so caging it in a 16:9 box — which is centre-aligned and unclipped all the way up through
|
||||
* NoteComposeLayout — makes the player paint over the note header above it and the reactions row
|
||||
* below. Audio also never reaches the cache, since it has no video track to report a size, so the
|
||||
* miss is permanent rather than first-play-only.
|
||||
*/
|
||||
internal fun unknownMediaAspectRatio(
|
||||
mimeType: String?,
|
||||
url: String,
|
||||
): Float? = if (RichTextParser.isAudioContent(mimeType, url)) null else DEFAULT_VIDEO_ASPECT_RATIO
|
||||
|
||||
@Composable
|
||||
fun ZoomableContentView(
|
||||
content: BaseMediaContent,
|
||||
@@ -203,7 +221,16 @@ fun ZoomableContentView(
|
||||
}
|
||||
|
||||
is MediaUrlVideo -> {
|
||||
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) ?: DEFAULT_VIDEO_ASPECT_RATIO
|
||||
// The fallback classifies the URL string, so compute it once per content — for audio
|
||||
// the cache miss is permanent and this branch re-runs on every recomposition.
|
||||
val fallbackRatio =
|
||||
remember(content.url, content.mimeType) {
|
||||
unknownMediaAspectRatio(content.mimeType, content.url)
|
||||
}
|
||||
val ratio =
|
||||
content.dim?.aspectRatio()
|
||||
?: MediaAspectRatioCache.get(content.url)
|
||||
?: fallbackRatio
|
||||
val bridgedUrl =
|
||||
remember(content.url, useLocalBlossomBridge) {
|
||||
content.toCoilModel(useLocalBlossomBridge)
|
||||
|
||||
@@ -44,7 +44,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@@ -351,12 +351,12 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
|
||||
@@ -550,7 +550,7 @@ fun payViaIntent(
|
||||
* parameter of a BIP21/BIP321 bitcoin URI (`bitcoin:?lno=lno1…`), where the on-chain
|
||||
* address is optional so a Lightning-only offer stands on its own. The wallet resolves
|
||||
* the offer, collects the amount, and completes the payment; this is a plain intent,
|
||||
* not a NIP-57/NIP-XX zap, so it produces no Nostr receipt.
|
||||
* not a NIP-57/NIP-B1 zap, so it produces no Nostr receipt.
|
||||
*/
|
||||
fun payViaBolt12Intent(
|
||||
offer: String,
|
||||
|
||||
@@ -40,11 +40,11 @@ import com.vitorpamplona.amethyst.ui.note.ZapIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import java.text.NumberFormat
|
||||
|
||||
/**
|
||||
* Standalone card for a NIP-XX BOLT12 zap (kind 9736), styled like the NIP-57
|
||||
* Standalone card for a NIP-B1 BOLT12 zap (kind 9736), styled like the NIP-57
|
||||
* lightning-zap card but labeled BOLT12. The sender is the `P` payer tag (or the
|
||||
* event pubkey when anonymous); the amount comes straight off the validated
|
||||
* `amount` tag — no LNURL provider or private-zap decryption is involved.
|
||||
|
||||
+4
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal.MusicPlaylistsFee
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.dal.MusicTracksFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal.NestsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ChannelInvitesState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
|
||||
@@ -138,6 +139,9 @@ class AccountFeedContentStates(
|
||||
val notificationsEveryone = CardFeedContentState(NotificationFeedFilter(account, TopFilter.Global), scope)
|
||||
|
||||
val notificationsOpenPolls = OpenPollsState(account, scope)
|
||||
|
||||
/** Channels somebody added the viewer to, awaiting a show-on-Messages decision. */
|
||||
val channelInvites = ChannelInvitesState(account, scope)
|
||||
val notificationSummary = NotificationSummaryState(account)
|
||||
|
||||
val feedListOptions = TopNavFilterState(account, scope)
|
||||
|
||||
+41
-26
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
|
||||
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
|
||||
@@ -75,7 +76,6 @@ import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientB
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.model.privateChatLastReadRoute
|
||||
import com.vitorpamplona.amethyst.model.unreadPrivateChatRoute
|
||||
import com.vitorpamplona.amethyst.service.ClinkDebitPayer
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.V4VPaymentHandler
|
||||
@@ -104,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.Marm
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUpload
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUploader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.markRoomNoteAsRead
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.rowHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
|
||||
@@ -439,6 +440,15 @@ class AccountViewModel(
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(30000), false)
|
||||
|
||||
/**
|
||||
* The bottom-bar envelope dot: true when ANY Messages row is showing its blue dot.
|
||||
*
|
||||
* Per-row via [rowHasUnreadFlow], which mirrors what each row composable computes for itself.
|
||||
* This used to call `unreadPrivateChatRoute` directly, which returns null for anything that is not
|
||||
* `ChatroomKeyable` — so only NIP-17/NIP-04 DMs counted, and a public chat, ephemeral room, geohash
|
||||
* cell, Marmot group, NIP-29/Buzz channel or Concord channel could sit there with a visible dot
|
||||
* while the envelope stayed clean.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val messagesHasNewItems =
|
||||
feedStates.dmKnown.feedContent
|
||||
@@ -449,14 +459,7 @@ class AccountViewModel(
|
||||
MutableStateFlow(null)
|
||||
}
|
||||
}.flatMapLatest { loadedFeedState ->
|
||||
val flows =
|
||||
loadedFeedState?.list?.mapNotNull { chat ->
|
||||
unreadPrivateChatRoute(chat)?.let { (route, createdAt) ->
|
||||
account.settings.getLastReadFlow(route).map { lastReadAt ->
|
||||
createdAt > lastReadAt
|
||||
}
|
||||
}
|
||||
}
|
||||
val flows = loadedFeedState?.list?.mapNotNull { chat -> rowHasUnreadFlow(chat, account) }
|
||||
|
||||
if (!flows.isNullOrEmpty()) {
|
||||
combine(flows) { newItems ->
|
||||
@@ -465,20 +468,6 @@ class AccountViewModel(
|
||||
} else {
|
||||
MutableStateFlow(false)
|
||||
}
|
||||
}.onStart {
|
||||
val feed = feedStates.dmKnown.feedContent.value
|
||||
if (feed is FeedState.Loaded) {
|
||||
val newItems =
|
||||
feed.feed.value.list.any { chat ->
|
||||
unreadPrivateChatRoute(chat)?.let { (route, createdAt) ->
|
||||
val lastReadAt =
|
||||
account.settings.lastReadPerRoute.value[route]
|
||||
?.value ?: 0L
|
||||
createdAt > lastReadAt
|
||||
} == true
|
||||
}
|
||||
emit(newItems)
|
||||
}
|
||||
}
|
||||
|
||||
val messagesHasNewItemsFlow =
|
||||
@@ -1218,7 +1207,7 @@ class AccountViewModel(
|
||||
/**
|
||||
* Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2
|
||||
* `pay` method, wrapping it as a BIP321 `bitcoin:?lno=` instruction. This is a
|
||||
* plain payment, not a NIP-XX zap (no Nostr receipt); the outcome is surfaced as
|
||||
* plain payment, not a NIP-B1 zap (no Nostr receipt); the outcome is surfaced as
|
||||
* a toast. Callers should gate on [hasNwcWallet].
|
||||
*/
|
||||
fun payBolt12OfferViaNwc(
|
||||
@@ -1683,6 +1672,34 @@ class AccountViewModel(
|
||||
|
||||
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) }
|
||||
|
||||
/**
|
||||
* Accept a channel somebody added me to: write it into my kind-10009 so it shows on Messages and
|
||||
* follows me to other devices. No kind-9021 join — the relay already put me in the roster, which is
|
||||
* why the channel opens and accepts posts today; this only records *my* decision to surface it.
|
||||
*/
|
||||
fun acceptChannelInvite(channel: RelayGroupChannel) =
|
||||
launchSigner {
|
||||
account.settings.undismissChannelInvite(channel.groupId.id)
|
||||
account.follow(channel)
|
||||
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the channel off Messages without touching membership. Local and reversible — I stay in the
|
||||
* roster and can still open and post; [leaveChannelInvite] is the one that actually removes me.
|
||||
*/
|
||||
fun dismissChannelInvite(channelId: String) {
|
||||
account.settings.dismissChannelInvite(channelId)
|
||||
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channelId)
|
||||
}
|
||||
|
||||
/** Actually leave: kind-9022 to the host relay, and drop it from my list and the pending set. */
|
||||
fun leaveChannelInvite(channel: RelayGroupChannel) =
|
||||
launchSigner {
|
||||
account.leaveRelayGroup(channel)
|
||||
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a Concord community from this account's private kind-13302 list. Fire-and-forget on the
|
||||
* signer dispatcher: the removal lands in the local cache (so the UI updates immediately) and the
|
||||
@@ -2174,8 +2191,6 @@ class AccountViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun unreadPrivateChatRoute(chat: Note): Pair<String, Long>? = unreadPrivateChatRoute(chat.event, account.signer.pubKey, account::isAllHidden)
|
||||
|
||||
private fun markHiddenChatroomsAsRead() {
|
||||
account.chatroomList.rooms.forEach { roomKey, chatroom ->
|
||||
if (account.isAllHidden(roomKey.users)) {
|
||||
|
||||
+67
-3
@@ -24,10 +24,13 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
|
||||
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
|
||||
@@ -37,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithH
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -96,10 +100,11 @@ private suspend fun runBuzzDmDiscovery(
|
||||
timeoutMs = 8_000,
|
||||
pendingOnAuthRequired = true,
|
||||
) { relay, event ->
|
||||
(event as? MemberAddedNotificationEvent)?.channel()?.let { BuzzDmChannels.record(me, it, relay) }
|
||||
(event as? MemberAddedNotificationEvent)?.let { recordDiscovery(me, it, relay) }
|
||||
false
|
||||
}
|
||||
fetchDmMetadata(account, me)
|
||||
classifyDiscoveredChannels(account, me)
|
||||
|
||||
relays.forEach { relay ->
|
||||
launch {
|
||||
@@ -107,9 +112,12 @@ private suspend fun runBuzzDmDiscovery(
|
||||
account.client.subscribeAsFlow(relay, filter).collect { events ->
|
||||
var changed = false
|
||||
events.filterIsInstance<MemberAddedNotificationEvent>().forEach { e ->
|
||||
e.channel()?.let { if (BuzzDmChannels.record(me, it, relay)) changed = true }
|
||||
if (recordDiscovery(me, e, relay)) changed = true
|
||||
}
|
||||
if (changed) {
|
||||
fetchDmMetadata(account, me)
|
||||
classifyDiscoveredChannels(account, me)
|
||||
}
|
||||
if (changed) fetchDmMetadata(account, me)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,3 +137,59 @@ private suspend fun fetchDmMetadata(
|
||||
if (byRelay.isEmpty()) return
|
||||
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a kind-44100 "you were added" into [BuzzDmChannels] so its directory can be fetched, and — when
|
||||
* somebody *else* did the adding — into [BuzzChannelInvites] as well.
|
||||
*
|
||||
* The relay emits this same kind for a self-join with `actor == me`, so the actor is what separates "I
|
||||
* joined this" from "a stranger put me in this". Everything is provisionally treated as a DM here because
|
||||
* the channel's type only becomes knowable once its kind-39000 lands; [classifyDiscoveredChannels] sorts
|
||||
* them out immediately afterwards.
|
||||
*/
|
||||
private fun recordDiscovery(
|
||||
me: HexKey,
|
||||
event: MemberAddedNotificationEvent,
|
||||
relay: NormalizedRelayUrl,
|
||||
): Boolean {
|
||||
val channelId = event.channel() ?: return false
|
||||
val changed = BuzzDmChannels.record(me, channelId, relay)
|
||||
val actor = event.actor()
|
||||
if (actor == null || !actor.equals(me, ignoreCase = true)) {
|
||||
BuzzChannelInvites.record(me, BuzzChannelInvite(channelId, relay, actor, event.createdAt))
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits what discovery found into DMs and named channels, now that each channel's kind-39000 has loaded.
|
||||
*
|
||||
* A `t = dm` channel is a real DM: it stays in [BuzzDmChannels], whose always-on tail keeps it warm so it
|
||||
* can reach Notifications without being opened, and it is never an "invite". Anything else is a named
|
||||
* channel somebody added the viewer to; it must NOT be silently subscribed, so it is dropped from
|
||||
* [BuzzDmChannels] and left in [BuzzChannelInvites] for the viewer to accept or dismiss.
|
||||
*
|
||||
* Channels already in the viewer's kind-10009 (accepted earlier, or joined from this device) are not
|
||||
* invites — the ordinary joined-group path owns them.
|
||||
*/
|
||||
private fun classifyDiscoveredChannels(
|
||||
account: Account,
|
||||
me: HexKey,
|
||||
) {
|
||||
val joined =
|
||||
account.relayGroupList.liveRelayGroupList.value
|
||||
.mapNotNullTo(mutableSetOf()) { it.groupId }
|
||||
|
||||
BuzzDmChannels.channelsFor(me).forEach { (channelId, relay) ->
|
||||
val metadata = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.event
|
||||
when {
|
||||
// Type not known yet — leave both entries alone and re-run when the directory lands.
|
||||
metadata == null -> Unit
|
||||
metadata.isBuzzDmChannel() -> BuzzChannelInvites.remove(me, channelId)
|
||||
else -> {
|
||||
BuzzDmChannels.remove(me, channelId)
|
||||
if (channelId in joined) BuzzChannelInvites.remove(me, channelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-4
@@ -24,15 +24,33 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
|
||||
/**
|
||||
* Whether [event] is a **minichat thread reply** — a reply that lives inside the thread opened from
|
||||
* its parent message, NOT as a flat sibling in the main timeline. Two dialects express the same idea:
|
||||
* its parent message, NOT as a flat sibling in the main timeline. Three dialects express the same idea:
|
||||
*
|
||||
* - **NIP-28/NIP-29 (public chats, Concord)**: a kind-1111 [CommentEvent].
|
||||
* - **Buzz workspaces**: a kind-40002 [StreamMessageV2Event] carrying a NIP-10 `reply`-marked `e` tag
|
||||
* and NOT flagged `broadcast` (Buzz rejects kind-1111, so it threads chat with 40002 markers; a
|
||||
* `broadcast=1` reply is an inline timeline sibling, matching block/buzz's `isThreadReply`).
|
||||
* - **Buzz workspaces, current**: a kind-9 [ChatEvent] carrying a NIP-10 `reply`-marked `e` tag. This
|
||||
* is what every live Buzz client writes — `_buildReplyTags` in its Flutter client emits
|
||||
* `["e", id, "", "reply"]` for a direct reply and `["e", root, "", "root"]` +
|
||||
* `["e", parent, "", "reply"]` for a nested one, and all three of their clients send chat as kind 9.
|
||||
* - **Buzz workspaces, legacy**: a kind-40002 [StreamMessageV2Event] with the same markers and NOT
|
||||
* flagged `broadcast`. Nothing in Buzz writes 40002 any more (their own NOSTR.md grades it
|
||||
* "Buzz-only — no standard NIP-29 client renders these"), but events exist in the wild from the
|
||||
* 10002 -> 40001 -> 40002 migration, and Amethyst itself wrote some, so it stays readable.
|
||||
*
|
||||
* ### Why a marked `e` and not `q`
|
||||
*
|
||||
* NIP-C7 gives kind 9 exactly one reply mechanism — `["q", <id>, <relay>, <pubkey>]` — and never
|
||||
* mentions `e` at all. So a marked `e` carries no C7 meaning and is free to denote a *thread* reply,
|
||||
* which is precisely how Buzz uses it. The marker is what separates the cases: WhiteNoise/Marmot
|
||||
* thread kind-9 chat with a **plain, unmarked** `e`, which is an in-chat reply and must keep rendering
|
||||
* as a quote bubble in the timeline — so matching on the `reply` marker (never on the bare tag) leaves
|
||||
* that dialect untouched.
|
||||
*
|
||||
* A `broadcast=1` reply is an inline timeline sibling ("also send to channel"), matching block/buzz's
|
||||
* `isThreadReply`. Kind 9 has no broadcast tag, so a marked kind-9 is always thread-only.
|
||||
*
|
||||
* The timeline filter drops these (they belong in the minichat), the minichat count counts them, and
|
||||
* the minichat feed shows them — so all three agree on one definition.
|
||||
@@ -40,6 +58,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
fun isMinichatReply(event: Event?): Boolean =
|
||||
when (event) {
|
||||
is CommentEvent -> true
|
||||
is ChatEvent -> event.tags.buzzThreadReply() != null
|
||||
is StreamMessageV2Event -> !event.isBroadcast() && event.tags.buzzThreadReply() != null
|
||||
else -> false
|
||||
}
|
||||
|
||||
+3
-3
@@ -149,17 +149,17 @@ fun ChatroomMessageCompose(
|
||||
RenderChannelAdminSystemMessage(baseNote, accountViewModel, nav)
|
||||
} else if (event is SystemMessageEvent) {
|
||||
// Buzz kind-40099: relay-signed room narration (join/leave/topic).
|
||||
RenderBuzzSystemMessage(baseNote)
|
||||
RenderBuzzSystemMessage(baseNote, accountViewModel, nav)
|
||||
} else if (event is StreamMessageDiffEvent) {
|
||||
// Buzz kind-40008: a code/text diff pushed into the channel.
|
||||
RenderBuzzDiff(baseNote)
|
||||
} else if (event is ForumVoteEvent) {
|
||||
// Buzz kind-45002: a forum up/down vote.
|
||||
RenderBuzzForumVote(baseNote)
|
||||
RenderBuzzForumVote(baseNote, accountViewModel)
|
||||
} else if (isBuzzActivityRow(event)) {
|
||||
// Buzz agent-job (43xxx) and huddle (48xxx) lifecycle narration. Huddles
|
||||
// especially must be caught here — their content is JSON, not chat text.
|
||||
RenderBuzzActivityRow(baseNote)
|
||||
RenderBuzzActivityRow(baseNote, accountViewModel)
|
||||
} else {
|
||||
NormalChatNote(
|
||||
baseNote,
|
||||
|
||||
+33
-3
@@ -20,22 +20,29 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size18dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
|
||||
@@ -43,11 +50,16 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
* A centered, muted system line for events that narrate the room rather than talk
|
||||
* in it (channel created, profile updated, ...). Visually distinct from user
|
||||
* bubbles: no author row, no tail, one small pill in the middle of the feed.
|
||||
*
|
||||
* [leading] is an optional slot rendered inside the pill, before the text — used to
|
||||
* put the avatar of whoever the line is about ("Alice joined") next to the sentence,
|
||||
* so a membership change is recognizable without reading the name.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatSystemMessage(
|
||||
text: String,
|
||||
onClick: (() -> Unit)? = null,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
@@ -66,19 +78,33 @@ fun ChatSystemMessage(
|
||||
Modifier
|
||||
},
|
||||
) {
|
||||
SystemMessageText(text)
|
||||
if (leading == null) {
|
||||
SystemMessageText(text)
|
||||
} else {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
) {
|
||||
leading()
|
||||
SystemMessageText(text, startPadding = 0.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SystemMessageText(text: String) {
|
||||
private fun SystemMessageText(
|
||||
text: String,
|
||||
startPadding: Dp = 12.dp,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = Font12SP,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||
modifier = Modifier.padding(start = startPadding, end = 12.dp, top = 5.dp, bottom = 5.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -88,5 +114,9 @@ private fun ChatSystemMessagePreview() {
|
||||
ThemeComparisonColumn {
|
||||
ChatSystemMessage("Alice created the channel Amethyst Users", onClick = {})
|
||||
ChatSystemMessage("Alice updated the channel profile")
|
||||
ChatSystemMessage(
|
||||
"Bob was added by Alice",
|
||||
leading = { Box(Modifier.size(Size18dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+67
-51
@@ -110,33 +110,6 @@ fun RenderBuzzEditedNote(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Buzz kind-40099 system message ("X joined", "channel created", "topic changed"):
|
||||
* narrates the room rather than speaking in it, so it renders as a centered system
|
||||
* line like the NIP-28 admin events, from the relay-signed JSON payload.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderBuzzSystemMessage(note: Note) {
|
||||
val event = note.event as? SystemMessageEvent ?: return
|
||||
val text = remember(event) { buzzSystemMessageText(event) }
|
||||
ChatSystemMessage(text = text)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line label for a Buzz kind-40099 system message. Relay-emitted machine text
|
||||
* (join/leave/topic); shown as-is rather than through string resources — the payload
|
||||
* vocabulary is Buzz's, not ours to translate yet. Pure so the Messages-list preview and
|
||||
* the in-chat system line render identical text.
|
||||
*/
|
||||
fun buzzSystemMessageText(event: SystemMessageEvent): String {
|
||||
val payload = event.payload()
|
||||
return when (payload?.type) {
|
||||
"topic_changed" -> payload.topic?.let { "topic: $it" } ?: "topic changed"
|
||||
"purpose_changed" -> payload.purpose?.let { "purpose: $it" } ?: "purpose changed"
|
||||
else -> payload?.type?.replace('_', ' ')
|
||||
} ?: event.content.take(120)
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the Buzz agent-job and huddle lifecycle kinds that [RenderBuzzActivityRow]
|
||||
* narrates as a centered system line rather than a chat bubble. Huddle events in
|
||||
@@ -161,32 +134,59 @@ fun isBuzzActivityRow(event: Event?): Boolean =
|
||||
* snippet (the human-readable status/result/error the agent wrote).
|
||||
*/
|
||||
@Composable
|
||||
fun RenderBuzzActivityRow(note: Note) {
|
||||
fun RenderBuzzActivityRow(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val event = note.event ?: return
|
||||
val text = remember(event) { buzzActivityLabel(event) ?: event.content.take(120) }
|
||||
val text = buzzActivityLabel(event, accountViewModel) ?: remember(event) { event.content.take(120) }
|
||||
ChatSystemMessage(text = text)
|
||||
}
|
||||
|
||||
/**
|
||||
* The centered-system-line label for a Buzz agent-job or huddle lifecycle event, or null if
|
||||
* [event] isn't one. Pure so the Messages-list preview and the in-chat activity row read the
|
||||
* same. The label is derived from the kind; job progress/result/error also append a short
|
||||
* content snippet (the human-readable status/result/error the agent wrote).
|
||||
* [event] isn't one. Shared by the Messages-list preview and the in-chat activity row so the
|
||||
* two read the same.
|
||||
*
|
||||
* Huddle joins/leaves name the participant from the event's `p` tag (falling back to the
|
||||
* signer), and job lines name the signer — the agent that accepted, the human that requested —
|
||||
* because "someone joined the huddle" in a busy channel says nothing about who to talk to.
|
||||
* Job progress/result/error also append a short snippet of what the agent wrote.
|
||||
*/
|
||||
fun buzzActivityLabel(event: Event): String? =
|
||||
when (event) {
|
||||
is JobRequestEvent -> "⚙ job requested" + event.request().snippet()
|
||||
is JobAcceptedEvent -> "⚙ job accepted"
|
||||
is JobProgressEvent -> "⚙ job progress" + (event.status()?.let { ": $it" } ?: "") + event.content.snippet()
|
||||
is JobResultEvent -> "⚙ job result" + event.result().snippet()
|
||||
is JobCancelEvent -> "⚙ job cancelled"
|
||||
is JobErrorEvent -> "⚠ job error" + event.error().snippet()
|
||||
is HuddleStartedEvent -> "🔊 huddle started"
|
||||
is HuddleParticipantJoinedEvent -> "🔊 someone joined the huddle"
|
||||
is HuddleParticipantLeftEvent -> "🔊 someone left the huddle"
|
||||
is HuddleEndedEvent -> "🔊 huddle ended"
|
||||
@Composable
|
||||
fun buzzActivityLabel(
|
||||
event: Event,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String? {
|
||||
val signer = observeUserNameByHex(event.pubKey, accountViewModel)
|
||||
val participant =
|
||||
observeUserNameByHex(
|
||||
remember(event) {
|
||||
when (event) {
|
||||
is HuddleParticipantJoinedEvent -> event.participant() ?: event.pubKey
|
||||
is HuddleParticipantLeftEvent -> event.participant() ?: event.pubKey
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
accountViewModel,
|
||||
)
|
||||
|
||||
return when (event) {
|
||||
is JobRequestEvent -> stringRes(R.string.buzz_job_requested, signer) + remember(event) { event.request().snippet() }
|
||||
is JobAcceptedEvent -> stringRes(R.string.buzz_job_accepted, signer)
|
||||
is JobProgressEvent ->
|
||||
stringRes(R.string.buzz_job_progress) +
|
||||
remember(event) { (event.status()?.let { ": $it" } ?: "") + event.content.snippet() }
|
||||
is JobResultEvent -> stringRes(R.string.buzz_job_result) + remember(event) { event.result().snippet() }
|
||||
is JobCancelEvent -> stringRes(R.string.buzz_job_cancelled)
|
||||
is JobErrorEvent -> stringRes(R.string.buzz_job_error) + remember(event) { event.error().snippet() }
|
||||
is HuddleStartedEvent -> stringRes(R.string.buzz_huddle_started, signer)
|
||||
is HuddleParticipantJoinedEvent -> stringRes(R.string.buzz_huddle_joined, participant)
|
||||
is HuddleParticipantLeftEvent -> stringRes(R.string.buzz_huddle_left, participant)
|
||||
is HuddleEndedEvent -> stringRes(R.string.buzz_huddle_ended)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A human-readable one-line preview of a Buzz chat-timeline event for the Messages list, or null
|
||||
@@ -195,12 +195,16 @@ fun buzzActivityLabel(event: Event): String? =
|
||||
* so this returns the same summary the in-chat system/activity/diff row shows instead of dumping
|
||||
* raw payload into the preview. Keyed off the exact set in [isBuzzChatTimelineContent].
|
||||
*/
|
||||
fun buzzTimelinePreviewSummary(event: Event): String? =
|
||||
@Composable
|
||||
fun buzzTimelinePreviewSummary(
|
||||
event: Event,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String? =
|
||||
when (event) {
|
||||
is StreamMessageV2Event -> null
|
||||
is SystemMessageEvent -> buzzSystemMessageText(event)
|
||||
is StreamMessageDiffEvent -> event.diffMeta()?.filePath?.let { "📄 $it" } ?: "📄 diff"
|
||||
else -> buzzActivityLabel(event)
|
||||
is SystemMessageEvent -> buzzSystemMessageText(event, accountViewModel)
|
||||
is StreamMessageDiffEvent -> remember(event) { event.diffMeta()?.filePath?.let { "📄 $it" } ?: "📄 diff" }
|
||||
else -> buzzActivityLabel(event, accountViewModel)
|
||||
}
|
||||
|
||||
/** A short one-line snippet of free-text content appended after a label, or "" if blank. */
|
||||
@@ -265,9 +269,21 @@ fun RenderBuzzDiff(note: Note) {
|
||||
|
||||
/** A Buzz kind-45002 forum vote: a lightweight up/down signal, shown as a system line. */
|
||||
@Composable
|
||||
fun RenderBuzzForumVote(note: Note) {
|
||||
fun RenderBuzzForumVote(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val event = note.event as? ForumVoteEvent ?: return
|
||||
// Content is the vote token ("+"/"-" or similar); show a compact glyph line.
|
||||
val text = remember(event) { if (event.content.trim().startsWith("-")) "▼ downvoted a post" else "▲ upvoted a post" }
|
||||
ChatSystemMessage(text = text)
|
||||
val isDownVote = remember(event) { event.content.trim().startsWith("-") }
|
||||
val voter = observeUserNameByHex(event.pubKey, accountViewModel)
|
||||
|
||||
ChatSystemMessage(
|
||||
text =
|
||||
if (isDownVote) {
|
||||
stringRes(R.string.buzz_forum_downvoted, voter)
|
||||
} else {
|
||||
stringRes(R.string.buzz_forum_upvoted, voter)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.chats.feed.types
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatSystemMessage
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size18dp
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz kind-40099 system message: relay-authored narration of a channel state change
|
||||
* ("Alice joined", "Bob made this channel private"). It narrates the room rather than
|
||||
* speaking in it, so it renders as a centered system line like the NIP-28 admin events —
|
||||
* with the avatar of whoever the line is about, tapping through to their profile.
|
||||
*
|
||||
* The event's own author is the **relay keypair**, never a person, so the people in the
|
||||
* sentence come from the signed JSON payload's `actor`/`target` pubkeys, not from
|
||||
* `note.author`.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderBuzzSystemMessage(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val event = note.event as? SystemMessageEvent ?: return
|
||||
val payload = remember(event) { event.payload() }
|
||||
val subject = remember(payload) { payload?.subject() }
|
||||
|
||||
ChatSystemMessage(
|
||||
text = buzzSystemMessageText(event, accountViewModel),
|
||||
onClick = subject?.let { { nav.nav(Route.Profile(it)) } },
|
||||
leading =
|
||||
subject?.let {
|
||||
{
|
||||
UserPicture(
|
||||
userHex = it,
|
||||
size = Size18dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line sentence for a Buzz kind-40099 system message, with `actor`/`target` pubkeys
|
||||
* resolved to the names the viewer knows them by (petname first, like everywhere else).
|
||||
*
|
||||
* Shared by the in-chat system line and the Messages-list preview so the two can never word
|
||||
* the same event differently. A payload this version has no sentence for degrades to
|
||||
* "name: the_raw_type" rather than vanishing — new relay vocabulary stays legible.
|
||||
*/
|
||||
@Composable
|
||||
fun buzzSystemMessageText(
|
||||
event: SystemMessageEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String {
|
||||
val payload = remember(event) { event.payload() } ?: return remember(event) { event.content.take(120) }
|
||||
|
||||
val actor = observeUserNameByHex(payload.actor, accountViewModel)
|
||||
val target = observeUserNameByHex(payload.target, accountViewModel)
|
||||
val subject = if (payload.target != null) target else actor
|
||||
|
||||
return when (payload.type) {
|
||||
// The relay emits the same type for "I joined" and "someone added me", separated only by
|
||||
// actor == target. Wording them the same would credit a self-join to whoever invited.
|
||||
SystemMessagePayload.MEMBER_JOINED ->
|
||||
if (payload.target == null || payload.target == payload.actor) {
|
||||
stringRes(R.string.buzz_system_member_joined, subject)
|
||||
} else {
|
||||
stringRes(R.string.buzz_system_member_added, target, actor)
|
||||
}
|
||||
|
||||
SystemMessagePayload.MEMBER_LEFT -> stringRes(R.string.buzz_system_member_left, subject)
|
||||
|
||||
SystemMessagePayload.MEMBER_REMOVED ->
|
||||
if (payload.target == null || payload.target == payload.actor) {
|
||||
stringRes(R.string.buzz_system_member_left, subject)
|
||||
} else {
|
||||
stringRes(R.string.buzz_system_member_removed, target, actor)
|
||||
}
|
||||
|
||||
SystemMessagePayload.TOPIC_CHANGED ->
|
||||
payload.topic?.takeIf { it.isNotBlank() }?.let {
|
||||
stringRes(R.string.buzz_system_topic_changed, actor, it)
|
||||
} ?: stringRes(R.string.buzz_system_topic_cleared, actor)
|
||||
|
||||
SystemMessagePayload.PURPOSE_CHANGED ->
|
||||
payload.purpose?.takeIf { it.isNotBlank() }?.let {
|
||||
stringRes(R.string.buzz_system_purpose_changed, actor, it)
|
||||
} ?: stringRes(R.string.buzz_system_purpose_cleared, actor)
|
||||
|
||||
// Buzz has exactly two visibility modes; spell out what each one means for the reader
|
||||
// rather than echoing the relay's token, and keep a literal fallback for a third.
|
||||
SystemMessagePayload.VISIBILITY_CHANGED ->
|
||||
when (payload.visibility) {
|
||||
SystemMessagePayload.VISIBILITY_OPEN -> stringRes(R.string.buzz_system_visibility_open, actor)
|
||||
SystemMessagePayload.VISIBILITY_PRIVATE -> stringRes(R.string.buzz_system_visibility_private, actor)
|
||||
else -> stringRes(R.string.buzz_system_visibility_other, actor, payload.visibility ?: "")
|
||||
}
|
||||
|
||||
// A null ttl_seconds is the relay clearing the TTL (messages become permanent).
|
||||
SystemMessagePayload.TTL_CHANGED ->
|
||||
payload.ttlSeconds?.takeIf { it > 0 }?.let {
|
||||
stringRes(R.string.buzz_system_ttl_set, actor, ttlDurationText(it))
|
||||
} ?: stringRes(R.string.buzz_system_ttl_cleared, actor)
|
||||
|
||||
SystemMessagePayload.CHANNEL_ARCHIVED -> stringRes(R.string.buzz_system_channel_archived, actor)
|
||||
SystemMessagePayload.CHANNEL_UNARCHIVED -> stringRes(R.string.buzz_system_channel_unarchived, actor)
|
||||
SystemMessagePayload.CHANNEL_CREATED -> stringRes(R.string.buzz_system_channel_created, actor)
|
||||
SystemMessagePayload.CHANNEL_DELETED -> stringRes(R.string.buzz_system_channel_deleted, actor)
|
||||
|
||||
SystemMessagePayload.MESSAGE_DELETED ->
|
||||
payload.publicReason?.takeIf { it.isNotBlank() }?.let {
|
||||
stringRes(R.string.buzz_system_message_deleted_reason, actor, it)
|
||||
} ?: stringRes(R.string.buzz_system_message_deleted, actor)
|
||||
|
||||
SystemMessagePayload.DM_CREATED -> stringRes(R.string.buzz_system_dm_created, actor)
|
||||
|
||||
else -> stringRes(R.string.buzz_system_unknown, actor, payload.type.replace('_', ' '))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A TTL as a rounded, pluralized duration ("7 days", "12 hours"), reusing the same duration
|
||||
* plurals as the last-seen line. Rounds to the largest whole unit that fits, which is what the
|
||||
* relay's own values are (a day, a week) and reads better than "604800 seconds".
|
||||
*/
|
||||
@Composable
|
||||
private fun ttlDurationText(seconds: Long): String =
|
||||
when {
|
||||
seconds >= TimeUtils.ONE_DAY -> {
|
||||
val n = (seconds / TimeUtils.ONE_DAY).toInt()
|
||||
pluralStringResource(R.plurals.duration_days, n, n)
|
||||
}
|
||||
seconds >= TimeUtils.ONE_HOUR -> {
|
||||
val n = (seconds / TimeUtils.ONE_HOUR).toInt()
|
||||
pluralStringResource(R.plurals.duration_hours, n, n)
|
||||
}
|
||||
else -> {
|
||||
val n = (seconds / TimeUtils.ONE_MINUTE).toInt().coerceAtLeast(1)
|
||||
pluralStringResource(R.plurals.duration_minutes, n, n)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The display name for a pubkey that arrived inside an event's *payload* rather than as its
|
||||
* author — resolving it needs a [com.vitorpamplona.amethyst.commons.model.User] first, which may
|
||||
* not be in the cache yet.
|
||||
*
|
||||
* Returns the shortened hex until the profile loads, and empty string for a null pubkey so a
|
||||
* caller can format a sentence whose subject the relay omitted without printing "null".
|
||||
*/
|
||||
@Composable
|
||||
fun observeUserNameByHex(
|
||||
pubkey: HexKey?,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String {
|
||||
if (pubkey == null) return ""
|
||||
|
||||
var user by remember(pubkey) { mutableStateOf(accountViewModel.getUserIfExists(pubkey)) }
|
||||
|
||||
if (user == null) {
|
||||
LaunchedEffect(pubkey) { user = accountViewModel.checkGetOrCreateUser(pubkey) }
|
||||
}
|
||||
|
||||
val loaded = user ?: return remember(pubkey) { pubkey.take(8) }
|
||||
val name by observeUserName(loaded, accountViewModel)
|
||||
return name
|
||||
}
|
||||
+1
-1
@@ -26,8 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
/**
|
||||
* Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts
|
||||
|
||||
+1
-1
@@ -28,8 +28,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
fun filterMessagesToLiveActivities(
|
||||
channel: LiveActivitiesChannel,
|
||||
|
||||
+35
-2
@@ -23,6 +23,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
@@ -78,11 +81,41 @@ fun relayGroupServerHasUnreadFlow(
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
/** Whether this group's message store holds any acceptable chat content created after [sinceSecs]. */
|
||||
/**
|
||||
* Whether [note] is one of this group's **timeline** messages — what the channel feed renders, what
|
||||
* the Messages row previews, and what the unread dot counts.
|
||||
*
|
||||
* Two exclusions, and both matter for the same reason: the row summary must not disagree with what
|
||||
* opening the channel shows.
|
||||
* - Non-content (`reaction`/deletion/label) carries the group's `h` tag too, so [isGroupChatContent]
|
||||
* gates it out — a trailing 👍 must not become the "last message".
|
||||
* - A **minichat thread reply** lives in the thread opened from its parent, not in the timeline
|
||||
* ([isMinichatReply], the same predicate `ChannelFeedFilter` uses). Without this the Messages row
|
||||
* previews a reply the channel never displays, and the unread dot lights for activity that leaves
|
||||
* the timeline unchanged — you open the group, see nothing new, and the dot clears.
|
||||
*
|
||||
* The Concord side solves this identically with `isConcordTimelineMessage`.
|
||||
*/
|
||||
fun isRelayGroupTimelineMessage(
|
||||
note: Note,
|
||||
account: Account,
|
||||
): Boolean = note.event?.isGroupChatContent() == true && !isMinichatReply(note.event) && account.isAcceptable(note)
|
||||
|
||||
/**
|
||||
* The newest timeline message in this group (see [isRelayGroupTimelineMessage]), or null if none —
|
||||
* the note the Messages row shows as the group's "last message".
|
||||
*/
|
||||
fun RelayGroupChannel.newestTimelineNote(account: Account): Note? =
|
||||
notes
|
||||
.filter { _, note -> isRelayGroupTimelineMessage(note, account) }
|
||||
.sortedByDefaultFeedOrder()
|
||||
.firstOrNull()
|
||||
|
||||
/** Whether this group's message store holds any acceptable timeline message created after [sinceSecs]. */
|
||||
private fun RelayGroupChannel.hasChatNewerThan(
|
||||
account: Account,
|
||||
sinceSecs: Long,
|
||||
): Boolean =
|
||||
notes.count { _, note ->
|
||||
(note.createdAt() ?: 0L) > sinceSecs && account.isAcceptable(note) && note.event?.isGroupChatContent() == true
|
||||
(note.createdAt() ?: 0L) > sinceSecs && isRelayGroupTimelineMessage(note, account)
|
||||
} > 0
|
||||
|
||||
+30
-16
@@ -28,18 +28,20 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.filterGroupNotificationsToPubkey
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** One screen's request to keep the viewer's Buzz DM channels' recent chat live. */
|
||||
/** One Buzz DM channel whose recent chat is kept live. */
|
||||
class BuzzDmJoinedChatTailQueryState(
|
||||
val account: Account,
|
||||
val groupId: GroupId,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -71,7 +73,7 @@ class BuzzDmJoinedChatTailFilterAssembler(
|
||||
class BuzzDmJoinedChatTailSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<BuzzDmJoinedChatTailQueryState>,
|
||||
) : PerUniqueIdEoseManager<BuzzDmJoinedChatTailQueryState, Account>(client, allKeys) {
|
||||
) : PerUniqueIdEoseManager<BuzzDmJoinedChatTailQueryState, GroupId>(client, allKeys) {
|
||||
private val windowLoad = WindowLoadTracker("buzzDm.preview.live")
|
||||
val loadingMore: StateFlow<Boolean> = windowLoad.loading
|
||||
|
||||
@@ -79,23 +81,35 @@ class BuzzDmJoinedChatTailSubAssembler(
|
||||
key: BuzzDmJoinedChatTailQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter>? {
|
||||
// The preload only mounts visible channels; re-checked here so hiding a conversation
|
||||
// empties its subscription even if the key outlives the recomposition.
|
||||
val me = key.account.userProfile().pubkeyHex
|
||||
val hidden = BuzzDmRegistry.hiddenFor(me)
|
||||
val channels = BuzzDmChannels.channelsFor(me).filterKeys { it !in hidden }
|
||||
if (channels.isEmpty()) {
|
||||
windowLoad.setExpectedRelays(emptySet())
|
||||
return null
|
||||
}
|
||||
if (key.groupId.id in BuzzDmRegistry.hiddenFor(me)) return null
|
||||
|
||||
// Reuse the joined-group tail builder: one #h filter per host relay carrying every DM channel id
|
||||
// on it, bounded by the shared recent floor (no per-channel limit, reconnect-safe).
|
||||
val asTags = channels.map { (channelId, relay) -> GroupTag(channelId, relay.url) }
|
||||
val filters = buildRelayGroupJoinedChatTailFilters(asTags, DmHistoryTuning.recentBoundary())
|
||||
windowLoad.setExpectedRelays(filters.mapTo(mutableSetOf()) { it.relay })
|
||||
return filters
|
||||
// Same one-subscription-per-channel shape as the joined-group tail: a Buzz DM is a
|
||||
// relay-authoritative NIP-29 group, so batching ids into one `#h` would register the
|
||||
// subscription as global on buzz and never deliver live messages. See
|
||||
// [buildRelayGroupJoinedChatTailFilter]. The second filter carries the DM's activity
|
||||
// addressed to me (reactions/zaps/reports) — same channel, so the subscription stays scoped.
|
||||
val relay = key.groupId.relayUrl
|
||||
windowLoad.setExpectedRelays(setOf(relay))
|
||||
return listOf(
|
||||
buildRelayGroupJoinedChatTailFilter(key.groupId, DmHistoryTuning.recentBoundary()),
|
||||
// Newest message at any age — a DM conversation dormant for over a week must still show its
|
||||
// last message on Messages, not a placeholder. See [buildRelayGroupPreviewFilter].
|
||||
buildRelayGroupPreviewFilter(key.groupId, since?.get(relay)?.time),
|
||||
// A DM channel takes reactions/deletions the same way a group channel does.
|
||||
buildRelayGroupAuxFilter(key.groupId, DmHistoryTuning.recentBoundary()),
|
||||
) +
|
||||
filterGroupNotificationsToPubkey(
|
||||
relay = relay,
|
||||
pubkey = me,
|
||||
groupIds = listOf(key.groupId.id),
|
||||
since = since?.get(relay)?.time,
|
||||
)
|
||||
}
|
||||
|
||||
override fun id(key: BuzzDmJoinedChatTailQueryState) = key.account
|
||||
override fun id(key: BuzzDmJoinedChatTailQueryState) = key.groupId
|
||||
|
||||
override fun newSub(key: BuzzDmJoinedChatTailQueryState): Subscription {
|
||||
windowLoad.startLoading(key.account.scope)
|
||||
|
||||
+41
-7
@@ -23,13 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
||||
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/**
|
||||
@@ -52,18 +55,35 @@ fun RelayGroupJoinedStatePreload(accountViewModel: AccountViewModel) {
|
||||
|
||||
/**
|
||||
* Always-on preview **live tail** for joined groups' recent chat, mounted alongside [RelayGroupJoinedStatePreload]
|
||||
* — keeps the Messages-list previews reflecting the true newest message app-wide. Re-derives on join/leave.
|
||||
* — keeps the Messages-list previews reflecting the true newest message app-wide.
|
||||
*
|
||||
* Mounts **one subscription per joined group** (not one per host relay): a subscription whose `#h` carries
|
||||
* more than one group id is registered as a *global* subscription by `block/buzz` and then never receives
|
||||
* channel-scoped events, so a batched tail backfills once and goes deaf. See
|
||||
* [buildRelayGroupJoinedChatTailFilter]. Joining/leaving adds or removes a key, so nothing needs an
|
||||
* explicit invalidate; a group whose relay url won't normalize is skipped, exactly as the Messages feed
|
||||
* filter skips it.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayGroupJoinedChatTailPreload(accountViewModel: AccountViewModel) {
|
||||
val account = accountViewModel.account
|
||||
val dataSource = accountViewModel.dataSources().relayGroupJoinedChatTail
|
||||
val state = remember(account) { RelayGroupJoinedChatTailQueryState(account) }
|
||||
|
||||
val joined by account.relayGroupList.liveRelayGroupList.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(joined) { dataSource.invalidateFilters() }
|
||||
val enabledFeeds by account.settings.enabledChatFeeds.collectAsStateWithLifecycle()
|
||||
|
||||
KeyDataSourceSubscription(state, dataSource)
|
||||
if (ChatFeedType.NIP29 in enabledFeeds) {
|
||||
joined.forEach { tag ->
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)
|
||||
if (relay != null) {
|
||||
val groupId = GroupId(tag.groupId, relay)
|
||||
key(groupId) {
|
||||
val state = remember(account, groupId) { RelayGroupJoinedChatTailQueryState(account, groupId) }
|
||||
KeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,13 +97,27 @@ fun RelayGroupJoinedChatTailPreload(accountViewModel: AccountViewModel) {
|
||||
fun BuzzDmJoinedChatTailPreload(accountViewModel: AccountViewModel) {
|
||||
val account = accountViewModel.account
|
||||
val dataSource = accountViewModel.dataSources().buzzDmJoinedChatTail
|
||||
val state = remember(account) { BuzzDmJoinedChatTailQueryState(account) }
|
||||
val me = account.userProfile().pubkeyHex
|
||||
|
||||
// Both values must be read (not just collected) so the snapshot system recomposes this
|
||||
// preload — and re-derives the mounted set — whenever a DM is discovered or hidden.
|
||||
val channels by BuzzDmChannels.flow.collectAsStateWithLifecycle()
|
||||
val hidden by BuzzDmRegistry.hidden.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(channels, hidden) { dataSource.invalidateFilters() }
|
||||
|
||||
KeyDataSourceSubscription(state, dataSource)
|
||||
val visible =
|
||||
remember(channels, hidden, me) {
|
||||
BuzzDmChannels.channelsFor(me).filterKeys { it !in BuzzDmRegistry.hiddenFor(me) }
|
||||
}
|
||||
|
||||
// One subscription per DM channel — batching their ids into a single `#h` makes buzz treat the
|
||||
// subscription as global and stop delivering live messages. See [buildRelayGroupJoinedChatTailFilter].
|
||||
visible.forEach { (channelId, relay) ->
|
||||
val groupId = GroupId(channelId, relay)
|
||||
key(groupId) {
|
||||
val state = remember(account, groupId) { BuzzDmJoinedChatTailQueryState(account, groupId) }
|
||||
KeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+122
-17
@@ -54,13 +54,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
@@ -172,6 +175,33 @@ val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS =
|
||||
/** The timeline kinds requested for every relay-group REQ (NIP-29 + Buzz; see above). */
|
||||
val RELAY_GROUP_ALL_TIMELINE_KINDS = RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS
|
||||
|
||||
/**
|
||||
* Channel **aux** kinds — overlays that modify an existing row rather than being a row: reactions (7),
|
||||
* NIP-09 deletions (5) and the Buzz-native NIP-29 delete (9005).
|
||||
*
|
||||
* Requested `#h`-scoped on the channel subscription, which is how the Buzz reference client does it
|
||||
* (`channelEventKinds` in its Flutter client bundles deletion + reaction + the message kinds into the
|
||||
* one `#h` channel REQ). Amethyst otherwise only learns about reactions through the shared `#e`
|
||||
* EventFinder query keyed on the ids of notes currently on screen — and that query carries no `#h`, so
|
||||
* on a relay that scopes live delivery per channel it is registered as a *global* subscription and can
|
||||
* never receive them live (a reaction inherits its target's channel, so it IS channel-scoped). The
|
||||
* result was reaction chips that only ever appeared on a re-query, never as they happened.
|
||||
*
|
||||
* Kept OUT of [RELAY_GROUP_ALL_TIMELINE_KINDS] on purpose: that set feeds the backward history pager,
|
||||
* whose `until` cursor walks `created_at`. Letting reactions consume a page's `limit` would advance the
|
||||
* cursor past chat messages that were never delivered — the same class of cursor-skip bug that the
|
||||
* unconditional-Buzz-kinds note above records.
|
||||
*/
|
||||
val RELAY_GROUP_AUX_KINDS =
|
||||
listOf(
|
||||
DeletionEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
DeleteEventEvent.KIND,
|
||||
)
|
||||
|
||||
/** How many aux events a channel replays on subscribe. Bounds the backfill; live delivery is unbounded. */
|
||||
const val RELAY_GROUP_AUX_LIMIT = 500
|
||||
|
||||
/**
|
||||
* Kinds requested on the **open channel's live tail only** — the timeline set plus the
|
||||
* ephemeral kind-20002 typing indicator. Typing is scoped to the one channel on screen
|
||||
@@ -233,25 +263,100 @@ fun buildRelayGroupStateFilters(
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent chat of every joined group, **one `#h` filter per host relay** carrying that relay's group ids,
|
||||
* bounded by a shared time floor ([sinceEpoch]) and **no per-group `limit`** — this is what lets the whole
|
||||
* relay's groups batch into a single REQ and makes it reconnect-safe.
|
||||
* Recent chat of **one** joined group: a single-valued `#h` filter on its host relay, bounded by a shared
|
||||
* time floor ([sinceEpoch]) and **no `limit`** — a time floor bounds it, so it stays reconnect-safe (a
|
||||
* reconnect re-issues one `since=window` REQ, never a page replay).
|
||||
*
|
||||
* ### Why one group per filter — and per *subscription*
|
||||
*
|
||||
* This used to batch every joined group on a relay into ONE filter carrying every group id in `#h`. That
|
||||
* shape is valid NIP-01 (a multi-value tag filter is an OR), and relays serve it correctly for **stored**
|
||||
* queries — which is exactly what made the bug so confusing: the boot backfill populated every group, so
|
||||
* the Messages list looked right until it needed to update.
|
||||
*
|
||||
* But `block/buzz` (the relay behind `*.communities.buzz.xyz`) indexes each live subscription under a
|
||||
* *single* channel uuid, resolved from the filters' `#h`. When two or more distinct ids appear anywhere
|
||||
* across a subscription's filters, `extract_channel_id_from_filters` returns `None` and the subscription
|
||||
* is registered as **global** — and global subscriptions deliberately never receive channel-scoped events
|
||||
* (`fan_out_scoped`: "Global subscriptions do NOT receive channel-scoped events", guarding against leaking
|
||||
* private channel content to a subscriber whose membership wasn't checked per channel). Net effect: a
|
||||
* batched tail gets full history at EOSE and then goes permanently deaf, so the Messages row froze while
|
||||
* the open chat — which always used a single-valued `#h` — stayed live.
|
||||
*
|
||||
* The resolution scans **all filters of a subscription**, so splitting into one filter per group is not
|
||||
* enough: each group needs its **own subscription** (see [RelayGroupJoinedChatTailFilterAssembler], which
|
||||
* keys its EOSE manager on [GroupId]). Relays advertise room for this — buzz allows `max_subscriptions:
|
||||
* 1024` against `max_filters: 10` — and it sidesteps the filter cap for anyone in more than ten groups.
|
||||
*/
|
||||
fun buildRelayGroupJoinedChatTailFilters(
|
||||
joined: Collection<GroupTag>,
|
||||
fun buildRelayGroupJoinedChatTailFilter(
|
||||
groupId: GroupId,
|
||||
sinceEpoch: Long,
|
||||
): List<RelayBasedFilter> =
|
||||
byHostRelay(joined).map { (relay, ids) ->
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to ids.distinct()),
|
||||
since = sinceEpoch,
|
||||
),
|
||||
)
|
||||
}
|
||||
): RelayBasedFilter =
|
||||
RelayBasedFilter(
|
||||
relay = groupId.relayUrl,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
|
||||
since = sinceEpoch,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* The **newest message** of one joined channel, at any age: single-valued `#h`, `limit = 1`, and a
|
||||
* `since` that is null until this relay EOSEs.
|
||||
*
|
||||
* This is what fills the channel's Messages-list row, and it is deliberately count-bounded rather than
|
||||
* time-bounded. [buildRelayGroupJoinedChatTailFilter] floors at `now - 7 days` to keep recent chat warm
|
||||
* in cache; on its own that means a channel nobody has posted in for eight days returns *zero* events,
|
||||
* so `newestChatNote()` stays null and the row is stuck on its "No messages yet" placeholder forever —
|
||||
* sorted to the bottom of Messages by `createdAt = 0`, indistinguishable from a channel you just joined.
|
||||
*
|
||||
* Every other roster-driven protocol on that screen already bounds by count for exactly this reason:
|
||||
* NIP-28 asks `limit = 1` per followed channel
|
||||
* ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.filterLastMessageFollowingPublicChats]),
|
||||
* Concord asks `limit = 10` per channel with no floor (`ConcordSubscriptionPlanner.channelPreviewFilters`).
|
||||
* They can, because their row set comes from a list event — unlike NIP-17/NIP-04, whose *rooms* are
|
||||
* discovered from the messages themselves and therefore need the backward pagers on the Messages list.
|
||||
* A NIP-29 channel is roster-driven (kind 10009), so it needs one newest message per row, not a window.
|
||||
*
|
||||
* `since` comes from the per-relay EOSE map: null on a cold start (newest message whatever its age),
|
||||
* then advancing, so a reconnect re-asks for one event rather than replaying a window.
|
||||
*/
|
||||
fun buildRelayGroupPreviewFilter(
|
||||
groupId: GroupId,
|
||||
sinceEpoch: Long?,
|
||||
): RelayBasedFilter =
|
||||
RelayBasedFilter(
|
||||
relay = groupId.relayUrl,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
|
||||
since = sinceEpoch,
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Reactions/deletions for **every** message in one channel, `#h`-scoped on its host relay — see
|
||||
* [RELAY_GROUP_AUX_KINDS]. Single-valued `#h`, so it can share a channel-scoped subscription with the
|
||||
* chat tail without downgrading it.
|
||||
*/
|
||||
fun buildRelayGroupAuxFilter(
|
||||
groupId: GroupId,
|
||||
sinceEpoch: Long,
|
||||
): RelayBasedFilter =
|
||||
RelayBasedFilter(
|
||||
relay = groupId.relayUrl,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_AUX_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
|
||||
since = sinceEpoch,
|
||||
limit = RELAY_GROUP_AUX_LIMIT,
|
||||
),
|
||||
)
|
||||
|
||||
/** The recent-chat live tail for a single open group, `#h`-scoped on its host relay. */
|
||||
fun buildRelayGroupOpenChatTailFilter(
|
||||
|
||||
+52
-39
@@ -27,19 +27,21 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.filterGroupNotificationsToPubkey
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** One screen's request to keep the user's joined groups' recent chat live. */
|
||||
/** One joined group whose recent chat is kept live for the Messages-list preview. */
|
||||
class RelayGroupJoinedChatTailQueryState(
|
||||
val account: Account,
|
||||
val groupId: GroupId,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -47,15 +49,17 @@ class RelayGroupJoinedChatTailQueryState(
|
||||
* analog of the NIP-04 rooms-list live tail
|
||||
* ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListNip04SubAssembler]).
|
||||
*
|
||||
* One `#h`-scoped filter **per host relay** carries every joined group id on that relay at once,
|
||||
* `since = recentBoundary()` and **no per-group limit** — a time floor bounds it, so unlike the fixed
|
||||
* `limit=50` window it can batch all of a relay's groups into a single REQ. This keeps the Messages-list
|
||||
* previews reflecting the true newest message and joined groups' recent chat warm in cache without
|
||||
* **One subscription per joined group**, each a single-valued `#h` filter with `since = recentBoundary()`
|
||||
* and no `limit` — a time floor bounds it, so it stays reconnect-safe. This keeps the Messages-list
|
||||
* previews reflecting the true newest message, and joined groups' recent chat warm in cache, without
|
||||
* opening each one. Older history is the [RelayGroupOpenChatHistorySubAssembler]'s job (below the floor).
|
||||
*
|
||||
* Batching by a shared time floor (rather than a per-group `limit` + shared per-relay EOSE `since`) is
|
||||
* what makes this both correct — every joined group is covered the moment it joins — and reconnect-safe:
|
||||
* a reconnect re-issues one `since=window` REQ per relay, never a per-group page replay.
|
||||
* These used to be batched into one subscription per host relay carrying every group id in a single `#h`.
|
||||
* That is valid NIP-01 and relays answer it correctly for stored queries, but it silently breaks *live*
|
||||
* delivery on `block/buzz`, which can only index a subscription under one channel and downgrades a
|
||||
* multi-id one to "global" — a class that by design receives no channel-scoped events. The batched tail
|
||||
* therefore backfilled at EOSE and then went permanently deaf, freezing the Messages row while the open
|
||||
* chat (always single-valued) stayed live. See [buildRelayGroupJoinedChatTailFilter] for the full trace.
|
||||
*/
|
||||
class RelayGroupJoinedChatTailFilterAssembler(
|
||||
client: INostrClient,
|
||||
@@ -74,7 +78,7 @@ class RelayGroupJoinedChatTailFilterAssembler(
|
||||
class RelayGroupJoinedChatTailSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<RelayGroupJoinedChatTailQueryState>,
|
||||
) : PerUniqueIdEoseManager<RelayGroupJoinedChatTailQueryState, Account>(client, allKeys) {
|
||||
) : PerUniqueIdEoseManager<RelayGroupJoinedChatTailQueryState, GroupId>(client, allKeys) {
|
||||
private val windowLoad = WindowLoadTracker("relayGroup.preview.live")
|
||||
val loadingMore: StateFlow<Boolean> = windowLoad.loading
|
||||
|
||||
@@ -82,42 +86,51 @@ class RelayGroupJoinedChatTailSubAssembler(
|
||||
key: RelayGroupJoinedChatTailQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter>? {
|
||||
if (!key.account.settings.isChatFeedEnabled(ChatFeedType.NIP29)) {
|
||||
windowLoad.setExpectedRelays(emptySet())
|
||||
return null
|
||||
}
|
||||
val joined = key.account.relayGroupList.liveRelayGroupList.value
|
||||
if (joined.isEmpty()) {
|
||||
windowLoad.setExpectedRelays(emptySet())
|
||||
return null
|
||||
}
|
||||
// The preload only mounts keys while the toggle is on; re-checked here so a flip to off
|
||||
// empties any subscription that outlives the recomposition.
|
||||
if (!key.account.settings.isChatFeedEnabled(ChatFeedType.NIP29)) return null
|
||||
|
||||
// One #h filter per host relay carrying every joined group id on it; bounded by the shared
|
||||
// recent-tail floor, so no per-group limit and no per-group re-subscribe on join.
|
||||
val filters = buildRelayGroupJoinedChatTailFilters(joined, DmHistoryTuning.recentBoundary())
|
||||
windowLoad.setExpectedRelays(filters.mapTo(mutableSetOf()) { it.relay })
|
||||
return filters
|
||||
val relay = key.groupId.relayUrl
|
||||
// The tracker is shared by every key of this assembler, so it must describe the whole joined
|
||||
// fleet rather than whichever channel happened to rebuild last — otherwise each key would
|
||||
// overwrite the expected set with its own single relay. [loadingMore] is what the Messages rows
|
||||
// read to tell "still fetching this channel's newest message" apart from "channel is empty".
|
||||
windowLoad.setExpectedRelays(
|
||||
key.account.relayGroupList.liveRelayGroupList.value
|
||||
.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it.relayUrl) },
|
||||
)
|
||||
|
||||
// Two filters, both `#h`-scoped to THIS group, so the subscription still resolves to a single
|
||||
// channel on buzz (its resolver only bails when two *distinct* ids appear, or when some filter
|
||||
// carries no channel tag at all). Each is queried independently, so they keep their own window:
|
||||
// - the chat tail: every timeline kind, time-floored, no limit
|
||||
// - group activity addressed to me: reactions/zaps/reports/replies (kinds the tail doesn't ask
|
||||
// for), `#p` = me + `limit`, `since` from EOSE so a cold start is all-time and a reconnect is
|
||||
// cheap. This used to ride the account-wide notifications subscription, which also carries
|
||||
// inbox filters with no `#h` — that alone forces the whole subscription global on buzz, so
|
||||
// live group reactions never arrived until the next launch.
|
||||
return listOf(
|
||||
buildRelayGroupJoinedChatTailFilter(key.groupId, DmHistoryTuning.recentBoundary()),
|
||||
// The row's newest message at ANY age. The tail above floors at 7 days to keep recent chat
|
||||
// warm, which on its own strands a channel quiet for longer than that on a placeholder row.
|
||||
buildRelayGroupPreviewFilter(key.groupId, since?.get(relay)?.time),
|
||||
// Reactions/deletions for every message in this channel — the shape Buzz's own client uses.
|
||||
buildRelayGroupAuxFilter(key.groupId, DmHistoryTuning.recentBoundary()),
|
||||
) +
|
||||
filterGroupNotificationsToPubkey(
|
||||
relay = relay,
|
||||
pubkey = key.account.userProfile().pubkeyHex,
|
||||
groupIds = listOf(key.groupId.id),
|
||||
since = since?.get(relay)?.time,
|
||||
)
|
||||
}
|
||||
|
||||
override fun id(key: RelayGroupJoinedChatTailQueryState) = key.account
|
||||
|
||||
private val toggleJobs = mutableMapOf<Account, Job>()
|
||||
override fun id(key: RelayGroupJoinedChatTailQueryState) = key.groupId
|
||||
|
||||
override fun newSub(key: RelayGroupJoinedChatTailQueryState): Subscription {
|
||||
windowLoad.startLoading(key.account.scope)
|
||||
toggleJobs.remove(key.account)?.cancel()
|
||||
toggleJobs[key.account] =
|
||||
key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.NIP29) { invalidateFilters() }
|
||||
return requestNewSubscription(
|
||||
windowLoad.trackingListener { relay: NormalizedRelayUrl, filters -> newEose(key, relay, TimeUtils.now(), filters) },
|
||||
)
|
||||
}
|
||||
|
||||
override fun endSub(
|
||||
key: Account,
|
||||
subId: String,
|
||||
) {
|
||||
super.endSub(key, subId)
|
||||
toggleJobs.remove(key)?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -76,7 +76,12 @@ class RelayGroupOpenChatTailSubAssembler(
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
windowLoad.setExpectedRelays(setOf(key.groupId.relayUrl))
|
||||
return listOf(buildRelayGroupOpenChatTailFilter(key.groupId, DmHistoryTuning.recentBoundary()))
|
||||
return listOf(
|
||||
buildRelayGroupOpenChatTailFilter(key.groupId, DmHistoryTuning.recentBoundary()),
|
||||
// Reactions/deletions for every message on screen, `#h`-scoped rather than `#e`-scoped —
|
||||
// see [RELAY_GROUP_AUX_KINDS]. Covers a non-joined group opened by link too.
|
||||
buildRelayGroupAuxFilter(key.groupId, DmHistoryTuning.recentBoundary()),
|
||||
)
|
||||
}
|
||||
|
||||
override fun id(key: RelayGroupOpenChatTailQueryState) = key.groupId
|
||||
|
||||
+15
-5
@@ -456,11 +456,21 @@ private fun RelayGroupRoomCompose(
|
||||
// A Buzz timeline row (system line, huddle/job activity, diff) carries JSON/diff in its
|
||||
// content, so show its human-readable summary — the same text the in-chat row renders —
|
||||
// rather than "author: {json}". Plain chat messages fall through to the usual framing.
|
||||
buzzTimelinePreviewSummary(noteEvent) ?: "$authorName: ${noteEvent.content.take(200)}"
|
||||
buzzTimelinePreviewSummary(noteEvent, accountViewModel) ?: "$authorName: ${noteEvent.content.take(200)}"
|
||||
} else {
|
||||
// Event-less placeholder row for a just-joined group with no messages yet — say so
|
||||
// explicitly (like Marmot groups) instead of an empty second line.
|
||||
stringRes(R.string.relay_group_no_messages_yet)
|
||||
// Event-less placeholder row. Until the channel's `limit = 1` preview REQ settles we cannot
|
||||
// tell an empty channel from one whose newest message simply hasn't arrived, and claiming
|
||||
// "No messages yet" for a busy channel reads as a bug — so say "Loading" while the joined
|
||||
// fleet is still fetching, and commit to the empty wording only once it has.
|
||||
val stillFetching by accountViewModel
|
||||
.dataSources()
|
||||
.relayGroupJoinedChatTail.tail.loadingMore
|
||||
.collectAsStateWithLifecycle()
|
||||
if (stillFetching) {
|
||||
stringRes(R.string.loading_feed)
|
||||
} else {
|
||||
stringRes(R.string.relay_group_no_messages_yet)
|
||||
}
|
||||
}
|
||||
|
||||
val groupPicture = channel.profilePicture()?.ifBlank { null }
|
||||
@@ -594,7 +604,7 @@ private fun RelayGroupServerRoomCompose(
|
||||
val authorName by observeUserName(author, accountViewModel)
|
||||
// Buzz timeline rows (system/huddle/job/diff) carry JSON/diff content — summarize them
|
||||
// like the in-chat row instead of printing raw payload; plain chat falls through.
|
||||
buzzTimelinePreviewSummary(noteEvent) ?: "$authorName: ${noteEvent.content.take(200)}"
|
||||
buzzTimelinePreviewSummary(noteEvent, accountViewModel) ?: "$authorName: ${noteEvent.content.take(200)}"
|
||||
} else {
|
||||
stringRes(R.string.relay_group_no_messages_yet)
|
||||
}
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.chats.rooms
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.unreadPrivateChatRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordCommunityHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelLastReadRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote
|
||||
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Whether one Messages row is showing a blue dot — the SAME question each row composable answers for
|
||||
* itself in [ChatroomHeaderCompose], hoisted so the bottom-bar badge can ask it about every row.
|
||||
*
|
||||
* The badge used to run on `unreadPrivateChatRoute` alone, which opens with
|
||||
* `if (newestMessage !is ChatroomKeyable) return null`. Only NIP-17/NIP-04 DM events implement that
|
||||
* interface, so eight of the nine row types — public chats, ephemeral rooms, geohash cells, Marmot
|
||||
* groups, NIP-29/Buzz channels and Concord channels, plus both collapsed "grouped" rows — were
|
||||
* silently skipped: their row could show a dot while the envelope stayed clean.
|
||||
*
|
||||
* Returns null when the row cannot be unread at all (no event, my own newest message in a DM, everyone
|
||||
* hidden), so callers can skip it rather than subscribe to a flow that is always false.
|
||||
*
|
||||
* The two collapsed rows are why this returns a `Flow<Boolean>` rather than a `(route, createdAt)`
|
||||
* pair: their dot is a fan-in over every child channel, not one timestamp against one marker, and
|
||||
* approximating them by the newest child would miss an older channel that is still unread.
|
||||
*/
|
||||
fun rowHasUnreadFlow(
|
||||
row: Note,
|
||||
account: Account,
|
||||
): Flow<Boolean>? {
|
||||
// Collapsed rows own a fan-in flow across their children — reuse the row's own signal verbatim.
|
||||
if (row is RelayGroupServerRoomNote) return relayGroupServerHasUnreadFlow(account, row.relay)
|
||||
if (row is ConcordServerRoomNote) return concordCommunityHasUnreadFlow(account, row.communityId)
|
||||
|
||||
val route = rowLastReadRoute(row, account) ?: return null
|
||||
val createdAt = row.createdAt() ?: return null
|
||||
return account.settings.getLastReadFlow(route).map { lastReadAt -> createdAt > lastReadAt }
|
||||
}
|
||||
|
||||
/**
|
||||
* The last-read marker route behind a row's dot, mirroring what each row composable loads. Channel-type
|
||||
* rows are identified by their gatherer (the channel the note was filed into) rather than by event kind,
|
||||
* because a Buzz channel and a Concord channel can both carry a kind-9 message.
|
||||
*/
|
||||
private fun rowLastReadRoute(
|
||||
row: Note,
|
||||
account: Account,
|
||||
): String? {
|
||||
row.inGatherers?.forEach { gatherer ->
|
||||
when (gatherer) {
|
||||
is RelayGroupChannel -> return relayGroupChannelLastReadRoute(gatherer.groupId)
|
||||
is ConcordChannel -> return concordChannelLastReadRoute(gatherer.channelId.communityId, gatherer.channelId.channelId)
|
||||
is MarmotGroupChatroom -> return marmotGroupLastReadRoute(gatherer.nostrGroupId)
|
||||
is GeohashChatChannel -> return "Geohash/${gatherer.geohash}"
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
return when (val event = row.event) {
|
||||
// Same route strings the row composables use — see ChatroomHeaderCompose.
|
||||
is ChannelMessageEvent -> event.channelId()?.let { "Channel/$it" }
|
||||
is EphemeralChatEvent -> event.roomId()?.let { "Channel/${it.toKey()}" }
|
||||
is GeohashChatEvent -> event.geohash()?.let { "Geohash/$it" }
|
||||
// DMs keep their own rule: a room whose newest message is mine counts as read.
|
||||
else -> unreadPrivateChatRoute(row.event, account.signer.pubKey, account::isAllHidden)?.first
|
||||
}
|
||||
}
|
||||
+7
-13
@@ -33,6 +33,8 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.isConcordTimelineMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.isRelayGroupTimelineMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.newestTimelineNote
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
@@ -157,7 +159,7 @@ class ChatroomListKnownFeedFilter(
|
||||
// Newest loaded chat message, or a placeholder row so a just-joined group shows
|
||||
// up on Messages before its first kind-9 arrives (mirrors the Marmot-group path
|
||||
// above). Content kinds only — never a reaction/deletion as the "last message".
|
||||
channel.newestChatNote(account) ?: channel.placeholderNote()
|
||||
channel.newestTimelineNote(account) ?: channel.placeholderNote()
|
||||
}
|
||||
|
||||
RelayGroupViewMode.GROUPED ->
|
||||
@@ -169,7 +171,7 @@ class ChatroomListKnownFeedFilter(
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null
|
||||
val newest =
|
||||
tags
|
||||
.mapNotNull { LocalCache.getOrCreateRelayGroupChannel(GroupId(it.groupId, relay)).newestChatNote(account) }
|
||||
.mapNotNull { LocalCache.getOrCreateRelayGroupChannel(GroupId(it.groupId, relay)).newestTimelineNote(account) }
|
||||
.maxByOrNull { it.createdAt() ?: 0L }
|
||||
RelayGroupServerRoomNote(relay, newest)
|
||||
}
|
||||
@@ -489,13 +491,6 @@ class ChatroomListKnownFeedFilter(
|
||||
return newRelevantEphemeralChats
|
||||
}
|
||||
|
||||
/** The newest actual chat message loaded in this group's channel, or null if none yet. */
|
||||
private fun RelayGroupChannel.newestChatNote(account: Account): Note? =
|
||||
notes
|
||||
.filter { _, it -> account.isAcceptable(it) && it.event?.isGroupChatContent() == true }
|
||||
.sortedByDefaultFeedOrder()
|
||||
.firstOrNull()
|
||||
|
||||
/**
|
||||
* The newest decrypted *timeline* message loaded in this Concord channel, or null if none yet.
|
||||
* Uses [isConcordTimelineMessage] so a trailing kind-1111 thread reply (or a hidden author)
|
||||
@@ -540,8 +535,8 @@ class ChatroomListKnownFeedFilter(
|
||||
val joinedGroupIds = joined.mapTo(HashSet()) { it.groupId }
|
||||
val result = mutableMapOf<String, Note>()
|
||||
newItems.forEach { newNote ->
|
||||
val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId()
|
||||
if (gid != null && gid in joinedGroupIds && account.isAcceptable(newNote)) {
|
||||
val gid = newNote.event?.takeIf { isRelayGroupTimelineMessage(newNote, account) }?.groupId()
|
||||
if (gid != null && gid in joinedGroupIds) {
|
||||
val lastNote = result[gid]
|
||||
if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) {
|
||||
result[gid] = newNote
|
||||
@@ -559,9 +554,8 @@ class ChatroomListKnownFeedFilter(
|
||||
// Newest new message per host relay, collapsed into one per-relay row.
|
||||
val newestPerRelay = HashMap<NormalizedRelayUrl, Note>()
|
||||
newItems.forEach { newNote ->
|
||||
val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId() ?: return@forEach
|
||||
val gid = newNote.event?.takeIf { isRelayGroupTimelineMessage(newNote, account) }?.groupId() ?: return@forEach
|
||||
val relay = groupToRelay[gid] ?: return@forEach
|
||||
if (!account.isAcceptable(newNote)) return@forEach
|
||||
val lastNote = newestPerRelay[relay]
|
||||
if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) {
|
||||
newestPerRelay[relay] = newNote
|
||||
|
||||
+22
-6
@@ -21,8 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -85,6 +87,13 @@ fun ChatroomListFeedView(
|
||||
scrollStateKey: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
/**
|
||||
* Pinned above the rows. Rendered INSIDE the feed rather than beside it so it inherits
|
||||
* [rememberFeedContentPadding] — the collapsing top bar draws over this area, and a header placed
|
||||
* outside the list lands underneath it. Shown in every state, so a standing prompt is still
|
||||
* reachable when the list itself is empty.
|
||||
*/
|
||||
headerContent: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
DisposableEffect(Unit) {
|
||||
Log.d("DMPagination") { "rooms.list: OPEN" }
|
||||
@@ -92,7 +101,7 @@ fun ChatroomListFeedView(
|
||||
}
|
||||
RefresheableBox(feedContentState, true) {
|
||||
SaveableFeedContentState(feedContentState, scrollStateKey) { listState ->
|
||||
CrossFadeState(feedContentState, listState, accountViewModel, nav)
|
||||
CrossFadeState(feedContentState, listState, accountViewModel, nav, headerContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +112,7 @@ private fun CrossFadeState(
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
headerContent: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val feedState by feedContentState.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -131,10 +141,13 @@ private fun CrossFadeState(
|
||||
) { state ->
|
||||
when (state) {
|
||||
is FeedState.Empty -> {
|
||||
if (historyExhausted) {
|
||||
FeedEmpty { feedContentState.invalidateData() }
|
||||
} else {
|
||||
LoadingFeed()
|
||||
Column(Modifier.padding(rememberFeedContentPadding(FeedPadding))) {
|
||||
headerContent?.invoke()
|
||||
if (historyExhausted) {
|
||||
FeedEmpty { feedContentState.invalidateData() }
|
||||
} else {
|
||||
LoadingFeed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +156,7 @@ private fun CrossFadeState(
|
||||
}
|
||||
|
||||
is FeedState.Loaded -> {
|
||||
FeedLoaded(state, listState, accountViewModel, nav)
|
||||
FeedLoaded(state, listState, accountViewModel, nav, headerContent)
|
||||
}
|
||||
|
||||
FeedState.Loading -> {
|
||||
@@ -159,6 +172,7 @@ private fun FeedLoaded(
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
headerContent: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -214,6 +228,8 @@ private fun FeedLoaded(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
) {
|
||||
headerContent?.let { item("chatroom-list-header") { it() } }
|
||||
|
||||
itemsIndexed(
|
||||
items.list,
|
||||
key = { _, item -> chatroomLazyKey(item, myPubKey) },
|
||||
|
||||
+12
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ChannelInvitesSection
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
|
||||
@@ -131,6 +132,17 @@ fun MessagesPager(
|
||||
scrollStateKey = tabs[page].scrollStateKey,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
// Channels somebody added you to are pending decisions, exactly like an unaccepted DM — so
|
||||
// they belong on New Requests, pinned above the rows. Passed as the feed's header rather
|
||||
// than stacked beside it: the collapsing top bar draws over this area, so a header outside
|
||||
// the list renders underneath it. The Notifications tab shows the same prompts from the
|
||||
// same state holder, so the two surfaces cannot disagree.
|
||||
headerContent =
|
||||
if (tabs[page].resource == R.string.new_requests) {
|
||||
{ ChannelInvitesSection(accountViewModel) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
/**
|
||||
* Per-room state for every wire subscription scoped to a single
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.notifications
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/**
|
||||
* "Somebody added you to a channel" prompts, rendered above the Notifications feed (the same header slot
|
||||
* the missing-inbox-relay prompt uses) and inside Messages › New Requests.
|
||||
*
|
||||
* These are deliberately NOT auto-accepted. On a Buzz relay another member can add you to a channel
|
||||
* server-side: the relay writes you into the kind-39002 roster and you can immediately read and post,
|
||||
* without you ever agreeing to see it. Amethyst used to silently subscribe to those channels' messages
|
||||
* while showing no row for them anywhere, so a channel could be joined, streaming, and invisible at once.
|
||||
* Now the relay's decision is surfaced as a question instead of being acted on.
|
||||
*/
|
||||
@Composable
|
||||
fun ChannelInvitesSection(
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val invites by accountViewModel.feedStates.channelInvites.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
if (invites.isEmpty()) return
|
||||
|
||||
Column(modifier) {
|
||||
invites.forEach { invite ->
|
||||
ChannelInviteCard(invite, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChannelInviteCard(
|
||||
invite: BuzzChannelInvite,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val channel = remember(invite.channelId, invite.relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay)) }
|
||||
|
||||
val actorUser = remember(invite.actor) { invite.actor?.let { LocalCache.getOrCreateUser(it) } }
|
||||
val actorName = actorUser?.let { observeUserName(it, accountViewModel).value }
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
// Who did it matters: the relay reports a self-join with the same event, so naming the
|
||||
// actor is what tells "I joined this" apart from "a stranger put me here".
|
||||
text =
|
||||
stringRes(
|
||||
R.string.channel_invite_body,
|
||||
actorName ?: stringRes(R.string.channel_invite_unknown_actor),
|
||||
invite.relay.displayUrl(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
) {
|
||||
// Leave is separate from Ignore on purpose: Ignore is a local display choice that leaves
|
||||
// you in the roster, Leave is the kind-9022 that actually removes you from the channel.
|
||||
TextButton(onClick = { accountViewModel.leaveChannelInvite(channel) }) {
|
||||
Text(stringRes(R.string.channel_invite_leave), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
TextButton(onClick = { accountViewModel.dismissChannelInvite(invite.channelId) }) {
|
||||
Text(stringRes(R.string.channel_invite_ignore))
|
||||
}
|
||||
TextButton(onClick = { accountViewModel.acceptChannelInvite(channel) }) {
|
||||
Text(stringRes(R.string.channel_invite_accept), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.notifications
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* The channels somebody else added the viewer to that are still awaiting a decision.
|
||||
*
|
||||
* An entry drops out the moment it stops being a question: accepting writes the group into kind-10009
|
||||
* (so `joined` covers it and the ordinary Messages row takes over), dismissing records the channel in
|
||||
* `dismissedChannelInvites`, and leaving makes the relay withdraw the membership. Nothing here asserts
|
||||
* membership — the relay already granted that — it only tracks whose call it is to surface the channel.
|
||||
*
|
||||
* Modelled on [OpenPollsState]: a small always-on projection the Notifications screen and the Messages
|
||||
* "New Requests" tab both render, so the two surfaces can never disagree about what is pending.
|
||||
*/
|
||||
@Stable
|
||||
class ChannelInvitesState(
|
||||
private val account: Account,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
val flow: StateFlow<List<BuzzChannelInvite>> =
|
||||
combine(
|
||||
BuzzChannelInvites.flow.map { it[account.userProfile().pubkeyHex] ?: emptyMap() },
|
||||
account.settings.dismissedChannelInvites,
|
||||
account.relayGroupList.liveRelayGroupList,
|
||||
) { invites, dismissed, joined ->
|
||||
val joinedIds = joined.mapTo(HashSet()) { it.groupId }
|
||||
invites.values
|
||||
.filter { it.channelId !in dismissed && it.channelId !in joinedIds }
|
||||
.sortedByDescending { it.createdAt }
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
}
|
||||
+6
-1
@@ -242,7 +242,12 @@ internal fun SingleNotificationsBody(
|
||||
nav = nav,
|
||||
routeForLastRead = NOTIFICATION_LAST_READ_KEY,
|
||||
scrollToEventId = scrollToEventId,
|
||||
headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) },
|
||||
headerContent = {
|
||||
ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav)
|
||||
// "X added you to #channel" prompts sit above the feed rather than inside it: they are a
|
||||
// standing decision, not a dated event, so they must not scroll away into history.
|
||||
ChannelInvitesSection(accountViewModel)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -40,8 +40,8 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
+48
-7
@@ -35,6 +35,8 @@ import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalRequestedEvent
|
||||
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
|
||||
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
|
||||
@@ -87,11 +89,11 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@@ -151,10 +153,16 @@ class NotificationFeedFilter(
|
||||
// filterGroupNotificationsToPubkey.
|
||||
// • NIP-C7 / Concord: an inline reply (Concord's default reply mode) or an @-mention
|
||||
// p-tags me; a minichat reply is a kind-1111 CommentEvent below.
|
||||
// Either way it notifies only when it p-tags me — a plain channel message tags no one
|
||||
// and never reaches here. Without kind 9 the acceptableEvent kind gate would drop these
|
||||
// replies before the p-tag check, so they'd never render on the Notifications tab.
|
||||
// • Buzz channels: a THREAD reply carries no `p` tag at all (Buzz only p-tags
|
||||
// @mentions), so it qualifies through [isBuzzThreadReplyToMyEvent] instead, which
|
||||
// resolves the author of its `root`/`reply` marked `e` tags.
|
||||
// A plain channel message tags no one and matches none of those, so it never reaches
|
||||
// here. Without kind 9 the acceptableEvent kind gate would drop these replies before
|
||||
// any of the checks, so they'd never render on the Notifications tab.
|
||||
ChatEvent.KIND,
|
||||
// Legacy Buzz thread replies (nothing writes 40002 any more, but they exist in the
|
||||
// wild). Same no-`p`-tag shape as kind 9 above.
|
||||
StreamMessageV2Event.KIND,
|
||||
ChatMessageEvent.KIND,
|
||||
ChatMessageEncryptedFileHeaderEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
@@ -420,6 +428,36 @@ class NotificationFeedFilter(
|
||||
?.pubkeyHex == me
|
||||
}
|
||||
|
||||
/**
|
||||
* A Buzz chat **thread reply** into one of my messages, when the reply carries no `p` tag.
|
||||
*
|
||||
* Buzz's clients thread with `["e", <id>, "", "reply"]` (nested: `root` + `reply`) and only ever
|
||||
* `p`-tag @mentions, so a reply to my message names me nowhere. That is the same shape as a Buzz
|
||||
* reaction, which [isReactionToMyEvent] already rescues by resolving the target's author instead of
|
||||
* trusting a tag — this does the same for replies, looking at the author of the `root`/`reply`
|
||||
* targets specifically (never a bare `e`, which is WhiteNoise/Marmot's in-chat reply, not a thread).
|
||||
*
|
||||
* Without it a reply to my message in a Buzz channel notifies nothing — and since a thread reply is
|
||||
* deliberately kept out of the channel timeline and its unread dot
|
||||
* ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.isRelayGroupTimelineMessage]),
|
||||
* it would be invisible on every surface.
|
||||
*
|
||||
* Only consulted when there is no `p` tag: a reply that does name me already passes the p-tag gate.
|
||||
*/
|
||||
private fun isBuzzThreadReplyToMyEvent(
|
||||
note: Note,
|
||||
me: HexKey,
|
||||
): Boolean {
|
||||
val event = note.event
|
||||
if (event !is ChatEvent && event !is StreamMessageV2Event) return false
|
||||
if (event.tags.any { it.getOrNull(0) == "p" }) return false
|
||||
|
||||
val threadTargets = setOfNotNull(event.tags.buzzThreadReply(), event.tags.buzzThreadRoot())
|
||||
if (threadTargets.isEmpty()) return false
|
||||
|
||||
return note.replyTo?.any { it.idHex in threadTargets && it.author?.pubkeyHex == me } == true
|
||||
}
|
||||
|
||||
fun acceptableEvent(
|
||||
it: Note,
|
||||
filterParams: FilterByListParams,
|
||||
@@ -555,6 +593,9 @@ class NotificationFeedFilter(
|
||||
// exactly like a Concord reaction, since being the author of the liked post is the only signal.
|
||||
val isReactionToMe = isReactionToMyEvent(it, loggedInUserHex)
|
||||
|
||||
// Same no-`p`-tag rescue for Buzz thread replies into my messages.
|
||||
val isThreadReplyToMe = isBuzzThreadReplyToMyEvent(it, loggedInUserHex)
|
||||
|
||||
// Concord CHAT (a message/reply) honors the "Messages in notifications" toggle that silences DMs
|
||||
// and Marmot groups above. A reaction isn't a message — regular reactions ignore that toggle, so
|
||||
// Concord reactions do too (only isConcordMessage is gated).
|
||||
@@ -573,14 +614,14 @@ class NotificationFeedFilter(
|
||||
// to genuine replies, so unrelated channel chatter never leaks through.
|
||||
return noteEvent?.kind in NOTIFICATION_KINDS &&
|
||||
(noteEvent is LnZapEvent || noteEvent is Bolt12ZapEvent || notifAuthor != loggedInUserHex) &&
|
||||
(isChessEvent || isConcord || isReactionToMe || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
|
||||
(noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex) || isReactionToMe) &&
|
||||
(isChessEvent || isConcord || isReactionToMe || isThreadReplyToMe || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
|
||||
(noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex) || isReactionToMe || isThreadReplyToMe) &&
|
||||
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
|
||||
(noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) &&
|
||||
// For a Concord note the explicit p-tag above IS the relevance signal (the reply/reaction/
|
||||
// mention targets me directly), so skip the per-kind heuristic — which for a reaction would
|
||||
// otherwise need my target message already loaded to resolve replyTo.
|
||||
(isRawGlobal || isConcord || tagsAnEventByUser(it, loggedInUserHex))
|
||||
(isRawGlobal || isConcord || isThreadReplyToMe || tagsAnEventByUser(it, loggedInUserHex))
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
|
||||
|
||||
+1
-1
@@ -26,8 +26,8 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND)
|
||||
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
|
||||
+1
-1
@@ -31,8 +31,8 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.BigDecimal
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
|
||||
+1
-1
@@ -163,13 +163,13 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
/** Returns the `@StringRes` id for the translated kind name, or -1 if unknown. */
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
+1
-1
@@ -344,12 +344,12 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
|
||||
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
|
||||
|
||||
@@ -48,12 +48,34 @@ object ArtiGuardState {
|
||||
fun parse(json: String): JsonNode = mapper.readTree(json)
|
||||
|
||||
/**
|
||||
* True when at least one non-empty guard selection has *zero* usable guards —
|
||||
* every guard permanently `disabled` or dropped from the consensus
|
||||
* (`unlisted_since`). This is the AllGuardsDown wedge: Arti can neither build
|
||||
* circuits nor replenish the sample (it's full of unusable entries), so it
|
||||
* stays broken across restarts until the on-disk state is wiped. A single
|
||||
* usable guard is enough to recover, so this only trips on a total wipeout.
|
||||
* Smallest sample worth judging by ratio. Below this a low usable count is just a young
|
||||
* sample that Arti is still filling, and wiping it would loop the bootstrap.
|
||||
*/
|
||||
const val MIN_SAMPLE_TO_JUDGE_RATIO = 10
|
||||
|
||||
/** A sample is wedged when fewer than 1 in [USABLE_RATIO_DIVISOR] of its guards are usable. */
|
||||
const val USABLE_RATIO_DIVISOR = 10
|
||||
|
||||
/**
|
||||
* True when a non-empty guard selection has no usable guards left, or so few that Arti cannot
|
||||
* realistically recover from them — every guard permanently `disabled` or dropped from the
|
||||
* consensus (`unlisted_since`).
|
||||
*
|
||||
* This is the AllGuardsDown wedge: Arti can neither build circuits nor replenish the sample, so
|
||||
* it stays broken across restarts until the on-disk state is wiped.
|
||||
*
|
||||
* ### Why this is not `usable == 0`
|
||||
*
|
||||
* It used to be, on the assumption that "a single usable guard is enough to recover". Observed in
|
||||
* the field: a sample of 60 with **59 disabled and 1 usable**, while Arti rejected all sixty at
|
||||
* runtime (`AllGuardsDown { n_accepted: 0, n_rejected: 60 }`) across repeated app restarts. The
|
||||
* lone survivor was just as unreachable as the rest — useless for recovery, but enough to veto
|
||||
* it, because `usable == 0` never became true. ~87% of relay connections failed indefinitely.
|
||||
*
|
||||
* So the test is proportional: a sample of at least [MIN_SAMPLE_TO_JUDGE_RATIO] whose usable
|
||||
* guards are under 1/[USABLE_RATIO_DIVISOR] of the total is wedged. A small, young sample is
|
||||
* still only judged by the strict `usable == 0` rule, so a legitimate first bootstrap that has
|
||||
* sampled one or two guards is never wiped out from under itself.
|
||||
*/
|
||||
fun hasNoUsableGuards(root: JsonNode): Boolean {
|
||||
var wedged = false
|
||||
@@ -61,7 +83,11 @@ object ArtiGuardState {
|
||||
val guards = selection.get("guards") ?: return@forEach
|
||||
if (guards.isArray && guards.size() > 0) {
|
||||
val usable = guards.count { !it.isDisabled() && !it.isUnlisted() }
|
||||
if (usable == 0) wedged = true
|
||||
if (usable == 0) {
|
||||
wedged = true
|
||||
} else if (guards.size() >= MIN_SAMPLE_TO_JUDGE_RATIO && usable * USABLE_RATIO_DIVISOR < guards.size()) {
|
||||
wedged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return wedged
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.tor
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
@@ -46,4 +47,16 @@ interface TorBackend {
|
||||
* this is a `suspend` call.
|
||||
*/
|
||||
suspend fun hasBootstrappedBefore(): Boolean
|
||||
|
||||
/**
|
||||
* Emits when Arti has reported `AllGuardsDown` persistently enough that the guard sample is
|
||||
* considered rotten at runtime, regardless of what `guards.json` claims.
|
||||
*
|
||||
* The on-disk heuristic ([ArtiGuardState.hasNoUsableGuards]) only sees guards Arti has
|
||||
* permanently retired. A guard that is merely unreachable stays "usable" on disk forever, so a
|
||||
* sample can be entirely dead in practice while still looking healthy to that check — which is
|
||||
* exactly the state that survived repeated restarts in the field. This is the runtime half:
|
||||
* Arti itself says every guard was rejected, so believe it.
|
||||
*/
|
||||
val guardsDownSignal: Flow<Unit>
|
||||
}
|
||||
|
||||
@@ -250,6 +250,24 @@ class TorManager(
|
||||
if (it is TorServiceStatus.Active) hasEverBootstrapped = true
|
||||
}.launchIn(scope)
|
||||
|
||||
// Rotten guard sample while Tor is otherwise UP. The watchdog above only fires on a status
|
||||
// stuck at Connecting, and the on-disk check only sees guards Arti has permanently retired —
|
||||
// so a sample whose guards are all merely *unreachable* falls between them: status reaches
|
||||
// Active, `guards.json` still lists usable entries, and every circuit fails anyway. Observed
|
||||
// in the field surviving repeated restarts with ~87% of relay connections failing. Arti's own
|
||||
// AllGuardsDown log is the only reliable signal, so route it through the same rate-limited
|
||||
// wipe. Always a clean-state reset: the whole point is that the persisted sample is the
|
||||
// problem.
|
||||
service.guardsDownSignal
|
||||
.onEach {
|
||||
val now = nowMs()
|
||||
if (now - lastSelfHealAtMs < SELF_HEAL_COOLDOWN_MS) return@onEach
|
||||
lastSelfHealAtMs = now
|
||||
Log.w("TorManager") { "Arti guard sample rotten at runtime — self-healing (drop client + wipe state)" }
|
||||
service.resetWithCleanState()
|
||||
resetEpoch.update { it + 1 }
|
||||
}.launchIn(scope)
|
||||
|
||||
selfHealSignal
|
||||
.onEach {
|
||||
val now = nowMs()
|
||||
|
||||
@@ -25,8 +25,12 @@ import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -34,6 +38,15 @@ import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/** Arti's log marker for "no usable guard could be found". */
|
||||
private const val ALL_GUARDS_DOWN_MARKER = "AllGuardsDown"
|
||||
|
||||
/** How many [ALL_GUARDS_DOWN_MARKER] lines inside [GUARDS_DOWN_WINDOW_MS] mean the sample is rotten. */
|
||||
private const val GUARDS_DOWN_THRESHOLD = 40
|
||||
|
||||
/** Window for [GUARDS_DOWN_THRESHOLD]. Wide enough that ordinary transient churn never trips it. */
|
||||
private const val GUARDS_DOWN_WINDOW_MS = 60_000L
|
||||
|
||||
private const val DEFAULT_SOCKS_PORT = 17392
|
||||
private const val MAX_PORT_RETRIES = 10
|
||||
|
||||
@@ -85,6 +98,47 @@ class TorService(
|
||||
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
|
||||
override val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
|
||||
|
||||
/**
|
||||
* Runtime detector for a rotten guard sample. Arti logs [ALL_GUARDS_DOWN_MARKER] every time it
|
||||
* fails to find a usable guard; [GUARDS_DOWN_THRESHOLD] of those inside [GUARDS_DOWN_WINDOW_MS]
|
||||
* means every guard in the sample is unreachable *right now* — which the on-disk check cannot
|
||||
* see, because an unreachable guard is not a `disabled` one (see
|
||||
* [ArtiGuardState.hasNoUsableGuards]).
|
||||
*
|
||||
* Counted here because the log callback is the only place Arti surfaces it; acted on by
|
||||
* [TorManager], which owns the reset cadence and its rate limiting. Both counters are only
|
||||
* touched from Arti's log thread.
|
||||
*/
|
||||
private var guardsDownCount = 0
|
||||
|
||||
private var guardsDownWindowStartMs = 0L
|
||||
|
||||
private val _guardsDownSignal =
|
||||
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
override val guardsDownSignal: Flow<Unit> = _guardsDownSignal.asSharedFlow()
|
||||
|
||||
/**
|
||||
* Feeds one Arti log line to the [guardsDownSignal] detector. Cheap by design: this runs for
|
||||
* every log line, and a wedged Arti emits tens of thousands of them.
|
||||
*/
|
||||
private fun trackGuardFailures(text: String) {
|
||||
if (!text.contains(ALL_GUARDS_DOWN_MARKER)) return
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - guardsDownWindowStartMs > GUARDS_DOWN_WINDOW_MS) {
|
||||
guardsDownWindowStartMs = now
|
||||
guardsDownCount = 0
|
||||
}
|
||||
guardsDownCount++
|
||||
if (guardsDownCount >= GUARDS_DOWN_THRESHOLD) {
|
||||
guardsDownCount = 0
|
||||
guardsDownWindowStartMs = now
|
||||
Log.w("TorService") { "Arti reported $ALL_GUARDS_DOWN_MARKER $GUARDS_DOWN_THRESHOLD times in ${GUARDS_DOWN_WINDOW_MS}ms — guard sample is rotten at runtime" }
|
||||
_guardsDownSignal.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun artiDataDir() = File(context.filesDir, "arti")
|
||||
|
||||
/** Diagnostic: total bytes of the consensus/descriptor cache, to correlate with bootstrap time. */
|
||||
@@ -181,6 +235,7 @@ class TorService(
|
||||
// (gated on proxyRunning) intermittently dropped the transition. start()
|
||||
// now sets Active deterministically once the proxy is bound.
|
||||
ArtiNative.setLogCallback { text ->
|
||||
trackGuardFailures(text)
|
||||
Log.d("TorService") {
|
||||
val newLine = text.indexOf('\n')
|
||||
if (newLine > 1) {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="chat_preview_decrypting">Dešifrování…</string>
|
||||
<!-- Prefix on a Messages-list preview when the newest message was sent by the logged-in user. %1$s is the message text -->
|
||||
<string name="chat_preview_you_prefix">Vy: %1$s</string>
|
||||
<string name="group_picture">Obrázek skupiny</string>
|
||||
<string name="explicit_content">Explicitní obsah</string>
|
||||
<string name="relay_notice">Oznámení relé</string>
|
||||
@@ -354,6 +355,18 @@
|
||||
<item quantity="many">%1$d kanálu</item>
|
||||
<item quantity="other">%1$d kanálů</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_workspaces_count">
|
||||
<item quantity="one">%1$d pracovní prostor</item>
|
||||
<item quantity="few">%1$d pracovní prostory</item>
|
||||
<item quantity="many">%1$d pracovního prostoru</item>
|
||||
<item quantity="other">%1$d pracovních prostorů</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_workspaces_channel_count">
|
||||
<item quantity="one">%1$d kanál</item>
|
||||
<item quantity="few">%1$d kanály</item>
|
||||
<item quantity="many">%1$d kanálu</item>
|
||||
<item quantity="other">%1$d kanálů</item>
|
||||
</plurals>
|
||||
<plurals name="concord_member_count">
|
||||
<item quantity="one">%1$d člen</item>
|
||||
<item quantity="few">%1$d členové</item>
|
||||
@@ -414,6 +427,7 @@
|
||||
<!-- Title of the minichat (thread) screen opened from a chat message. -->
|
||||
<string name="chat_minichat_title">Vlákno</string>
|
||||
<!-- Title of the picked-image confirmation dialog when attaching a picture to a thread reply. -->
|
||||
<string name="chat_send_image_title">Odeslat obrázek</string>
|
||||
<!-- Chip on a chat message that opens its thread ("minichat") of kind-1111 replies. -->
|
||||
<plurals name="chat_minichat_reply_count">
|
||||
<item quantity="one">%1$d odpověď</item>
|
||||
@@ -1308,6 +1322,7 @@
|
||||
<string name="nest_create_submit">Začít prostor</string>
|
||||
<string name="nest_create_schedule_toggle">Naplánovat na později</string>
|
||||
<string name="nest_create_when">Vyberte čas začátku</string>
|
||||
<string name="agent_console_title">Konzole agenta</string>
|
||||
<string name="nests_servers_title">Nest servery</string>
|
||||
<string name="nests_servers_explainer">Vyberte, na které MoQ hostitelské servery Amethyst publikuje vaše nesty. První položka se použije jako výchozí při vytváření nového prostoru.</string>
|
||||
<string name="nests_servers_my_section">Vaše servery</string>
|
||||
@@ -1658,6 +1673,10 @@
|
||||
<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_stored_on">Uloženo na</string>
|
||||
<string name="blossom_file_details">Podrobnosti souboru</string>
|
||||
<string name="blossom_on_all_servers">Na všech serverech</string>
|
||||
<string name="blossom_not_on_all_servers">Není na všech serverech</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>
|
||||
@@ -1677,6 +1696,39 @@
|
||||
<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="blossom_more_actions">Další akce</string>
|
||||
<string name="blossom_import_menu">Importovat soubory…</string>
|
||||
<string name="blossom_import_title">Importovat soubory</string>
|
||||
<string name="blossom_import_intro">Prohledejte ostatní Blossom servery a najděte soubory, které jste nahráli jinde, a zkopírujte je na své vlastní servery.</string>
|
||||
<string name="blossom_import_sources_section">Servery k prohledání</string>
|
||||
<string name="blossom_import_enable_all">Zapnout vše</string>
|
||||
<string name="blossom_import_disable_all">Vypnout vše</string>
|
||||
<string name="blossom_import_add_url_label">Nebo vložte adresu serveru</string>
|
||||
<string name="blossom_import_scan">Prohledat servery</string>
|
||||
<string name="blossom_import_scanning">Prohledávání…</string>
|
||||
<string name="blossom_import_source_failed">Tento server je nedostupný</string>
|
||||
<string name="blossom_import_none_found">Na vybraných serverech nebyly nalezeny žádné nové soubory.</string>
|
||||
<string name="blossom_import_no_targets">Nejprve přidejte vlastní Blossom servery, aby bylo kam importované soubory zkopírovat.</string>
|
||||
<string name="blossom_import_manage_servers">Spravovat mé servery</string>
|
||||
<string name="blossom_import_busy">Synchronizace souborů už probíhá. Zkuste import znovu, až skončí.</string>
|
||||
<plurals name="blossom_import_files_found">
|
||||
<item quantity="one">%1$d soubor</item>
|
||||
<item quantity="few">%1$d soubory</item>
|
||||
<item quantity="many">%1$d souboru</item>
|
||||
<item quantity="other">%1$d souborů</item>
|
||||
</plurals>
|
||||
<plurals name="blossom_import_found_files">
|
||||
<item quantity="one">Nalezen %1$d soubor k importu na vaše servery.</item>
|
||||
<item quantity="few">Nalezeny %1$d soubory k importu na vaše servery.</item>
|
||||
<item quantity="many">Nalezeno %1$d souboru k importu na vaše servery.</item>
|
||||
<item quantity="other">Nalezeno %1$d souborů k importu na vaše servery.</item>
|
||||
</plurals>
|
||||
<plurals name="blossom_import_start_button">
|
||||
<item quantity="one">Importovat %1$d soubor</item>
|
||||
<item quantity="few">Importovat %1$d soubory</item>
|
||||
<item quantity="many">Importovat %1$d souboru</item>
|
||||
<item quantity="other">Importovat %1$d souborů</item>
|
||||
</plurals>
|
||||
<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>
|
||||
@@ -1705,6 +1757,20 @@
|
||||
<string name="no_payment_app_found">Nebyla nalezena žádná aplikace pro zpracování této platby. Nainstalujte prosím kompatibilní peněženku.</string>
|
||||
<string name="no_payment_app_found_for_type">Není nainstalována žádná aplikace pro platby typu %1$s. Nainstalujte prosím kompatibilní peněženku.</string>
|
||||
<string name="error_dialog_payment_error">Nelze otevřít platbu</string>
|
||||
<string name="bolt12_offers">Nabídky BOLT12</string>
|
||||
<string name="bolt12_offers_explainer">Zveřejněte znovupoužitelné nabídky BOLT12, aby vám ostatní mohli zapovat přes Lightning bez samostatné faktury pokaždé.</string>
|
||||
<string name="bolt12_offers_section_explainer">Přidejte jednu nebo více nabídek BOLT12 (lno1…). Jsou sdíleny veřejně, takže vám může kdokoli zaplatit.</string>
|
||||
<string name="no_bolt12_offers_message">Nejsou nastaveny žádné nabídky BOLT12. Přidejte jednu níže ↓</string>
|
||||
<string name="bolt12_offer">Nabídka BOLT12 (lno1…)</string>
|
||||
<string name="invalid_bolt12_offer">Neplatná nabídka BOLT12</string>
|
||||
<string name="delete_bolt12_offer">Smazat nabídku BOLT12</string>
|
||||
<string name="bolt12_pay_with_wallet">Zaplatit připojenou peněženkou</string>
|
||||
<string name="bolt12_payment_amount_sats">Částka (sats)</string>
|
||||
<string name="bolt12_payment_sent">Platba BOLT12 odeslána</string>
|
||||
<string name="bolt12_payment_failed">Platba BOLT12 selhala: %1$s</string>
|
||||
<string name="bolt12_zap_error">Chyba zapu BOLT12</string>
|
||||
<string name="bolt12_zap_paid_no_receipt">Zaplaceno přes BOLT12, ale peněženka nevrátila žádný důkaz, takže nebyla zveřejněna žádná zap účtenka.</string>
|
||||
<string name="bolt12_zap_invalid_receipt">Zaplaceno přes BOLT12, ale důkaz o platbě se nepodařilo ověřit, takže nebyla zveřejněna žádná zap účtenka.</string>
|
||||
<string name="uploading_state_ready">Nezačaté</string>
|
||||
<string name="uploading_state_compressing">Kompresuje</string>
|
||||
<string name="uploading_state_uploading">Nahrávání</string>
|
||||
@@ -1811,6 +1877,10 @@
|
||||
<string name="app_notification_zaps_channel_message">%1$s satů</string>
|
||||
<string name="app_notification_zaps_channel_message_from">Od %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">pro %1$s</string>
|
||||
<string name="app_notification_payments_channel_name">Přijaté platby</string>
|
||||
<string name="app_notification_payments_channel_description">Upozorní vás, když vaše připojená peněženka obdrží platbu, která není Nostr zapem</string>
|
||||
<string name="app_notification_payments_channel_message">Přijato %1$s sats</string>
|
||||
<string name="app_notification_payments_summary">Nové platby</string>
|
||||
<string name="app_notification_reply_label">Odpovědět</string>
|
||||
<string name="app_notification_mark_read_label">Označit jako přečtené</string>
|
||||
<string name="app_notification_me">Já</string>
|
||||
@@ -2380,6 +2450,7 @@
|
||||
<string name="relay_group_invite_preparing">Příprava pozvánky…</string>
|
||||
<string name="relay_group_badge_private">Soukromá</string>
|
||||
<string name="relay_group_badge_invite_only">Pouze na pozvání</string>
|
||||
<string name="relay_group_badge_live">ŽIVĚ</string>
|
||||
<string name="relay_group_browse_invalid_url">Zadejte platnou URL relay (wss://…).</string>
|
||||
<string name="relay_group_field_name">Název</string>
|
||||
<string name="relay_group_field_about">Popis</string>
|
||||
@@ -2444,6 +2515,12 @@
|
||||
<string name="relay_group_browse_your_relays">Relaye, na kterých jste</string>
|
||||
<string name="relay_group_browse_popular">Oblíbené relaye</string>
|
||||
<string name="relay_group_no_messages_yet">Zatím žádné zprávy</string>
|
||||
<string name="channel_invite_title">Přidáno do %1$s</string>
|
||||
<string name="channel_invite_body">%1$s vás přidal do tohoto kanálu na %2$s. Zobrazit ho ve Zprávách?</string>
|
||||
<string name="channel_invite_unknown_actor">Někdo</string>
|
||||
<string name="channel_invite_accept">Zobrazit</string>
|
||||
<string name="channel_invite_ignore">Ignorovat</string>
|
||||
<string name="channel_invite_leave">Opustit</string>
|
||||
<string name="relay_group_join_to_post">Připojte se k této skupině pro odesílání zpráv.</string>
|
||||
<string name="relay_group_invite_only_to_post">Tato skupina je pouze na pozvání — k přispívání potřebujete pozvánku.</string>
|
||||
<plurals name="relay_group_member_count">
|
||||
@@ -2470,6 +2547,12 @@
|
||||
<item quantity="many">%1$d osoby, které sledujete</item>
|
||||
<item quantity="other">%1$d osob, které sledujete</item>
|
||||
</plurals>
|
||||
<plurals name="relay_group_relay_group_count">
|
||||
<item quantity="one">%1$d skupina</item>
|
||||
<item quantity="few">%1$d skupiny</item>
|
||||
<item quantity="many">%1$d skupiny</item>
|
||||
<item quantity="other">%1$d skupin</item>
|
||||
</plurals>
|
||||
<string name="messages_new_message">Soukromé</string>
|
||||
<string name="messages_new_message_to">Pro</string>
|
||||
<string name="messages_new_message_subject">Předmět</string>
|
||||
@@ -3174,9 +3257,89 @@
|
||||
<string name="chat_system_created_channel">%1$s vytvořil(a) kanál %2$s</string>
|
||||
<string name="chat_system_created_channel_unnamed">%1$s vytvořil(a) kanál</string>
|
||||
<string name="chat_system_updated_channel">%1$s aktualizoval(a) profil kanálu</string>
|
||||
<string name="buzz_message_edited">(upraveno)</string>
|
||||
<string name="buzz_diff_truncated">(rozdíl zkrácen)</string>
|
||||
<string name="buzz_canvas_title">Plátno</string>
|
||||
<string name="buzz_canvas_empty">V tomto pracovním prostoru zatím nebylo sdíleno žádné plátno.</string>
|
||||
<string name="buzz_canvas_edit">Upravit plátno</string>
|
||||
<string name="buzz_canvas_save">Uložit plátno</string>
|
||||
<string name="buzz_canvas_body_label">Plátno (Markdown)</string>
|
||||
<string name="buzz_edit_message">Upravit</string>
|
||||
<string name="buzz_editing_banner">Úprava zprávy</string>
|
||||
<string name="edit_message">Upravit</string>
|
||||
<string name="message_edited">(upraveno)</string>
|
||||
<string name="concord_editing_banner">Úprava zprávy</string>
|
||||
<string name="buzz_typing_one">%1$s píše…</string>
|
||||
<string name="buzz_typing_two">%1$s a %2$s píší…</string>
|
||||
<string name="buzz_typing_many">Několik lidí píše…</string>
|
||||
<string name="buzz_workspaces_title">Pracovní prostory Buzz</string>
|
||||
<string name="buzz_workspaces_hero_tagline">Kde lidé a agenti tvoří společně</string>
|
||||
<string name="buzz_workspaces_leave">Opustit pracovní prostor</string>
|
||||
<string name="buzz_workspaces_section">Vaše pracovní prostory</string>
|
||||
<string name="buzz_workspaces_empty_title">Zatím žádné pracovní prostory</string>
|
||||
<string name="buzz_workspaces_empty_body">Pracovní prostory Buzz jsou relaye, kde lidé a AI agenti tvoří společně. Připojte se k některému a uvidíte ho tady.</string>
|
||||
<string name="buzz_workspaces_browse">Procházet skupiny</string>
|
||||
<string name="buzz_console_card_title">Konzole agenta</string>
|
||||
<string name="buzz_console_card_subtitle">Náklady · Persony · Živý pozorovatel</string>
|
||||
<string name="buzz_forum_new_title">Nové téma fóra</string>
|
||||
<string name="buzz_forum_body_label">O čem chcete diskutovat?</string>
|
||||
<string name="buzz_forum_post_action">Odeslat téma</string>
|
||||
<string name="buzz_forum_thread_title">Vlákno fóra</string>
|
||||
<string name="buzz_forum_no_replies">Zatím žádné odpovědi. Buďte první, kdo odpoví.</string>
|
||||
<string name="buzz_dm_title">Přímé zprávy</string>
|
||||
<string name="buzz_dm_nav_label">Zprávy Buzz</string>
|
||||
<string name="buzz_dm_card_subtitle">Soukromé konverzace ve vašich pracovních prostorech</string>
|
||||
<string name="buzz_dm_new">Nová zpráva</string>
|
||||
<string name="buzz_dm_empty_title">Zatím žádné přímé zprávy</string>
|
||||
<string name="buzz_dm_empty_body">Začněte soukromou konverzaci s kýmkoli v pracovním prostoru Buzz.</string>
|
||||
<string name="buzz_dm_more">Více</string>
|
||||
<string name="buzz_dm_hide">Skrýt konverzaci</string>
|
||||
<string name="buzz_dm_add_member">Přidat člena</string>
|
||||
<string name="buzz_dm_add_member_title">Přidat někoho do této konverzace</string>
|
||||
<string name="buzz_dm_add_member_invalid">Neplatný npub nebo hex klíč</string>
|
||||
<string name="buzz_dm_just_you">Jen vy</string>
|
||||
<string name="buzz_dm_workspace">Pracovní prostor</string>
|
||||
<string name="buzz_dm_recipients">Komu</string>
|
||||
<string name="buzz_dm_add_hint">Přidat někoho (npub nebo hex)</string>
|
||||
<string name="buzz_dm_start">Zahájit konverzaci</string>
|
||||
<string name="buzz_dm_opening">Otevírání…</string>
|
||||
<string name="buzz_dm_remove">Odebrat</string>
|
||||
<string name="buzz_invite_title">Pozvánka do pracovního prostoru</string>
|
||||
<string name="buzz_invite_heading">Připojit se k tomuto pracovnímu prostoru</string>
|
||||
<string name="buzz_invite_workspace">Pracovní prostor</string>
|
||||
<string name="buzz_invite_body">Pracovní prostor se otevře v zabezpečeném prohlížeči v aplikaci, kde si projdete podmínky a dokončíte připojení. Přihlásí vás vaším klíčem Amethyst — bez hesla.</string>
|
||||
<string name="buzz_invite_continue">Pokračovat v prohlížeči</string>
|
||||
<string name="buzz_invite_invalid">Toto nevypadá jako platný odkaz s pozvánkou Buzz.</string>
|
||||
<string name="buzz_invite_expired">Platnost této pozvánky vypršela. Požádejte o novou.</string>
|
||||
<string name="buzz_invite_after_browser">Dokončete přijetí podmínek v prohlížeči a pak se vraťte — váš pracovní prostor tu na vás bude čekat.</string>
|
||||
<string name="buzz_invite_open_workspace">Otevřít mé pracovní prostory</string>
|
||||
<string name="buzz_import_title">Importovat kanály pracovního prostoru</string>
|
||||
<string name="buzz_import_loading">Hledání vašich kanálů…</string>
|
||||
<string name="buzz_import_your_channels">Vaše kanály v tomto pracovním prostoru</string>
|
||||
<string name="buzz_import_add">Přidat</string>
|
||||
<string name="buzz_import_add_all">Přidat vše</string>
|
||||
<string name="buzz_import_added">Přidáno</string>
|
||||
<string name="buzz_import_empty_title">Nenalezeny žádné kanály</string>
|
||||
<string name="buzz_import_empty_body">Zdá se, že zatím nejste členem žádného kanálu na tomto relayi. Nejprve přijměte pozvánku do pracovního prostoru v prohlížeči a pak to zkuste znovu.</string>
|
||||
<string name="relay_group_section_channels">Kanály</string>
|
||||
<string name="relay_group_section_forums">Fóra</string>
|
||||
<string name="buzz_agent_working">Pracuji…</string>
|
||||
<string name="relay_group_add_member">Přidat člena</string>
|
||||
<string name="buzz_community_add_people">Přidat lidi do tohoto pracovního prostoru</string>
|
||||
<string name="buzz_invite_create">Vytvořit odkaz s pozvánkou</string>
|
||||
<string name="buzz_invite_link_title">Odkaz s pozvánkou</string>
|
||||
<string name="buzz_invite_share">Sdílet</string>
|
||||
<string name="buzz_invite_copy">Kopírovat</string>
|
||||
<string name="buzz_invite_error_title">Pozvánku se nepodařilo vytvořit</string>
|
||||
<string name="buzz_pin">Připnout kanál</string>
|
||||
<string name="buzz_unpin">Odepnout kanál</string>
|
||||
<string name="buzz_dm_section_empty">Zatím žádné konverzace</string>
|
||||
<plurals name="buzz_dm_see_all_count">
|
||||
<item quantity="one">Zobrazit další %1$d konverzaci</item>
|
||||
<item quantity="few">Zobrazit všechny %1$d konverzace</item>
|
||||
<item quantity="many">Zobrazit všech %1$d konverzace</item>
|
||||
<item quantity="other">Zobrazit všech %1$d konverzací</item>
|
||||
</plurals>
|
||||
<string name="chat_delivery_details_title">Doručení zprávy</string>
|
||||
<string name="close">Zavřít</string>
|
||||
<string name="chat_delivery_pending">Čekání, až relay přijme tuto zprávu</string>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="chat_preview_decrypting">Wird entschlüsselt…</string>
|
||||
<!-- Prefix on a Messages-list preview when the newest message was sent by the logged-in user. %1$s is the message text -->
|
||||
<string name="chat_preview_you_prefix">Du: %1$s</string>
|
||||
<string name="group_picture">Gruppenbild</string>
|
||||
<string name="explicit_content">Anstößiger Inhalt</string>
|
||||
<string name="relay_notice">Relay-Hinweis</string>
|
||||
@@ -344,6 +345,14 @@
|
||||
<item quantity="one">%1$d Kanal</item>
|
||||
<item quantity="other">%1$d Kanäle</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_workspaces_count">
|
||||
<item quantity="one">%1$d Workspace</item>
|
||||
<item quantity="other">%1$d Workspaces</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_workspaces_channel_count">
|
||||
<item quantity="one">%1$d Kanal</item>
|
||||
<item quantity="other">%1$d Kanäle</item>
|
||||
</plurals>
|
||||
<plurals name="concord_member_count">
|
||||
<item quantity="one">%1$d Mitglied</item>
|
||||
<item quantity="other">%1$d Mitglieder</item>
|
||||
@@ -400,6 +409,7 @@
|
||||
<!-- Title of the minichat (thread) screen opened from a chat message. -->
|
||||
<string name="chat_minichat_title">Thread</string>
|
||||
<!-- Title of the picked-image confirmation dialog when attaching a picture to a thread reply. -->
|
||||
<string name="chat_send_image_title">Bild senden</string>
|
||||
<!-- Chip on a chat message that opens its thread ("minichat") of kind-1111 replies. -->
|
||||
<plurals name="chat_minichat_reply_count">
|
||||
<item quantity="one">%1$d Antwort</item>
|
||||
@@ -1252,6 +1262,7 @@
|
||||
<string name="nest_create_submit">Space starten</string>
|
||||
<string name="nest_create_schedule_toggle">Für später planen</string>
|
||||
<string name="nest_create_when">Startzeit wählen</string>
|
||||
<string name="agent_console_title">Agent-Konsole</string>
|
||||
<string name="nests_servers_title">Nest-Server</string>
|
||||
<string name="nests_servers_explainer">Wähle, an welche MoQ-Hostserver Amethyst deine Nests veröffentlicht. Der erste Eintrag wird standardmäßig verwendet, wenn du einen neuen Space startest.</string>
|
||||
<string name="nests_servers_my_section">Deine Server</string>
|
||||
@@ -1594,6 +1605,10 @@
|
||||
<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_stored_on">Gespeichert auf</string>
|
||||
<string name="blossom_file_details">Dateidetails</string>
|
||||
<string name="blossom_on_all_servers">Auf allen Servern</string>
|
||||
<string name="blossom_not_on_all_servers">Nicht auf allen Servern</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>
|
||||
@@ -1611,6 +1626,33 @@
|
||||
<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="blossom_more_actions">Weitere Aktionen</string>
|
||||
<string name="blossom_import_menu">Dateien importieren…</string>
|
||||
<string name="blossom_import_title">Dateien importieren</string>
|
||||
<string name="blossom_import_intro">Durchsuche andere Blossom-Server nach Dateien, die du anderswo hochgeladen hast, und kopiere sie auf deine eigenen Server.</string>
|
||||
<string name="blossom_import_sources_section">Zu prüfende Server</string>
|
||||
<string name="blossom_import_enable_all">Alle aktivieren</string>
|
||||
<string name="blossom_import_disable_all">Alle deaktivieren</string>
|
||||
<string name="blossom_import_add_url_label">Oder füge eine Serveradresse ein</string>
|
||||
<string name="blossom_import_scan">Server prüfen</string>
|
||||
<string name="blossom_import_scanning">Wird geprüft…</string>
|
||||
<string name="blossom_import_source_failed">Dieser Server ist nicht erreichbar</string>
|
||||
<string name="blossom_import_none_found">Auf den ausgewählten Servern wurden keine neuen Dateien gefunden.</string>
|
||||
<string name="blossom_import_no_targets">Füge zuerst eigene Blossom-Server hinzu, damit die importierten Dateien irgendwohin kopiert werden können.</string>
|
||||
<string name="blossom_import_manage_servers">Meine Server verwalten</string>
|
||||
<string name="blossom_import_busy">Eine Dateisynchronisierung läuft bereits. Versuche den Import erneut, sobald sie abgeschlossen ist.</string>
|
||||
<plurals name="blossom_import_files_found">
|
||||
<item quantity="one">%1$d Datei</item>
|
||||
<item quantity="other">%1$d Dateien</item>
|
||||
</plurals>
|
||||
<plurals name="blossom_import_found_files">
|
||||
<item quantity="one">%1$d Datei zum Import auf deine Server gefunden.</item>
|
||||
<item quantity="other">%1$d Dateien zum Import auf deine Server gefunden.</item>
|
||||
</plurals>
|
||||
<plurals name="blossom_import_start_button">
|
||||
<item quantity="one">%1$d Datei importieren</item>
|
||||
<item quantity="other">%1$d Dateien importieren</item>
|
||||
</plurals>
|
||||
<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>
|
||||
@@ -1639,6 +1681,20 @@
|
||||
<string name="no_payment_app_found">Keine App gefunden, die diese Zahlung verarbeiten kann. Bitte installiere eine kompatible Wallet.</string>
|
||||
<string name="no_payment_app_found_for_type">Es ist keine App installiert, die Zahlungen vom Typ %1$s verarbeiten kann. Bitte installiere eine kompatible Wallet.</string>
|
||||
<string name="error_dialog_payment_error">Zahlung konnte nicht geöffnet werden</string>
|
||||
<string name="bolt12_offers">BOLT12-Angebote</string>
|
||||
<string name="bolt12_offers_explainer">Veröffentliche wiederverwendbare BOLT12-Angebote, damit andere dich über Lightning zappen können, ohne jedes Mal eine eigene Rechnung.</string>
|
||||
<string name="bolt12_offers_section_explainer">Füge ein oder mehrere BOLT12-Angebote (lno1…) hinzu. Sie werden öffentlich geteilt, sodass dich jeder bezahlen kann.</string>
|
||||
<string name="no_bolt12_offers_message">Keine BOLT12-Angebote festgelegt. Füge unten eines hinzu ↓</string>
|
||||
<string name="bolt12_offer">BOLT12-Angebot (lno1…)</string>
|
||||
<string name="invalid_bolt12_offer">Kein gültiges BOLT12-Angebot</string>
|
||||
<string name="delete_bolt12_offer">BOLT12-Angebot löschen</string>
|
||||
<string name="bolt12_pay_with_wallet">Mit verbundener Wallet bezahlen</string>
|
||||
<string name="bolt12_payment_amount_sats">Betrag (sats)</string>
|
||||
<string name="bolt12_payment_sent">BOLT12-Zahlung gesendet</string>
|
||||
<string name="bolt12_payment_failed">BOLT12-Zahlung fehlgeschlagen: %1$s</string>
|
||||
<string name="bolt12_zap_error">BOLT12-Zap-Fehler</string>
|
||||
<string name="bolt12_zap_paid_no_receipt">Über BOLT12 bezahlt, aber die Wallet hat keinen Nachweis zurückgegeben, daher wurde keine Zap-Quittung veröffentlicht.</string>
|
||||
<string name="bolt12_zap_invalid_receipt">Über BOLT12 bezahlt, aber der Zahlungsnachweis konnte nicht validiert werden, daher wurde keine Zap-Quittung veröffentlicht.</string>
|
||||
<string name="uploading_state_ready">Noch nicht angefangen</string>
|
||||
<string name="uploading_state_compressing">Komprimieren</string>
|
||||
<string name="uploading_state_uploading">Hochladen</string>
|
||||
@@ -1745,6 +1801,10 @@
|
||||
<string name="app_notification_zaps_channel_message">%1$s Sats</string>
|
||||
<string name="app_notification_zaps_channel_message_from">Von %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">für %1$s</string>
|
||||
<string name="app_notification_payments_channel_name">Erhaltene Zahlungen</string>
|
||||
<string name="app_notification_payments_channel_description">Benachrichtigt dich, wenn deine verbundene Wallet eine Zahlung erhält, die kein Nostr-Zap ist</string>
|
||||
<string name="app_notification_payments_channel_message">%1$s sats erhalten</string>
|
||||
<string name="app_notification_payments_summary">Neue Zahlungen</string>
|
||||
<string name="app_notification_reply_label">Antworten</string>
|
||||
<string name="app_notification_mark_read_label">Als gelesen markieren</string>
|
||||
<string name="app_notification_me">Ich</string>
|
||||
@@ -2368,6 +2428,12 @@
|
||||
<string name="relay_group_browse_your_relays">Relays, auf denen du bist</string>
|
||||
<string name="relay_group_browse_popular">Beliebte Relays</string>
|
||||
<string name="relay_group_no_messages_yet">Noch keine Nachrichten</string>
|
||||
<string name="channel_invite_title">Zu %1$s hinzugefügt</string>
|
||||
<string name="channel_invite_body">%1$s hat dich auf %2$s zu diesem Kanal hinzugefügt. In Nachrichten anzeigen?</string>
|
||||
<string name="channel_invite_unknown_actor">Jemand</string>
|
||||
<string name="channel_invite_accept">Anzeigen</string>
|
||||
<string name="channel_invite_ignore">Ignorieren</string>
|
||||
<string name="channel_invite_leave">Verlassen</string>
|
||||
<string name="relay_group_join_to_post">Tritt dieser Gruppe bei, um Nachrichten zu senden.</string>
|
||||
<string name="relay_group_invite_only_to_post">Diese Gruppe ist nur mit Einladung — du brauchst eine Einladung, um zu posten.</string>
|
||||
<plurals name="relay_group_member_count">
|
||||
@@ -2386,6 +2452,10 @@
|
||||
<item quantity="one">%1$d Person, der du folgst</item>
|
||||
<item quantity="other">%1$d Personen, denen du folgst</item>
|
||||
</plurals>
|
||||
<plurals name="relay_group_relay_group_count">
|
||||
<item quantity="one">%1$d Gruppe</item>
|
||||
<item quantity="other">%1$d Gruppen</item>
|
||||
</plurals>
|
||||
<string name="messages_new_message">Privat</string>
|
||||
<string name="messages_new_message_to">An</string>
|
||||
<string name="messages_new_message_subject">Betreff</string>
|
||||
@@ -3070,9 +3140,84 @@
|
||||
<string name="chat_system_created_channel">%1$s hat den Kanal %2$s erstellt</string>
|
||||
<string name="chat_system_created_channel_unnamed">%1$s hat den Kanal erstellt</string>
|
||||
<string name="chat_system_updated_channel">%1$s hat das Kanalprofil aktualisiert</string>
|
||||
<string name="buzz_message_edited">(bearbeitet)</string>
|
||||
<string name="buzz_diff_truncated">(Diff gekürzt)</string>
|
||||
<string name="buzz_canvas_empty">In diesem Workspace wurde noch kein Canvas geteilt.</string>
|
||||
<string name="buzz_canvas_edit">Canvas bearbeiten</string>
|
||||
<string name="buzz_canvas_save">Canvas speichern</string>
|
||||
<string name="buzz_edit_message">Bearbeiten</string>
|
||||
<string name="buzz_editing_banner">Nachricht bearbeiten</string>
|
||||
<string name="edit_message">Bearbeiten</string>
|
||||
<string name="message_edited">(bearbeitet)</string>
|
||||
<string name="concord_editing_banner">Nachricht bearbeiten</string>
|
||||
<string name="buzz_typing_one">%1$s tippt…</string>
|
||||
<string name="buzz_typing_two">%1$s und %2$s tippen…</string>
|
||||
<string name="buzz_typing_many">Mehrere Personen tippen…</string>
|
||||
<string name="buzz_workspaces_title">Buzz-Workspaces</string>
|
||||
<string name="buzz_workspaces_hero_tagline">Wo Menschen und Agenten gemeinsam bauen</string>
|
||||
<string name="buzz_workspaces_leave">Workspace verlassen</string>
|
||||
<string name="buzz_workspaces_section">Deine Workspaces</string>
|
||||
<string name="buzz_workspaces_empty_title">Noch keine Workspaces</string>
|
||||
<string name="buzz_workspaces_empty_body">Buzz-Workspaces sind Relays, in denen Menschen und KI-Agenten gemeinsam bauen. Tritt einem bei, um ihn hier zu sehen.</string>
|
||||
<string name="buzz_workspaces_browse">Gruppen durchsuchen</string>
|
||||
<string name="buzz_console_card_title">Agent-Konsole</string>
|
||||
<string name="buzz_console_card_subtitle">Kosten · Personas · Live-Beobachter</string>
|
||||
<string name="buzz_forum_new_title">Neues Forumsthema</string>
|
||||
<string name="buzz_forum_body_label">Worüber möchtest du diskutieren?</string>
|
||||
<string name="buzz_forum_post_action">Thema veröffentlichen</string>
|
||||
<string name="buzz_forum_thread_title">Forum-Thread</string>
|
||||
<string name="buzz_forum_no_replies">Noch keine Antworten. Sei die erste Person, die antwortet.</string>
|
||||
<string name="buzz_dm_title">Direktnachrichten</string>
|
||||
<string name="buzz_dm_nav_label">Buzz-DMs</string>
|
||||
<string name="buzz_dm_card_subtitle">Private Unterhaltungen in deinen Workspaces</string>
|
||||
<string name="buzz_dm_new">Neue Nachricht</string>
|
||||
<string name="buzz_dm_empty_title">Noch keine Direktnachrichten</string>
|
||||
<string name="buzz_dm_empty_body">Beginne eine private Unterhaltung mit jemandem in einem Buzz-Workspace.</string>
|
||||
<string name="buzz_dm_more">Mehr</string>
|
||||
<string name="buzz_dm_hide">Unterhaltung ausblenden</string>
|
||||
<string name="buzz_dm_add_member">Mitglied hinzufügen</string>
|
||||
<string name="buzz_dm_add_member_title">Jemanden zu dieser DM hinzufügen</string>
|
||||
<string name="buzz_dm_add_member_invalid">Kein gültiger npub- oder Hex-Schlüssel</string>
|
||||
<string name="buzz_dm_just_you">Nur du</string>
|
||||
<string name="buzz_dm_recipients">An</string>
|
||||
<string name="buzz_dm_add_hint">Jemanden hinzufügen (npub oder hex)</string>
|
||||
<string name="buzz_dm_start">Unterhaltung beginnen</string>
|
||||
<string name="buzz_dm_opening">Wird geöffnet…</string>
|
||||
<string name="buzz_dm_remove">Entfernen</string>
|
||||
<string name="buzz_invite_title">Workspace-Einladung</string>
|
||||
<string name="buzz_invite_heading">Diesem Workspace beitreten</string>
|
||||
<string name="buzz_invite_role">Rolle</string>
|
||||
<string name="buzz_invite_body">Du öffnest den Workspace in einem sicheren In-App-Browser, um die Bedingungen zu prüfen und den Beitritt abzuschließen. Die Anmeldung erfolgt mit deinem Amethyst-Schlüssel — ohne Passwort.</string>
|
||||
<string name="buzz_invite_continue">Im Browser fortfahren</string>
|
||||
<string name="buzz_invite_invalid">Das sieht nicht nach einem gültigen Buzz-Einladungslink aus.</string>
|
||||
<string name="buzz_invite_expired">Diese Einladung ist abgelaufen. Bitte um eine neue.</string>
|
||||
<string name="buzz_invite_after_browser">Schließe die Zustimmung zu den Bedingungen im Browser ab und komm dann zurück — dein Workspace wartet auf dich.</string>
|
||||
<string name="buzz_invite_open_workspace">Meine Workspaces öffnen</string>
|
||||
<string name="buzz_import_title">Workspace-Kanäle importieren</string>
|
||||
<string name="buzz_import_loading">Deine Kanäle werden gesucht…</string>
|
||||
<string name="buzz_import_your_channels">Deine Kanäle in diesem Workspace</string>
|
||||
<string name="buzz_import_add">Hinzufügen</string>
|
||||
<string name="buzz_import_add_all">Alle hinzufügen</string>
|
||||
<string name="buzz_import_added">Hinzugefügt</string>
|
||||
<string name="buzz_import_empty_title">Keine Kanäle gefunden</string>
|
||||
<string name="buzz_import_empty_body">Du scheinst noch in keinem Kanal auf diesem Relay Mitglied zu sein. Nimm zuerst die Workspace-Einladung im Browser an und versuche es dann erneut.</string>
|
||||
<string name="relay_group_section_channels">Kanäle</string>
|
||||
<string name="relay_group_section_forums">Foren</string>
|
||||
<string name="buzz_agent_working">Arbeitet…</string>
|
||||
<string name="relay_group_add_member">Mitglied hinzufügen</string>
|
||||
<string name="buzz_community_add_people">Personen zu diesem Workspace hinzufügen</string>
|
||||
<string name="buzz_invite_create">Einladungslink erstellen</string>
|
||||
<string name="buzz_invite_link_title">Einladungslink</string>
|
||||
<string name="buzz_invite_share">Teilen</string>
|
||||
<string name="buzz_invite_copy">Kopieren</string>
|
||||
<string name="buzz_invite_error_title">Einladung konnte nicht erstellt werden</string>
|
||||
<string name="buzz_pin">Kanal anheften</string>
|
||||
<string name="buzz_unpin">Kanal nicht mehr anheften</string>
|
||||
<string name="buzz_dm_section_empty">Noch keine Unterhaltungen</string>
|
||||
<plurals name="buzz_dm_see_all_count">
|
||||
<item quantity="one">%1$d weitere Unterhaltung anzeigen</item>
|
||||
<item quantity="other">Alle %1$d Unterhaltungen anzeigen</item>
|
||||
</plurals>
|
||||
<string name="chat_delivery_details_title">Nachrichtenzustellung</string>
|
||||
<string name="close">Schließen</string>
|
||||
<string name="chat_delivery_pending">Warte darauf, dass ein Relay diese Nachricht akzeptiert</string>
|
||||
|
||||
@@ -2367,6 +2367,8 @@
|
||||
<string name="relay_group_invite_preparing">आमन्त्रण उत्पादन…</string>
|
||||
<string name="relay_group_badge_private">निजी</string>
|
||||
<string name="relay_group_badge_invite_only">केवल आमन्त्रित</string>
|
||||
<string name="relay_group_badge_live">वर्तमान</string>
|
||||
<string name="relay_group_message_count_short_capped">%1$d से अधिक</string>
|
||||
<string name="relay_group_browse_invalid_url">प्रविष्ट करें एक मान्य पुनःप्रसारक पता (wss://…)।</string>
|
||||
<string name="relay_group_field_name">नाम</string>
|
||||
<string name="relay_group_field_about">विवरण</string>
|
||||
@@ -2449,6 +2451,10 @@
|
||||
<item quantity="one">%1$d व्यक्ति जिसके आप अनुचर</item>
|
||||
<item quantity="other">%1$d व्यक्ति जिसके आप अनुचर</item>
|
||||
</plurals>
|
||||
<plurals name="relay_group_relay_group_count">
|
||||
<item quantity="one">%1$d समूह</item>
|
||||
<item quantity="other">%1$d समूह</item>
|
||||
</plurals>
|
||||
<string name="messages_new_message">निजी</string>
|
||||
<string name="messages_new_message_to">के लिए</string>
|
||||
<string name="messages_new_message_subject">विषय</string>
|
||||
|
||||
@@ -737,7 +737,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="profile_badges_empty">Nie otrzymałeś jeszcze żadnych odznak.</string>
|
||||
<string name="profile_apps_header">Aplikacje · %1$d</string>
|
||||
<string name="profile_apps_header_empty">Aplikacje</string>
|
||||
<string name="profile_apps_empty_hint">Poleć aplikacjom Nostr, których używasz, aby inni mogli je odkryć.</string>
|
||||
<string name="profile_apps_empty_hint">Poleć aplikacje Nostr, z których korzystasz, aby inni mogli je odkryć.</string>
|
||||
<string name="profile_app_recommendations_title">Polecane aplikacje</string>
|
||||
<string name="profile_app_recommendations_description">Wybierz aplikacje Nostr, które chcesz publicznie polecić. Twoje rekomendacje pojawią się na Twoim profilu i pomogą innym użytkownikom znaleźć aplikacje z treściami, których ten klient nie może otworzyć.</string>
|
||||
<string name="profile_app_recommendations_empty">Nie znaleziono jeszcze żadnych aplikacji. Aplikacje będą się tu pojawiać w miarę ich wykrywania na Twoich transmiterach.</string>
|
||||
@@ -2451,6 +2451,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="relay_group_invite_preparing">Przygotowywanie zaproszenia…</string>
|
||||
<string name="relay_group_badge_private">Prywatna</string>
|
||||
<string name="relay_group_badge_invite_only">Tylko na zaproszenie</string>
|
||||
<string name="relay_group_badge_live">NA ŻYWO</string>
|
||||
<string name="relay_group_message_count_short_capped">%1$d+</string>
|
||||
<string name="relay_group_browse_invalid_url">Wprowadź poprawny adres URL transmitera (wss://…).</string>
|
||||
<string name="relay_group_field_name">Nazwa</string>
|
||||
<string name="relay_group_field_about">Opis</string>
|
||||
@@ -2541,6 +2543,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<item quantity="many">%1$d osób, które obserwujesz</item>
|
||||
<item quantity="other">%1$d osoby, które obserwujesz</item>
|
||||
</plurals>
|
||||
<plurals name="relay_group_relay_group_count">
|
||||
<item quantity="one">%1$d grupa</item>
|
||||
<item quantity="few">%1$d grup</item>
|
||||
<item quantity="many">%1$d grup</item>
|
||||
<item quantity="other">%1$d grupy</item>
|
||||
</plurals>
|
||||
<string name="messages_new_message">Prywatna</string>
|
||||
<string name="messages_new_message_to">Do</string>
|
||||
<string name="messages_new_message_subject">Temat</string>
|
||||
@@ -4388,6 +4396,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="pow_cancel_dialog_send_without_pow">Wyślij bez PoW</string>
|
||||
<string name="pow_cancel_dialog_discard">Odrzuć post</string>
|
||||
<string name="pow_difficulty_estimate">◆ %1$s na posta na tym urządzeniu</string>
|
||||
<string name="pow_estimate_instant"><1 s</string>
|
||||
<plurals name="pow_estimate_seconds">
|
||||
<item quantity="one">%1$d sekundę</item>
|
||||
<item quantity="few">%1$d sekund</item>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="chat_preview_decrypting">Descriptografando…</string>
|
||||
<!-- Prefix on a Messages-list preview when the newest message was sent by the logged-in user. %1$s is the message text -->
|
||||
<string name="chat_preview_you_prefix">Você: %1$s</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>
|
||||
@@ -344,6 +345,14 @@
|
||||
<item quantity="one">%1$d canal</item>
|
||||
<item quantity="other">%1$d canais</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_workspaces_count">
|
||||
<item quantity="one">%1$d espaço de trabalho</item>
|
||||
<item quantity="other">%1$d espaços de trabalho</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_workspaces_channel_count">
|
||||
<item quantity="one">%1$d canal</item>
|
||||
<item quantity="other">%1$d canais</item>
|
||||
</plurals>
|
||||
<plurals name="concord_member_count">
|
||||
<item quantity="one">%1$d membro</item>
|
||||
<item quantity="other">%1$d membros</item>
|
||||
@@ -400,6 +409,7 @@
|
||||
<!-- Title of the minichat (thread) screen opened from a chat message. -->
|
||||
<string name="chat_minichat_title">Tópico</string>
|
||||
<!-- Title of the picked-image confirmation dialog when attaching a picture to a thread reply. -->
|
||||
<string name="chat_send_image_title">Enviar imagem</string>
|
||||
<!-- Chip on a chat message that opens its thread ("minichat") of kind-1111 replies. -->
|
||||
<plurals name="chat_minichat_reply_count">
|
||||
<item quantity="one">%1$d resposta</item>
|
||||
@@ -1250,6 +1260,7 @@
|
||||
<string name="nest_create_submit">Iniciar espaço</string>
|
||||
<string name="nest_create_schedule_toggle">Agendar para depois</string>
|
||||
<string name="nest_create_when">Escolha um horário de início</string>
|
||||
<string name="agent_console_title">Console do agente</string>
|
||||
<string name="nests_servers_title">Servidores de nest</string>
|
||||
<string name="nests_servers_explainer">Escolha em quais servidores MoQ o Amethyst publica seus nests. A primeira entrada é usada por padrão ao iniciar um novo espaço.</string>
|
||||
<string name="nests_servers_my_section">Seus servidores</string>
|
||||
@@ -1592,6 +1603,10 @@
|
||||
<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_stored_on">Armazenado em</string>
|
||||
<string name="blossom_file_details">Detalhes do arquivo</string>
|
||||
<string name="blossom_on_all_servers">Em todos os servidores</string>
|
||||
<string name="blossom_not_on_all_servers">Não está em todos os servidores</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>
|
||||
@@ -1609,6 +1624,33 @@
|
||||
<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="blossom_more_actions">Mais ações</string>
|
||||
<string name="blossom_import_menu">Importar arquivos…</string>
|
||||
<string name="blossom_import_title">Importar arquivos</string>
|
||||
<string name="blossom_import_intro">Procure em outros servidores Blossom por arquivos que você enviou em outro lugar e copie-os para os seus próprios servidores.</string>
|
||||
<string name="blossom_import_sources_section">Servidores a verificar</string>
|
||||
<string name="blossom_import_enable_all">Ativar todos</string>
|
||||
<string name="blossom_import_disable_all">Desativar todos</string>
|
||||
<string name="blossom_import_add_url_label">Ou cole um endereço de servidor</string>
|
||||
<string name="blossom_import_scan">Verificar servidores</string>
|
||||
<string name="blossom_import_scanning">Verificando…</string>
|
||||
<string name="blossom_import_source_failed">Não foi possível acessar este servidor</string>
|
||||
<string name="blossom_import_none_found">Nenhum arquivo novo foi encontrado nos servidores selecionados.</string>
|
||||
<string name="blossom_import_no_targets">Adicione primeiro seus próprios servidores Blossom, para que haja onde copiar os arquivos importados.</string>
|
||||
<string name="blossom_import_manage_servers">Gerenciar meus servidores</string>
|
||||
<string name="blossom_import_busy">Uma sincronização de arquivos já está em andamento. Tente importar novamente quando ela terminar.</string>
|
||||
<plurals name="blossom_import_files_found">
|
||||
<item quantity="one">%1$d arquivo</item>
|
||||
<item quantity="other">%1$d arquivos</item>
|
||||
</plurals>
|
||||
<plurals name="blossom_import_found_files">
|
||||
<item quantity="one">Encontrado %1$d arquivo para importar para os seus servidores.</item>
|
||||
<item quantity="other">Encontrados %1$d arquivos para importar para os seus servidores.</item>
|
||||
</plurals>
|
||||
<plurals name="blossom_import_start_button">
|
||||
<item quantity="one">Importar %1$d arquivo</item>
|
||||
<item quantity="other">Importar %1$d arquivos</item>
|
||||
</plurals>
|
||||
<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>
|
||||
@@ -1637,6 +1679,20 @@
|
||||
<string name="no_payment_app_found">Nenhum aplicativo encontrado para processar este pagamento. Por favor, instale uma carteira compatível.</string>
|
||||
<string name="no_payment_app_found_for_type">Nenhum app instalado para processar pagamentos %1$s. Instale uma carteira compatível.</string>
|
||||
<string name="error_dialog_payment_error">Não foi possível abrir o pagamento</string>
|
||||
<string name="bolt12_offers">Ofertas BOLT12</string>
|
||||
<string name="bolt12_offers_explainer">Publique ofertas BOLT12 reutilizáveis para que outras pessoas possam zapear você pela Lightning sem uma fatura separada a cada vez.</string>
|
||||
<string name="bolt12_offers_section_explainer">Adicione uma ou mais ofertas BOLT12 (lno1…). Elas são compartilhadas publicamente, então qualquer pessoa pode pagar você.</string>
|
||||
<string name="no_bolt12_offers_message">Nenhuma oferta BOLT12 definida. Adicione uma abaixo ↓</string>
|
||||
<string name="bolt12_offer">Oferta BOLT12 (lno1…)</string>
|
||||
<string name="invalid_bolt12_offer">Não é uma oferta BOLT12 válida</string>
|
||||
<string name="delete_bolt12_offer">Excluir oferta BOLT12</string>
|
||||
<string name="bolt12_pay_with_wallet">Pagar com a carteira conectada</string>
|
||||
<string name="bolt12_payment_amount_sats">Valor (sats)</string>
|
||||
<string name="bolt12_payment_sent">Pagamento BOLT12 enviado</string>
|
||||
<string name="bolt12_payment_failed">Falha no pagamento BOLT12: %1$s</string>
|
||||
<string name="bolt12_zap_error">Erro no zap BOLT12</string>
|
||||
<string name="bolt12_zap_paid_no_receipt">Pago via BOLT12, mas a carteira não retornou nenhum comprovante, então nenhum recibo de zap foi publicado.</string>
|
||||
<string name="bolt12_zap_invalid_receipt">Pago via BOLT12, mas o comprovante de pagamento não foi validado, então nenhum recibo de zap foi publicado.</string>
|
||||
<string name="uploading_state_ready">Não iniciado</string>
|
||||
<string name="uploading_state_compressing">Compactando</string>
|
||||
<string name="uploading_state_uploading">Enviando</string>
|
||||
@@ -1743,6 +1799,10 @@
|
||||
<string name="app_notification_zaps_channel_message">%1$s sats</string>
|
||||
<string name="app_notification_zaps_channel_message_from">De %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">por %1$s</string>
|
||||
<string name="app_notification_payments_channel_name">Pagamentos recebidos</string>
|
||||
<string name="app_notification_payments_channel_description">Notifica você quando sua carteira conectada recebe um pagamento que não é um zap do Nostr</string>
|
||||
<string name="app_notification_payments_channel_message">Recebidos %1$s sats</string>
|
||||
<string name="app_notification_payments_summary">Novos pagamentos</string>
|
||||
<string name="app_notification_reply_label">Responder</string>
|
||||
<string name="app_notification_mark_read_label">Marcar como lida</string>
|
||||
<string name="app_notification_me">Eu</string>
|
||||
@@ -1776,6 +1836,7 @@
|
||||
<string name="app_notification_group_developer">Desenvolvedor</string>
|
||||
<string name="app_notification_group_games">Jogos</string>
|
||||
<!-- Reposts -->
|
||||
<string name="app_notification_reposts_channel_name">Repostagens</string>
|
||||
<string name="app_notification_reposts_channel_description">Notifica você quando alguém reposta sua publicação</string>
|
||||
<string name="app_notification_reposts_channel_message">%1$s repostou sua publicação</string>
|
||||
<string name="app_notification_reposts_summary">Novos reposts</string>
|
||||
@@ -2302,6 +2363,7 @@
|
||||
<string name="relay_group_invite_preparing">Preparando convite…</string>
|
||||
<string name="relay_group_badge_private">Privado</string>
|
||||
<string name="relay_group_badge_invite_only">Somente por convite</string>
|
||||
<string name="relay_group_badge_live">AO VIVO</string>
|
||||
<string name="relay_group_browse_invalid_url">Insira uma URL de relay válida (wss://…).</string>
|
||||
<string name="relay_group_field_name">Nome</string>
|
||||
<string name="relay_group_field_about">Descrição</string>
|
||||
@@ -2366,6 +2428,12 @@
|
||||
<string name="relay_group_browse_your_relays">Relays em que você está</string>
|
||||
<string name="relay_group_browse_popular">Relays populares</string>
|
||||
<string name="relay_group_no_messages_yet">Nenhuma mensagem ainda</string>
|
||||
<string name="channel_invite_title">Adicionado a %1$s</string>
|
||||
<string name="channel_invite_body">%1$s adicionou você a este canal em %2$s. Mostrar em Mensagens?</string>
|
||||
<string name="channel_invite_unknown_actor">Alguém</string>
|
||||
<string name="channel_invite_accept">Mostrar</string>
|
||||
<string name="channel_invite_ignore">Ignorar</string>
|
||||
<string name="channel_invite_leave">Sair</string>
|
||||
<string name="relay_group_join_to_post">Entre neste grupo para enviar mensagens.</string>
|
||||
<string name="relay_group_invite_only_to_post">Este grupo é somente por convite — você precisa de um convite para publicar.</string>
|
||||
<plurals name="relay_group_member_count">
|
||||
@@ -2384,6 +2452,10 @@
|
||||
<item quantity="one">%1$d pessoa que você segue</item>
|
||||
<item quantity="other">%1$d pessoas que você segue</item>
|
||||
</plurals>
|
||||
<plurals name="relay_group_relay_group_count">
|
||||
<item quantity="one">%1$d grupo</item>
|
||||
<item quantity="other">%1$d grupos</item>
|
||||
</plurals>
|
||||
<string name="messages_new_message">Privado</string>
|
||||
<string name="messages_new_message_to">Para</string>
|
||||
<string name="messages_new_message_subject">Assunto</string>
|
||||
@@ -3068,9 +3140,86 @@
|
||||
<string name="chat_system_created_channel">%1$s criou o canal %2$s</string>
|
||||
<string name="chat_system_created_channel_unnamed">%1$s criou o canal</string>
|
||||
<string name="chat_system_updated_channel">%1$s atualizou o perfil do canal</string>
|
||||
<string name="buzz_message_edited">(editado)</string>
|
||||
<string name="buzz_diff_truncated">(diff truncado)</string>
|
||||
<string name="buzz_canvas_empty">Nenhum canvas foi compartilhado neste espaço de trabalho ainda.</string>
|
||||
<string name="buzz_canvas_edit">Editar canvas</string>
|
||||
<string name="buzz_canvas_save">Salvar canvas</string>
|
||||
<string name="buzz_edit_message">Editar</string>
|
||||
<string name="buzz_editing_banner">Editando mensagem</string>
|
||||
<string name="edit_message">Editar</string>
|
||||
<string name="message_edited">(editado)</string>
|
||||
<string name="concord_editing_banner">Editando mensagem</string>
|
||||
<string name="buzz_typing_one">%1$s está digitando…</string>
|
||||
<string name="buzz_typing_two">%1$s e %2$s estão digitando…</string>
|
||||
<string name="buzz_typing_many">Várias pessoas estão digitando…</string>
|
||||
<string name="buzz_workspaces_title">Espaços de trabalho Buzz</string>
|
||||
<string name="buzz_workspaces_hero_tagline">Onde humanos e agentes constroem juntos</string>
|
||||
<string name="buzz_workspaces_leave">Sair do espaço de trabalho</string>
|
||||
<string name="buzz_workspaces_section">Seus espaços de trabalho</string>
|
||||
<string name="buzz_workspaces_empty_title">Nenhum espaço de trabalho ainda</string>
|
||||
<string name="buzz_workspaces_empty_body">Espaços de trabalho Buzz são relays onde humanos e agentes de IA constroem juntos. Entre em um para vê-lo aqui.</string>
|
||||
<string name="buzz_workspaces_browse">Explorar grupos</string>
|
||||
<string name="buzz_console_card_title">Console do agente</string>
|
||||
<string name="buzz_console_card_subtitle">Custos · Personas · Observador ao vivo</string>
|
||||
<string name="buzz_forum_new_title">Novo tópico do fórum</string>
|
||||
<string name="buzz_forum_body_label">O que você quer discutir?</string>
|
||||
<string name="buzz_forum_post_action">Publicar tópico</string>
|
||||
<string name="buzz_forum_thread_title">Discussão do fórum</string>
|
||||
<string name="buzz_forum_no_replies">Nenhuma resposta ainda. Seja a primeira pessoa a responder.</string>
|
||||
<string name="buzz_dm_title">Mensagens diretas</string>
|
||||
<string name="buzz_dm_nav_label">DMs do Buzz</string>
|
||||
<string name="buzz_dm_card_subtitle">Conversas privadas nos seus espaços de trabalho</string>
|
||||
<string name="buzz_dm_new">Nova mensagem</string>
|
||||
<string name="buzz_dm_empty_title">Nenhuma mensagem direta ainda</string>
|
||||
<string name="buzz_dm_empty_body">Inicie uma conversa privada com qualquer pessoa em um espaço de trabalho Buzz.</string>
|
||||
<string name="buzz_dm_more">Mais</string>
|
||||
<string name="buzz_dm_hide">Ocultar conversa</string>
|
||||
<string name="buzz_dm_add_member">Adicionar membro</string>
|
||||
<string name="buzz_dm_add_member_title">Adicionar alguém a esta DM</string>
|
||||
<string name="buzz_dm_add_member_invalid">Não é uma chave npub ou hex válida</string>
|
||||
<string name="buzz_dm_just_you">Só você</string>
|
||||
<string name="buzz_dm_workspace">Espaço de trabalho</string>
|
||||
<string name="buzz_dm_recipients">Para</string>
|
||||
<string name="buzz_dm_add_hint">Adicionar alguém (npub ou hex)</string>
|
||||
<string name="buzz_dm_start">Iniciar conversa</string>
|
||||
<string name="buzz_dm_opening">Abrindo…</string>
|
||||
<string name="buzz_dm_remove">Remover</string>
|
||||
<string name="buzz_invite_title">Convite para espaço de trabalho</string>
|
||||
<string name="buzz_invite_heading">Entrar neste espaço de trabalho</string>
|
||||
<string name="buzz_invite_workspace">Espaço de trabalho</string>
|
||||
<string name="buzz_invite_role">Função</string>
|
||||
<string name="buzz_invite_body">Você abrirá o espaço de trabalho em um navegador seguro dentro do app para revisar os termos e concluir a entrada. Ele faz seu login com sua chave do Amethyst — sem senha.</string>
|
||||
<string name="buzz_invite_continue">Continuar no navegador</string>
|
||||
<string name="buzz_invite_invalid">Isso não parece ser um link de convite válido do Buzz.</string>
|
||||
<string name="buzz_invite_expired">Este convite expirou. Peça um novo.</string>
|
||||
<string name="buzz_invite_after_browser">Termine de aceitar os termos no navegador e depois volte — seu espaço de trabalho estará esperando.</string>
|
||||
<string name="buzz_invite_open_workspace">Abrir meus espaços de trabalho</string>
|
||||
<string name="buzz_import_title">Importar canais do espaço de trabalho</string>
|
||||
<string name="buzz_import_loading">Procurando seus canais…</string>
|
||||
<string name="buzz_import_your_channels">Seus canais neste espaço de trabalho</string>
|
||||
<string name="buzz_import_add">Adicionar</string>
|
||||
<string name="buzz_import_add_all">Adicionar todos</string>
|
||||
<string name="buzz_import_added">Adicionado</string>
|
||||
<string name="buzz_import_empty_title">Nenhum canal encontrado</string>
|
||||
<string name="buzz_import_empty_body">Você ainda não parece ser membro de nenhum canal neste relay. Aceite primeiro o convite do espaço de trabalho no navegador e tente novamente.</string>
|
||||
<string name="relay_group_section_channels">Canais</string>
|
||||
<string name="relay_group_section_forums">Fóruns</string>
|
||||
<string name="buzz_agent_working">Trabalhando…</string>
|
||||
<string name="relay_group_add_member">Adicionar membro</string>
|
||||
<string name="buzz_community_add_people">Adicionar pessoas a este espaço de trabalho</string>
|
||||
<string name="buzz_invite_create">Criar link de convite</string>
|
||||
<string name="buzz_invite_link_title">Link de convite</string>
|
||||
<string name="buzz_invite_share">Compartilhar</string>
|
||||
<string name="buzz_invite_copy">Copiar</string>
|
||||
<string name="buzz_invite_error_title">Não foi possível criar o convite</string>
|
||||
<string name="buzz_pin">Fixar canal</string>
|
||||
<string name="buzz_unpin">Desafixar canal</string>
|
||||
<string name="buzz_dm_section_empty">Nenhuma conversa ainda</string>
|
||||
<plurals name="buzz_dm_see_all_count">
|
||||
<item quantity="one">Ver mais %1$d conversa</item>
|
||||
<item quantity="other">Ver todas as %1$d conversas</item>
|
||||
</plurals>
|
||||
<string name="chat_delivery_details_title">Entrega da mensagem</string>
|
||||
<string name="close">Fechar</string>
|
||||
<string name="chat_delivery_pending">Aguardando um relay aceitar esta mensagem</string>
|
||||
|
||||
@@ -26,13 +26,21 @@
|
||||
<item quantity="few">+%1$d\nodgovorov</item>
|
||||
<item quantity="other">+%1$d\nodgovorov</item>
|
||||
</plurals>
|
||||
<plurals name="dm_sender_reported">
|
||||
<item quantity="one">%1$d oseba, ki ji sledite, je prijavila</item>
|
||||
<item quantity="two">%1$d osebi, ki jima sledite, sta se prijavili</item>
|
||||
<item quantity="few">%1$d osebe, ki jim sledite, so se prijavile</item>
|
||||
<item quantity="other">%1$d oseb, ki ji sledite, so se prijavile</item>
|
||||
</plurals>
|
||||
<string name="post_not_found">Dogodek se nalaga ali pa ni najden v tvojih relejih</string>
|
||||
<string name="post_not_found_short">👀</string>
|
||||
<string name="channel_image">Slika kanala</string>
|
||||
<string name="referenced_event_not_found">Referenčni dogodek ni najden</string>
|
||||
<string name="could_not_decrypt_the_message">Dešifriranje sporočila ni uspelo</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="chat_preview_decrypting">Dekodiranje…</string>
|
||||
<!-- Prefix on a Messages-list preview when the newest message was sent by the logged-in user. %1$s is the message text -->
|
||||
<string name="chat_preview_you_prefix">Ti: %1$s</string>
|
||||
<string name="group_picture">Slika skupine</string>
|
||||
<string name="explicit_content">Eksplicitna vsebina</string>
|
||||
<string name="relay_notice">Obvestilo releja</string>
|
||||
@@ -314,7 +322,19 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="already_have_an_account">Že imam Nostr račun?</string>
|
||||
<string name="loading_feed">Nalagam vir vsebin</string>
|
||||
<string name="loading_account">Račun se nalaga</string>
|
||||
<string name="concord_redeeming_invite">Prevzem povabila…</string>
|
||||
<string name="concord_invite_failed">Povabila ni mogoče pridobiti. Povezava je morda potekla ali pa so njeni releji nedosegljivi.</string>
|
||||
<string name="concord_invite_failed_invalid">Ta povezava do povabila ni veljavna ali pa je ni mogoče odpreti s tem računom.</string>
|
||||
<string name="concord_invite_failed_incompatible">Te povezave do povabila ni mogoče odpreti. Morda je zastarela, jo je nadomestila nova ali pa je bila ustvarjena z novejšo različico aplikacije. Zaprosite za novo povezavo do povabila.</string>
|
||||
<string name="concord_invite_failed_revoked">Ta povezava do povabila je bila preklicana in je ni več mogoče uporabiti. Zaprosite za novo.</string>
|
||||
<string name="concord_invite_failed_expired">Ta povezava do povabila je potekla in je ni več mogoče uporabiti. Zaprosite za novo povezavo.</string>
|
||||
<string name="concord_invite_preview_unknown_name">Ime skupnosti je razkrito šele, ko se ji pridružite.</string>
|
||||
<string name="concord_invite_preview_explainer">Pridružitev se poveže z releji tega povabila, objavi obvestilo o pridružitvi, podpisano z vašim računom, in doda skupnost na vaš seznam. Dokler ne tapnete »Pridruži se«, se ne pošlje ničesar.</string>
|
||||
<string name="concord_invite_preview_relays">Releji, s katerimi se bo povezalo to povabilo: %1$s</string>
|
||||
<string name="concord_home_title">Kanali Concord</string>
|
||||
<string name="concord_home_empty">Pridružili se niste še nobenemu kanalu Concord. Ustvarite ga ali odprite povezavo do povabila.</string>
|
||||
<string name="concord_channels_empty">Še ni kanalov.</string>
|
||||
<string name="concord_show_all_channels">Prikaži vse kanale</string>
|
||||
<string name="concord_send_image_title">Pošlji sliko</string>
|
||||
<string name="concord_open_channel">Odpri kanal</string>
|
||||
<string name="concord_edit_banner_hint">Dodaj pasico</string>
|
||||
@@ -324,10 +344,45 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="concord_channel_name_label">Ime kanala</string>
|
||||
<string name="concord_channel_delete">Izbriši kanal</string>
|
||||
<string name="concord_channel_delete_title">Izbrišem kanal?</string>
|
||||
<string name="concord_channel_delete_message">Želite izbrisati kanal #%1$s? Tega ni mogoče razveljaviti, kanala pa tudi ni mogoče ponovno ustvariti z istim ID-jem.</string>
|
||||
<string name="concord_channel_delete_confirm">Izbriši</string>
|
||||
<string name="concord_leave_community">Zapusti skupnost</string>
|
||||
<string name="concord_leave_title">Zapustim skupnost?</string>
|
||||
<string name="concord_leave_message">Želite zapustiti skupnost %1$s? Odstranjena bo iz seznama tega računa in sinhronizacija v vaših napravah se bo ustavila. Skupnost o tem ne bo obveščena in ne boste odstranjeni iz seznama njenih članov. Sporočil, ki jih ne boste mogli več dešifrirati, morda ne bo mogoče obnoviti, vrnete pa se lahko le z novim povabilom.</string>
|
||||
<string name="concord_leave_owner_warning">To skupnost ste ustvarili vi. Z izhodom je ne boste izbrisali ali predali komu drugemu, vendar boste zavrgli lastniški ključ, shranjen na vašem seznamu – tako je ne boste mogli več upravljati.</string>
|
||||
<string name="concord_edit_relays_desc">Kjer so objavljeni in prebrani šifrirani ravninski sloji (encrypted planes) te skupnosti</string>
|
||||
<string name="concord_typing_one">%1$s tipka…</string>
|
||||
<string name="concord_typing_two">%1$s in %2$s tipkata…</string>
|
||||
<string name="concord_typing_many">Več ljudi tipka…</string>
|
||||
<string name="concord_create_title">Nov kanal Concord</string>
|
||||
<string name="concord_create_name">Ime</string>
|
||||
<string name="concord_create_about">Opis (neobvezno)</string>
|
||||
<string name="concord_create_relays">Releji</string>
|
||||
<string name="concord_create_icon">URL ikone (izbirno)</string>
|
||||
<plurals name="concord_channel_count">
|
||||
<item quantity="one">%1$d kanal</item>
|
||||
<item quantity="two">%1$d kanala</item>
|
||||
<item quantity="few">%1$d kanali</item>
|
||||
<item quantity="other">%1$d kanalov</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_workspaces_count">
|
||||
<item quantity="one">%1$d delovni prostor</item>
|
||||
<item quantity="two">%1$d delovna prostora</item>
|
||||
<item quantity="few">%1$d delovni prostori</item>
|
||||
<item quantity="other">%1$d delovnih prostorov</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_workspaces_channel_count">
|
||||
<item quantity="one">%1$d kanal</item>
|
||||
<item quantity="two">%1$d kanala</item>
|
||||
<item quantity="few">%1$d kanali</item>
|
||||
<item quantity="other">%1$d kanalov</item>
|
||||
</plurals>
|
||||
<plurals name="concord_member_count">
|
||||
<item quantity="one">%1$d član</item>
|
||||
<item quantity="two">%1$d člana</item>
|
||||
<item quantity="few">%1$d člani</item>
|
||||
<item quantity="other">%1$d članov</item>
|
||||
</plurals>
|
||||
<plurals name="concord_unread_messages">
|
||||
<item quantity="one">%1$d novo sporočilo</item>
|
||||
<item quantity="two">%1$d nova sporočila</item>
|
||||
@@ -337,32 +392,52 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="concord_channel_no_messages">Še ni sporočil</string>
|
||||
<string name="concord_create_action">Ustvari</string>
|
||||
<string name="concord_invite_action">Povabi ljudi</string>
|
||||
<string name="concord_invite_title">Povezava za povabilo</string>
|
||||
<string name="concord_make_admin">Povišaj v administratorja</string>
|
||||
<string name="concord_remove_admin">Odstrani administratorja</string>
|
||||
<string name="concord_ban_user">Izključi</string>
|
||||
<string name="concord_ban_user_title">Izključim iz te skupnosti?</string>
|
||||
<string name="concord_ban_user_body">Ta član bo dodan na seznam prepovedi skupnosti. Vsi člani bodo skrili njegova sporočila in zavrgli njegove prihodnje objave. Pozneje ga lahko ponovno omogočite.</string>
|
||||
<string name="concord_create_icon_hint">Nastavi ikono skupnosti</string>
|
||||
<string name="concord_create_relays_desc">Releji, ki shranjujejo šifrirana sporočila te skupnosti. Pustite prazno, če želite uporabiti svoje.</string>
|
||||
<string name="concord_edit_title">Uredi skupnost</string>
|
||||
<string name="concord_edit_save">Shrani</string>
|
||||
<string name="concord_members_title">Člani</string>
|
||||
<string name="concord_members_empty">Za zdaj ni lastnika, skrbnikov ali blokiranih članov za prikaz.</string>
|
||||
<string name="concord_members_make_admin">Povišaj v administratorja</string>
|
||||
<string name="concord_members_remove_admin">Odstrani skrbnika</string>
|
||||
<string name="concord_members_ban">Blokiraj</string>
|
||||
<string name="concord_members_unban">Odstrani blokado</string>
|
||||
<string name="concord_members_remove">Odstrani iz skupnosti</string>
|
||||
<string name="concord_members_remove_title">Odstranim člana?</string>
|
||||
<string name="concord_members_remove_message">S tem se zamenja šifrirni ključ skupnosti, tako da ta član ne more več brati ničesar, kar bo poslano kasneje. Vsem ostalim se ključ samodejno posodobi. Tega ni mogoče razveljaviti.</string>
|
||||
<string name="concord_members_remove_confirm">Odstrani</string>
|
||||
<string name="concord_members_roles">Vloge…</string>
|
||||
<string name="concord_members_roles_title">Dodeli vloge</string>
|
||||
<string name="concord_members_roles_message">Izberite vse vloge, ki jih mora imeti ta član. Odznačitev vloge jo odstrani.</string>
|
||||
<string name="concord_members_roles_save">Shrani</string>
|
||||
<string name="concord_members_roles_out_of_reach">Nimate višjega položaja kot ta član</string>
|
||||
<string name="concord_members_roles_none_assignable">Ni vlog, ki bi jih lahko dodelili</string>
|
||||
<string name="concord_members_roles_failed">Vloge tega člana ni bilo mogoče posodobiti.</string>
|
||||
<string name="concord_role_owner">Lastnik</string>
|
||||
<string name="concord_role_admin">Administrator</string>
|
||||
<string name="concord_role_banned">Blokiran</string>
|
||||
<string name="concord_invite_card_join">Pridruži se skupnosti</string>
|
||||
<string name="concord_invite_card_subtitle">Povabilo Concord skupnosti</string>
|
||||
<string name="concord_invite_naddr_label">Povabilo v Concord (odprite celotno povezavo za povabilo, da se pridružite)</string>
|
||||
<string name="concord_server_label">Concord</string>
|
||||
<string name="concord_view_mode_title">Prikaz skupnosti Concord</string>
|
||||
<string name="concord_view_inline">Vrstično</string>
|
||||
<string name="concord_view_grouped">Po skupnosti</string>
|
||||
<string name="concord_view_inline_desc">Prikaži vsak kanal kot lasten pogovor, vmešan med vaše klepete.</string>
|
||||
<string name="concord_view_grouped_desc">Zložite kanale posamezne skupnosti v eno vrstico, ki je postavljena na njeno najnovejše sporočilo.</string>
|
||||
<!-- Reply-mode toggle in the chat composer: reply inline in the timeline vs pull it aside into a thread. -->
|
||||
<string name="chat_reply_in_chat">v pogovoru</string>
|
||||
<string name="chat_reply_in_chat">V klepetu</string>
|
||||
<string name="chat_reply_in_thread">V nizu objav</string>
|
||||
<!-- Title of the minichat (thread) screen opened from a chat message. -->
|
||||
<string name="chat_minichat_title">Niz objav</string>
|
||||
<!-- Title of the picked-image confirmation dialog when attaching a picture to a thread reply. -->
|
||||
<string name="chat_send_image_title">Pošlji sliko</string>
|
||||
<!-- Chip on a chat message that opens its thread ("minichat") of kind-1111 replies. -->
|
||||
<plurals name="chat_minichat_reply_count">
|
||||
<item quantity="one">%1$d odgovor</item>
|
||||
@@ -377,7 +452,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="chats_reply_not_found">Ne nejdem tega sporočila</string>
|
||||
<!-- Reply subtitle when every relay genuinely bottomed out (no stalls) and it still wasn't there. -->
|
||||
<string name="chats_reply_searched">Preiskani vsi releji · dotakni se za ogled</string>
|
||||
<string name="dm_sender_reported_who">Kdo je dal prijavo?</string>
|
||||
<string name="dm_sender_reported_dismiss">Opusti</string>
|
||||
<string name="dm_sender_reported_hide">Skrij</string>
|
||||
<string name="dm_sender_reported_subtitle">Oseba, ki jo spremljate, je opozorila na ta račun.</string>
|
||||
<string name="dm_sender_reported_icon">Prijavljeno</string>
|
||||
<!-- Overflow count on the capped reporter avatar row, e.g. "+21". -->
|
||||
<string name="dm_sender_reported_more_count">+%1$d</string>
|
||||
<string name="error_loading_replies">"Napaka pri nalaganju odgovorov: "</string>
|
||||
<string name="try_again">Poskusi ponovno</string>
|
||||
<string name="notification_feed_is_empty">Ni še obvestil.</string>
|
||||
@@ -397,6 +478,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
So odlične za odprte skupnosti, povezane s specifičnimi temami. Nekatere od teh skupin so kratkotrajne
|
||||
zato sporočila v klepetu sčasoma izginejo</string>
|
||||
<string name="public_chat">Javni pogovor</string>
|
||||
<string name="new_public_chat">Ustvari nov javen klepet</string>
|
||||
<string name="marmot_group">MLS skupina</string>
|
||||
<string name="marmot_group_no_messages_yet">Še ni sporočil</string>
|
||||
<string name="public_chat_title">Metapodatki Javnega Klepeta</string>
|
||||
@@ -474,6 +556,15 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="share_as_image_url">Deli URL slike</string>
|
||||
<string name="share_as_image_generating">Ustvarjam predogled…</string>
|
||||
<string name="share_as_image_watermark">Deljeno z Amethyst</string>
|
||||
<string name="share_as_qr">Deli kot QR kodo</string>
|
||||
<string name="share_as_qr_mode_web">Spletna povezava</string>
|
||||
<string name="share_as_qr_mode_nostr">Nostr povezava</string>
|
||||
<string name="share_as_qr_hint_web">Skeniraj s katerokoli kamero na telefonu</string>
|
||||
<string name="share_as_qr_hint_nostr">Skeniraj z Nostr aplikacijo</string>
|
||||
<string name="share_as_qr_kind_picture">Fotografija</string>
|
||||
<string name="share_as_qr_code_description_web">Koda QR s spletno povezavo do te opombe</string>
|
||||
<string name="share_as_qr_code_description_nostr">Koda QR s povezavo Nostr do te opombe</string>
|
||||
<string name="share_as_qr_thumbnail_hidden_sensitive">Miniatura je skrita zaradi občutljive vsebine</string>
|
||||
<string name="quick_action_copy_user_id">Avtorjev ID</string>
|
||||
<string name="quick_action_copy_note_id">ID zapiska</string>
|
||||
<string name="quick_action_copy_text">Kopiraj tekst</string>
|
||||
@@ -809,6 +900,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="napplet_consent_storage">Ta nApplet želi uporabiti svojo zasebno shrambo.</string>
|
||||
<string name="napplet_consent_pay">Ta nApplet želi plačati Lightning račun.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_pay_no_amount">⚠ Ta nApplet želi plačati račun Lightning, ki ne določa zneska — prejemnik plačila določi, koliko se odtrga. To dovolite samo, če mu zaupate.</string>
|
||||
<string name="napplet_consent_resource">Ta nApplet želi pridobiti spletni vir.</string>
|
||||
<string name="napplet_consent_upload">Ta nApplet želi naložiti datoteko na vaš medijski strežnik.</string>
|
||||
<string name="napplet_consent_notify">Ta nApplet vam želi prikazati obvestila.</string>
|
||||
@@ -823,11 +915,74 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
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">Sledi %1$d novemu računu</item>
|
||||
<item quantity="two">Sledi %1$d novima računoma</item>
|
||||
<item quantity="few">Sledi %1$d novim računom</item>
|
||||
<item quantity="other">Sledi %1$d novim računom</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_follow_removed">
|
||||
<item quantity="one">PRENEHA SLEDITI %1$d računu</item>
|
||||
<item quantity="two">PRENEHA SLEDITI %1$d računoma</item>
|
||||
<item quantity="few">PRENEHA SLEDITI %1$d računom</item>
|
||||
<item quantity="other">PRENEHA SLEDITI %1$d računom</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_relay_added">
|
||||
<item quantity="one">doda %1$d rele</item>
|
||||
<item quantity="two">doda %1$d releja</item>
|
||||
<item quantity="few">doda %1$d releje</item>
|
||||
<item quantity="other">doda %1$d relejev</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_relay_removed">
|
||||
<item quantity="one">ODSTRANI %1$d rele</item>
|
||||
<item quantity="two">ODSTRANI %1$d releja</item>
|
||||
<item quantity="few">ODSTRANI %1$d releje</item>
|
||||
<item quantity="other">ODSTRANI %1$d relejev</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_mute_added">
|
||||
<item quantity="one">utiša %1$d dodatno osebo</item>
|
||||
<item quantity="two">utiša %1$d dodatni osebi</item>
|
||||
<item quantity="few">utiša %1$d dodatne osebe</item>
|
||||
<item quantity="other">utiša %1$d dodatnih oseb</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_diff_mute_removed">
|
||||
<item quantity="one">PREKINE UTIŠANJE %1$d osebe</item>
|
||||
<item quantity="two">PREKINE UTIŠANJE %1$d oseb</item>
|
||||
<item quantity="few">PREKINE UTIŠANJE %1$d oseb</item>
|
||||
<item quantity="other">PREKINE UTIŠANJE %1$d oseb</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">⚠ To sledi uporabniku %1$s.</string>
|
||||
<string name="napplet_consent_diff_unfollow_one">⚠ To PRENEHA SLEDITI uporabniku %1$s.</string>
|
||||
<string name="napplet_consent_diff_mute_one">⚠ To utiša uporabnika %1$s.</string>
|
||||
<string name="napplet_consent_diff_unmute_one">⚠ To PREKINE UTIŠANJE uporabnika %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">⚠ To prepiše vaš seznam sledilcev: %1$s.</string>
|
||||
<string name="napplet_consent_diff_relays">⚠ To prepiše vaš seznam relejev: %1$s. Vaše objave in branja se premaknejo skupaj z njim.</string>
|
||||
<string name="napplet_consent_diff_mutes">⚠ To prepiše vaš seznam utišanih: %1$s. Utišane besede in oznake niso prikazane tukaj — za celoten seznam tapnite »Pokaži dogodek«</string>
|
||||
<string name="napplet_consent_diff_joiner">%1$s in %2$s</string>
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<string name="napplet_consent_diff_none">To ponovno objavi vaš obstoječi seznam nespremenjen.</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">To zapisuje seznam z %1$d vnosom. Amethyst nima zajete kopije za primerjavo.</item>
|
||||
<item quantity="two">⚠ To zapisuje seznam z %1$d vnosoma. Amethyst nima zajete kopije za primerjavo.</item>
|
||||
<item quantity="few">⚠ To zapisuje seznam z %1$d vnosi. Amethyst nima zajete kopije za primerjavo.</item>
|
||||
<item quantity="other">⚠ To zapisuje seznam z %1$d vnosi. Amethyst nima zajete kopije za primerjavo.</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_effect_deletes">
|
||||
<item quantity="one">⚠ To zahteva izbris %1$d vašega dogodka.</item>
|
||||
<item quantity="two">⚠ To zahteva izbris %1$d vaših dogodkov.</item>
|
||||
<item quantity="few">⚠ To zahteva izbris %1$d vaših dogodkov.</item>
|
||||
<item quantity="other">⚠ To zahteva izbris %1$d vaših dogodkov.</item>
|
||||
</plurals>
|
||||
<plurals name="napplet_consent_effect_tags">
|
||||
<item quantity="one">Vsebuje %1$d oznako. Tapnite »Pokaži dogodek«, da vidite, kaj bo podpisano.</item>
|
||||
<item quantity="two">Vsebuje %1$d oznaki. Tapnite »Pokaži dogodek«, da vidite, kaj bo podpisano.</item>
|
||||
<item quantity="few">Vsebuje %1$d oznake. Tapnite »Pokaži dogodek«, da vidite, kaj bo podpisano.</item>
|
||||
<item quantity="other">Vsebuje %1$d oznak. Tapnite »Pokaži dogodek«, da vidite, kaj bo podpisano.</item>
|
||||
</plurals>
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Povezava z Nostrom</string>
|
||||
<string name="napplet_connect_subtitle">se želi povezati z vašim nostr računom</string>
|
||||
@@ -864,9 +1019,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">berem tvoja zasebna sporočila</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<string name="napplet_op_decrypt_from">preberite zasebna sporočila z %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">Vedno dovoli za %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 tega sporočila ni mogel dešifrirati. Morda ni naslovljeno na ta račun.</string>
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<string name="nip46_signer_messages_with">Sporočila z</string>
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Povezane aplikacije</string>
|
||||
<string name="napplet_permissions_revoke_all">Prekliči vsa dovoljenja</string>
|
||||
@@ -878,6 +1037,103 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="napplet_decision_deny">Zavrni</string>
|
||||
<string name="napplet_connected_apps_search_keywords">Aplikacije, dovoljenja, podpisovalnik, napplet, nsite, spletna aplikacije zaupnik povezan.</string>
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<string name="nip46_signer_title">Oddaljen podpisnik</string>
|
||||
<string name="nip46_signer_search_keywords">Podpisnik, bunker, NIP-46, Nostr Connect, oddaljeno podpisovanje</string>
|
||||
<string name="nip46_signer_explainer">Dovolite drugim aplikacijam podpisovanje z vašim ključem. Amethyst posluša na vaših prejetih relejih in pred podpisom preveri dovoljenja posamezne aplikacije — pri čemer uporablja enake ravni zaupanja kot Povezane aplikacije.</string>
|
||||
<string name="nip46_signer_enable">Deluj kot oddaljeni podpisnik</string>
|
||||
<plurals name="nip46_signer_status_listening">
|
||||
<item quantity="one">Poslušanje na %1$d releju</item>
|
||||
<item quantity="two">Poslušanje na %1$d relejih</item>
|
||||
<item quantity="few">Poslušanje na %1$d relejih</item>
|
||||
<item quantity="other">Poslušanje na %1$d relejih</item>
|
||||
</plurals>
|
||||
<string name="nip46_signer_status_no_relays">Ni nastavljenih vhodnih relejev. Dodajte vhodne releje, da lahko aplikacije dosežejo vašega podpisnika.</string>
|
||||
<plurals name="nip46_signer_relays_all_live">
|
||||
<item quantity="one">Poslušanje na %1$d vhodnem releju</item>
|
||||
<item quantity="two">Poslušanje na %1$d vhodnih relejih, oba poveza</item>
|
||||
<item quantity="few">Poslušanje na %1$d vhodnih relejih, vsi povezani</item>
|
||||
<item quantity="other">Poslušanje na %1$d vhodnih relejih, vsi povezani</item>
|
||||
</plurals>
|
||||
<string name="nip46_signer_relays_some_down">Povezan na %1$d od \%2$d relejev</string>
|
||||
<string name="nip46_signer_bunker_uri_label">Vaš bunker naslov</string>
|
||||
<string name="nip46_signer_bunker_uri_hint">Prilepite to v drugo aplikacijo, da jo povežete s svojim ključem.</string>
|
||||
<string name="nip46_signer_copy">Kopiraj</string>
|
||||
<string name="nip46_signer_copied">Naslov bunkerja kopiran</string>
|
||||
<string name="nip46_signer_regenerate">Nov naslov</string>
|
||||
<string name="nip46_signer_regenerated">Generiran nov bunker naslov</string>
|
||||
<string name="nip46_signer_rotate_confirm_title">Ustvarim nov naslov?</string>
|
||||
<string name="nip46_signer_rotate_confirm_message">S tem ustvarite nov bunker naslov in prekinete povezavo z vsemi trenutno povezanimi aplikacijami. Nihče s starim naslovom – vključno s pošiljatelji neželene pošte – vas ne more več doseči. Svoje aplikacije znova povežite s skeniranjem nove kode.</string>
|
||||
<string name="nip46_signer_rotate_confirm_button">Generiraj</string>
|
||||
<string name="nip46_signer_cancel">Prekliči</string>
|
||||
<string name="nip46_signer_connect_label">Poveži aplikacijo</string>
|
||||
<string name="nip46_signer_connect_hint">prilepi nostrconnect:// povezavo</string>
|
||||
<string name="nip46_signer_connect_button">Poveži</string>
|
||||
<string name="nip46_signer_manage_apps">Upravljanje povezanih aplikacij</string>
|
||||
<string name="nip46_signer_apps_empty">Z vašim podpisnikom še ni povezana nobena aplikacija.\n\nZa povezavo skenirajte ali prilepite kodo aplikacije. Povezane aplikacije se prikažejo tukaj, neaktivne pa se po enem tednu samodejno odstranijo.</string>
|
||||
<string name="nip46_signer_app_last_used">uporabljeno %1$s</string>
|
||||
<string name="nip46_signer_app_online">Povezano</string>
|
||||
<string name="nip46_signer_app_offline">Brez povezave</string>
|
||||
<string name="nip46_signer_app_reconnect">Ponovno poveži</string>
|
||||
<string name="nip46_signer_reconnecting">Ponovno povezovanje z releji…</string>
|
||||
<string name="nip46_signer_app_relays_title">Releji</string>
|
||||
<string name="nip46_signer_app_relays_inbox">Podpisuje preko vaših vhodnih relejev — ta aplikacija ni dodala svojih, zato ne ohranja dodatne povezave v ozadju.</string>
|
||||
<string name="nip46_signer_app_relays_own_hint">Amethyst ohranja povezavo v ozadju z vsakim od njih, dokler ta aplikacija ostaja povezana. Pozabite aplikacijo, da jih prekinete.</string>
|
||||
<plurals name="nip46_signer_app_relay_count">
|
||||
<item quantity="one">%1$d rele</item>
|
||||
<item quantity="two">%1$d releja</item>
|
||||
<item quantity="few">%1$d releji</item>
|
||||
<item quantity="other">%1$d relejev</item>
|
||||
</plurals>
|
||||
<string name="nip46_signer_connected_ok">Aplikacija je povezana. Amethyst zdaj zanjo podpisuje v ozadju.</string>
|
||||
<string name="nip46_signer_connected_named">Povezano %1$s. Amethyst zdaj za to podpisuje v ozadju.</string>
|
||||
<string name="nip46_signer_connect_invalid">Ni veljavna povezava nostrconnect://</string>
|
||||
<string name="nip46_signer_connect_no_relays">Ta povezava ne vsebuje nobenega releja za povezavo</string>
|
||||
<string name="nip46_signer_connect_failed">Ni se bilo mogoče povezati: %1$s</string>
|
||||
<string name="nip46_signer_connect_declined">Povezava je bila zavrnjena</string>
|
||||
<string name="nip46_connect_requests_title">Ta aplikacija zahteva:</string>
|
||||
<string name="nip46_signer_readonly">To je račun samo za branje in ne more podpisovati.</string>
|
||||
<string name="nip46_signer_remote_app">Aplikacija oddaljenega podpisnika</string>
|
||||
<string name="nip46_signer_background_hint">Amethyst ohranja povezavo v ozadju (prikazano kot trajno obvestilo), da lahko odgovarja na zahteve za podpisovanje, ko je zaprt.</string>
|
||||
<string name="nip46_signer_hero_title">Podpisovanje za druge aplikacije</string>
|
||||
<string name="nip46_signer_turn_on">Vklopite podpisnika</string>
|
||||
<string name="nip46_signer_live">V živo</string>
|
||||
<plurals name="nip46_signer_batch_title">
|
||||
<item quantity="one">%1$d zahteva za podpis</item>
|
||||
<item quantity="two">%1$d zahtevi za podpis</item>
|
||||
<item quantity="few">%1$d zahteve za podpis</item>
|
||||
<item quantity="other">%1$d zahtev za podpis</item>
|
||||
</plurals>
|
||||
<string name="nip46_signer_batch_select_all">Izberi vse</string>
|
||||
<string name="nip46_signer_batch_select_none">Nič ne izberi</string>
|
||||
<string name="nip46_signer_batch_remember">Zapomni si te za vsako aplikacijo</string>
|
||||
<string name="nip46_signer_batch_signing_as">kot %1$s</string>
|
||||
<string name="nip46_signer_batch_allow">Dovoli %1$d</string>
|
||||
<string name="nip46_signer_batch_deny">Zavrni %1$d</string>
|
||||
<string name="nip46_signer_activity_title">Nedavna aktivnost</string>
|
||||
<string name="nip46_signer_activity_empty">Še ni bilo obdelanih zahtev.</string>
|
||||
<string name="nip46_signer_activity_denied">zavrnjeno</string>
|
||||
<string name="nip46_signer_act_signed_kind">Podpisan dogodek (tip(kind) %1$d)</string>
|
||||
<string name="nip46_signer_act_encrypted">Šifrirano sporočilo</string>
|
||||
<string name="nip46_signer_act_decrypted">Dešifrirano sporočilo</string>
|
||||
<string name="nip46_signer_act_shared_pubkey">Deljen vaš javni ključ</string>
|
||||
<string name="nip46_signer_act_connected">Povezan</string>
|
||||
<string name="nip46_signer_act_ping">Ping</string>
|
||||
<string name="nip46_signer_act_listed_relays">Prikazan seznam relejev</string>
|
||||
<string name="nip46_signer_act_other">%1$s</string>
|
||||
<string name="nip46_signer_notif_channel_name">Zahteve za podpisovanje</string>
|
||||
<string name="nip46_signer_notif_channel_desc">Celozaslonski pozivi, ki vas prosijo za odobritev aplikacije, ki želi, da Amethyst z vašim ključem podpisuje, šifrira ali dešifrira.</string>
|
||||
<string name="nip46_signer_notif_sign_title">Želite odobriti zahtevo za podpisovanje?</string>
|
||||
<string name="nip46_signer_notif_connect_title">Aplikacija se želi povezati</string>
|
||||
<string name="nip46_signer_notif_tap">Tapnite za preveritev</string>
|
||||
<string name="nip46_signer_scan_caption">Skenirajte za povezavo aplikacije z vašim ključem</string>
|
||||
<string name="nip46_signer_scan_connect">Skeniraj kodo</string>
|
||||
<string name="nip46_signer_paste_link">Raje prilepite povezavo</string>
|
||||
<plurals name="nip46_signer_connected_count">
|
||||
<item quantity="one">%1$d povezana aplikacija</item>
|
||||
<item quantity="two">%1$d povezani aplikaciji</item>
|
||||
<item quantity="few">%1$d povezane aplikacije</item>
|
||||
<item quantity="other">%1$d povezanih aplikacij</item>
|
||||
</plurals>
|
||||
<string name="napplet_connected_app_trust_level">Podpisujem stopnjo zaupanja</string>
|
||||
<string name="napplet_connected_app_capabilities">Zmogljivosti</string>
|
||||
<string name="napplet_connected_app_forget">Pozabi to aplikacijo</string>
|
||||
@@ -948,6 +1204,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="nip82_older_releases_hide">Skrij starejše različice</string>
|
||||
<string name="shorts">Kratki posnetki</string>
|
||||
<string name="public_chats">Javni klepeti</string>
|
||||
<string name="location_channels">Lokacijski kanali</string>
|
||||
<string name="follow_packs">Paketi sledenih</string>
|
||||
<string name="live_streams">Posnetki v živo</string>
|
||||
<string name="nests">Zvočne sobe</string>
|
||||
@@ -1078,6 +1335,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="nest_create_submit">Začni sobo za klepet</string>
|
||||
<string name="nest_create_schedule_toggle">Načrtovano za kasneje</string>
|
||||
<string name="nest_create_when">Izberi začetni čas</string>
|
||||
<string name="agent_console_title">Konzola za agente</string>
|
||||
<string name="nests_servers_title">Strežniki gnezda</string>
|
||||
<string name="nests_servers_explainer">Izberite strežnike MoQ, na katerih Amethyst objavlja vaša gnezda. Prvi vnos bo privzeto uporabljen ob začetku novega pogovora.</string>
|
||||
<string name="nests_servers_my_section">Vaši strežniki</string>
|
||||
@@ -1302,6 +1560,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="public_bookmark_presence_indicator">že v tem javnem zaznamku</string>
|
||||
<string name="private_bookmark_presence_indicator">je zasebni zaznamek tukaj</string>
|
||||
<string name="private_rumor_mark">Zasebno — vidno le označenim sodelujočim</string>
|
||||
<string name="private_rumor_info_title">Zasebni zapisek</string>
|
||||
<string name="private_rumor_info_description">To sporočilo je bilo dostavljeno zasebno in ni podpisano. Vidijo ga lahko le označeni udeleženci, nihče zunaj pa ne more dokazati, kdo ga je napisal.</string>
|
||||
<string name="private_note">Nastavi kot zasebno: šifriraj zapis le za obveščene uporabnike</string>
|
||||
<string name="disable_private_note">Nastavi kot javno</string>
|
||||
<string name="private_note_locked">Odgovori na zasebni zapis so vedno zasebni.</string>
|
||||
@@ -1309,6 +1569,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="private_note_no_receivers">Ni še prejemnikov: ta zapis lahko vidiš samo ti. Dodaj ljudi, da ga deliš z njimi.</string>
|
||||
<string name="notify_add_user">Dodaj</string>
|
||||
<string name="notify_remove_user">Odstrani uporabnika iz obvestil</string>
|
||||
<string name="notify_mute_user">Pošiljanje obvestil. Tapnite, da utišate obvestila za tega uporabnika</string>
|
||||
<string name="notify_unmute_user">Pošiljanje obvestil. Tapnite, da utišate obvestila za tega uporabnika</string>
|
||||
<string name="notify_search_and_add_user">Poišči in dodaj prejemnika obvestil</string>
|
||||
<string name="bookmark_absence_indicator">Ni v tem zaznamku</string>
|
||||
<string name="bookmark_remove_action_desc">Odstrani zaznamek iz seznama</string>
|
||||
@@ -1408,11 +1670,96 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="local_blossom_cache_profile_pics_only">V predpomnilnik shrani le slike profilov</string>
|
||||
<string name="local_blossom_cache_profile_pics_only_caption">Lokalni predpomnilnik omeji le na slike profilov. Slike in videoposnetki iz vira vsebin se bodo prenašali neposredno z izvornih strežnikov.</string>
|
||||
<string name="no_blossom_server_message">Nimate nastavljenih Blossom strežnikov. Uporabite lahko Amethyst-ov seznam ali dodajte enega spodaj ↓</string>
|
||||
<string name="media_servers_upload_section">Vedenje ob nalaganju</string>
|
||||
<string name="blossom_mirror_uploads">Zrcaljenje naloženih datotek</string>
|
||||
<string name="blossom_mirror_uploads_caption">Po nalaganju datoteko kopirajte na svoje druge strežnike Blossom, da ostane na voljo, če se eden izklopi.</string>
|
||||
<string name="blossom_optimize_media">Optimiziraj medije na strežniku</string>
|
||||
<string name="blossom_optimize_media_caption">Naložite prek strežniške končne točke /media, da lahko odstrani metapodatke in stisne datoteko. Shranjena datoteka se lahko razlikuje od izvirnika.</string>
|
||||
<string name="manage_stored_files">Upravljanje shranjenih datotek</string>
|
||||
<string name="my_blossom_data">Moje Blossom datoteke</string>
|
||||
<string name="blossom_refresh">Osveži</string>
|
||||
<string name="blossom_sync_all">Sinhroniziraj vse</string>
|
||||
<string name="blossom_sync_gaps">Nekatere vaše datoteke še niso na vseh vaših strežnikih.</string>
|
||||
<string name="blossom_syncing">Kopiranje vaših datotek med strežniki…</string>
|
||||
<string name="blossom_sync_done">Sinhronizacija zaključena</string>
|
||||
<string name="blossom_sync_cancel">Prekliči</string>
|
||||
<string name="blossom_sync_channel_name">Blossom sinhronizacija</string>
|
||||
<string name="blossom_sync_channel_description">Prikazuje napredek med kopiranjem vaših datotek med vašimi strežniki Blossom.</string>
|
||||
<string name="manage_stored_files_empty">Na vaših strežnikih Blossom ni bilo najdenih shranjenih datotek.</string>
|
||||
<string name="blossom_stored_on">Shranjeno na</string>
|
||||
<string name="blossom_file_details">Podrobnosti datoteke</string>
|
||||
<string name="blossom_on_all_servers">Na vseh strežnikih</string>
|
||||
<string name="blossom_not_on_all_servers">Ni na vseh strežnikih</string>
|
||||
<string name="blossom_mirror_to_missing">Zrcali na manjkajoče</string>
|
||||
<string name="blossom_delete_from">Izbriši iz…</string>
|
||||
<string name="blossom_delete_from_host">Izbriši iz %1$s</string>
|
||||
<string name="blossom_report">Prijavi</string>
|
||||
<string name="blossom_open">Odpri</string>
|
||||
<string name="blossom_payment_required">Ta strežnik za nalaganje zahteva plačilo: %1$s</string>
|
||||
<string name="blossom_payment_title">Zahtevano plačilo</string>
|
||||
<string name="blossom_payment_message">%1$s za shranjevanje te datoteke zahteva plačilo preko omrežja Lightning. Za nadaljevanje plačajte iz svoje povezane denarnice.</string>
|
||||
<string name="blossom_payment_server_says">%1$s pove: \"%2$s\"</string>
|
||||
<string name="blossom_pay">Plačaj</string>
|
||||
<plurals name="blossom_pay_sats">
|
||||
<item quantity="one">Plačaj %1$d sat</item>
|
||||
<item quantity="two">Plačaj %1$d sata</item>
|
||||
<item quantity="few">Plačaj %1$d sate</item>
|
||||
<item quantity="other">Plačaj %1$d satov</item>
|
||||
</plurals>
|
||||
<string name="blossom_report_title">Prijavi blob</string>
|
||||
<string name="blossom_report_comment_hint">Razlog (izbirno)</string>
|
||||
<string name="blossom_send">Pošlji</string>
|
||||
<string name="blossom_more_actions">Več možnosti</string>
|
||||
<string name="blossom_import_menu">Uvozi datoteke…</string>
|
||||
<string name="blossom_import_title">Uvozi datoteke</string>
|
||||
<string name="blossom_import_intro">Preveri druge strežnike Blossom za datoteke, ki si jih naložil drugje, in jih kopiraj na svoje strežnike.</string>
|
||||
<string name="blossom_import_sources_section">Strežniki za preverjanje</string>
|
||||
<string name="blossom_import_enable_all">Omogoči vse</string>
|
||||
<string name="blossom_import_disable_all">Onemogoči vse</string>
|
||||
<string name="blossom_import_add_url_label">Ali pa prilepite naslov strežnika</string>
|
||||
<string name="blossom_import_scan">Preveri stražnike</string>
|
||||
<string name="blossom_import_scanning">PReverjam…</string>
|
||||
<string name="blossom_import_source_failed">Strežnika ni mogoče doseči</string>
|
||||
<string name="blossom_import_none_found">Na izbranih strežnikih ni bilo najdenih novih datotek.</string>
|
||||
<string name="blossom_import_no_targets">Najprej dodajte svoje strežnike Blossom, da boste imeli kam kopirati uvožene datoteke.</string>
|
||||
<string name="blossom_import_manage_servers">Upravljanje mojih strežnikov</string>
|
||||
<string name="blossom_import_busy">Sinhronizacija datotek že poteka. Poskusite znova, ko se zaključi.</string>
|
||||
<plurals name="blossom_import_files_found">
|
||||
<item quantity="one">%1$d datoteka</item>
|
||||
<item quantity="two">%1$d datoteki</item>
|
||||
<item quantity="few">%1$d datoteke</item>
|
||||
<item quantity="other">%1$d datotek</item>
|
||||
</plurals>
|
||||
<plurals name="blossom_import_found_files">
|
||||
<item quantity="one">Najdena %1$d datoteka za uvoz na vaše strežnike.</item>
|
||||
<item quantity="two">Najdeni %1$d datoteki za uvoz na vaše strežnike.</item>
|
||||
<item quantity="few">Najdene %1$d datoteke za uvoz na vaše strežnike.</item>
|
||||
<item quantity="other">Najdenih %1$d datotek za uvoz na vaše strežnike.</item>
|
||||
</plurals>
|
||||
<plurals name="blossom_import_start_button">
|
||||
<item quantity="one">Uvozi %1$d datoteko</item>
|
||||
<item quantity="two">Uvozi %1$d datoteki</item>
|
||||
<item quantity="few">Uvozi %1$d datoteke</item>
|
||||
<item quantity="other">Uvozi %1$d datotek</item>
|
||||
</plurals>
|
||||
<string name="recommended_media_servers">Priporočljivi medijski strežniki</string>
|
||||
<string name="built_in_servers_description">Privzeti seznam Amethysta. Lahko jih dodate posamično ali dodate celoten seznam.</string>
|
||||
<string name="use_default_servers">Uporabi privzeti seznam</string>
|
||||
<string name="add_media_server">Dodaj medijski strežnik</string>
|
||||
<string name="delete_media_server">Odstrani medijski strežnik</string>
|
||||
<string name="media_servers_reorder_hint">Podrsajte za spremembo vrstnega reda. Naložene datoteke se zaporedoma poskušajo poslati na strežnike od zgoraj navzdol.</string>
|
||||
<string name="media_servers_priority_section">Prednost nalaganja</string>
|
||||
<string name="media_servers_add_section">Dodaj strežnik</string>
|
||||
<string name="media_servers_recommended_label">Priporočeno</string>
|
||||
<string name="media_servers_add_url_label">Ali prilepite naslov strežnika</string>
|
||||
<string name="media_servers_cache_section">Predpomnilnik naprave</string>
|
||||
<string name="media_server_primary_badge">Primarni</string>
|
||||
<string name="media_server_added">Dodano</string>
|
||||
<string name="media_server_reorder">Razvrsti strežnik</string>
|
||||
<string name="media_server_status_online">Povezano</string>
|
||||
<string name="media_server_status_slow">Počasno</string>
|
||||
<string name="media_server_status_offline">Brez povezave</string>
|
||||
<string name="media_server_status_checking">Preverjam…</string>
|
||||
<string name="payment_targets">Cilji plačil</string>
|
||||
<string name="payment_targets_explainer">Objavite svoje naslove za plačila, da vam lahko drugi neposredno pošiljajo sredstva.</string>
|
||||
<string name="payment_targets_section_explainer">Dodajte naslove za plačila za različna omrežja (npr. bitcoin, lightning, monero).</string>
|
||||
@@ -1423,6 +1770,20 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="no_payment_app_found">Ni aplikacije za tovrstno plačilo. Namestite združljivo denarnico.</string>
|
||||
<string name="no_payment_app_found_for_type">Ni nameščene aplikacije za plačila %1$s. Namesti združljivo denarnico.</string>
|
||||
<string name="error_dialog_payment_error">Plačila ni mogoče odpreti</string>
|
||||
<string name="bolt12_offers">BOLT12 Ponudbe</string>
|
||||
<string name="bolt12_offers_explainer">Objavite večkrat uporabne ponudbe BOLT12, da vam lahko drugi pošiljajo zap-e preko omrežja Lightning brez ustvarjanja novega računa vsakič.</string>
|
||||
<string name="bolt12_offers_section_explainer">Dodajte eno ali več ponudb BOLT12 (lno1…). Objavljene so javno, tako da vam lahko plača kdo koli.</string>
|
||||
<string name="no_bolt12_offers_message">Ni nastavljenih ponudb BOLT12. Dodajte jo spodaj ↓</string>
|
||||
<string name="bolt12_offer">BOLT12 ponudba (lno1…)</string>
|
||||
<string name="invalid_bolt12_offer">Neveljavna ponudba BOLT12</string>
|
||||
<string name="delete_bolt12_offer">Izbriši BOLT12 ponudbo</string>
|
||||
<string name="bolt12_pay_with_wallet">Plačaj s povezano denarnico</string>
|
||||
<string name="bolt12_payment_amount_sats">Znesek (sat)</string>
|
||||
<string name="bolt12_payment_sent">BOLT12 plačilo poslano</string>
|
||||
<string name="bolt12_payment_failed">BOLT12 plačilo ni uspelo: %1$s</string>
|
||||
<string name="bolt12_zap_error">BOLT12 zap napaka</string>
|
||||
<string name="bolt12_zap_paid_no_receipt">Plačano preko BOLT12, vendar denarnica ni vrnila dokazila, zato potrdilo o zapu ni bilo objavljeno.</string>
|
||||
<string name="bolt12_zap_invalid_receipt">Plačano preko BOLT12, vendar dokazilo o plačilu ni prestalo preverjanja, zato potrdilo o zapu ni bilo objavljeno.</string>
|
||||
<string name="uploading_state_ready">Čakam na začetek</string>
|
||||
<string name="uploading_state_compressing">Zgoščujem</string>
|
||||
<string name="uploading_state_uploading">Nalagam</string>
|
||||
@@ -1440,6 +1801,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="follow_list_kind3follows_users_only">Vsi sledeni sledenih</string>
|
||||
<string name="follow_list_kind3_follows_users_only">Privzeti seznam sledenih</string>
|
||||
<string name="follow_list_aroundme">V moji okolici</string>
|
||||
<string name="follow_list_teleport">Teleportacija na lokacijo…</string>
|
||||
<string name="follow_geohash">Sledi tej lokaciji</string>
|
||||
<string name="unfollow_geohash">Prenehaj slediti tej lokaciji</string>
|
||||
<string name="geo_post_posting_to">Pošiljam v</string>
|
||||
<string name="geo_post_change_place">Spremeni</string>
|
||||
<string name="follow_list_global">Globalno</string>
|
||||
<string name="follow_list_curated">Izbrani</string>
|
||||
<string name="follow_list_mine">Moje</string>
|
||||
@@ -1524,8 +1890,15 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s sat</string>
|
||||
<string name="app_notification_zaps_channel_message_from">Od %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">za %1$s</string>
|
||||
<string name="app_notification_payments_channel_name">Plačilo prejeto</string>
|
||||
<string name="app_notification_payments_channel_description">Obvesti vas, ko vaša povezana denarnica prejme plačilo, ki ni Nostr zap</string>
|
||||
<string name="app_notification_payments_channel_message">Prejeto %1$s sat</string>
|
||||
<string name="app_notification_payments_summary">Nova plačila</string>
|
||||
<string name="app_notification_reply_label">Odgovori</string>
|
||||
<string name="app_notification_mark_read_label">Označi kot prebrano</string>
|
||||
<string name="app_notification_me">Jaz</string>
|
||||
<string name="app_notification_new_message">Novo sporočilo</string>
|
||||
<string name="app_notification_added_to_group">Dodani ste bili v %1$s</string>
|
||||
<string name="app_notification_dms_summary">Novo sporočilo</string>
|
||||
<string name="app_notification_zaps_summary">Novi zapi</string>
|
||||
<string name="app_notification_reactions_channel_name">Odzivne ikone</string>
|
||||
@@ -1548,13 +1921,48 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="app_notification_mentions_channel_message">%1$s vas je omenil</string>
|
||||
<string name="app_notification_mentions_summary">Nove omembe</string>
|
||||
<!-- Notification channel groups (organize the per-kind channels in system settings) -->
|
||||
<string name="app_notification_group_messages">Sporočila</string>
|
||||
<string name="app_notification_group_social">Družabno</string>
|
||||
<string name="app_notification_group_payments">Plačila</string>
|
||||
<string name="app_notification_group_content">Vsebina</string>
|
||||
<string name="app_notification_group_developer">Razvijalec</string>
|
||||
<string name="app_notification_group_games">Igre</string>
|
||||
<!-- Reposts -->
|
||||
<string name="app_notification_reposts_channel_name">Deljenja</string>
|
||||
<string name="app_notification_reposts_channel_description">Obvesti vas, ko nekdo znova objavi vašo objavo</string>
|
||||
<string name="app_notification_reposts_channel_message">Oseba %1$s je delila vašo objavo</string>
|
||||
<string name="app_notification_reposts_summary">Nove delitve</string>
|
||||
<!-- Media (picture & video posts that mention you) -->
|
||||
<string name="app_notification_media_channel_name">Medijske vsebine</string>
|
||||
<string name="app_notification_media_channel_description">Obvesti vas, ko vas nekdo označi na fotografiji ali v videoposnetku</string>
|
||||
<string name="app_notification_media_channel_message_photo">Oseba %1$s je delila fotografijo</string>
|
||||
<string name="app_notification_media_channel_message_video">Oseba %1$s je delila video</string>
|
||||
<string name="app_notification_media_summary">Nova medijska vsebina</string>
|
||||
<!-- Articles & highlights -->
|
||||
<string name="app_notification_articles_channel_name">Članki in poudarki</string>
|
||||
<string name="app_notification_articles_channel_description">Obvesti vas, ko vas nekdo emeni ali označi v članku</string>
|
||||
<string name="app_notification_articles_channel_message">%1$s vas je omenil v članku</string>
|
||||
<string name="app_notification_articles_channel_message_highlight">%1$s je poudaril vaš članek</string>
|
||||
<string name="app_notification_articles_summary">Novi članki</string>
|
||||
<!-- Code & git -->
|
||||
<string name="app_notification_code_channel_name">Koda in Git</string>
|
||||
<string name="app_notification_code_channel_description">Obvesti vas o težavah, popravkih in zahtevkih za poteg (PR)</string>
|
||||
<string name="app_notification_code_channel_message_issue">%1$s je odprl/a težavo</string>
|
||||
<string name="app_notification_code_channel_message_patch">%1$s je poslal/a popravek</string>
|
||||
<string name="app_notification_code_channel_message_pr">%1$s je odprl/a zahtevo za popravek (PR)</string>
|
||||
<string name="app_notification_code_channel_message_pr_update">%1$s je osvežil/a zahtevo za popravek (PR)</string>
|
||||
<string name="app_notification_code_summary">Nove aktivnosti v kodi</string>
|
||||
<!-- Badges -->
|
||||
<string name="app_notification_badges_channel_name">Značke</string>
|
||||
<string name="app_notification_badges_channel_description">Vas obvesti ko si prislužite značko</string>
|
||||
<string name="app_notification_badges_channel_message">Prislužili ste si značko</string>
|
||||
<string name="app_notification_badges_channel_message_from">Nagrajeni ste bili od %1$s</string>
|
||||
<string name="app_notification_badges_summary">Nove značke</string>
|
||||
<!-- Nutzaps & onchain zaps (rendered on the Zaps channel) -->
|
||||
<string name="app_notification_nutzap_channel_message_from">Nutzap od %1$s</string>
|
||||
<string name="app_notification_onchain_channel_message_from">Onchain zap od %1$s</string>
|
||||
<!-- Poll mentions (rendered on the Mentions channel) -->
|
||||
<string name="app_notification_poll_channel_message">%1$s sprašuje</string>
|
||||
<!-- Call notifications and UI -->
|
||||
<string name="app_notification_calls_channel_name">Dohodni klici</string>
|
||||
<string name="app_notification_calls_channel_description">Obvestila o dohodnih glasovnih in video klicih</string>
|
||||
@@ -1620,6 +2028,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="always_on_notif_connecting">Povezovanje vhodnih relejev\u2026</string>
|
||||
<string name="always_on_notif_setting_title">Vedno aktivna obvestila</string>
|
||||
<string name="always_on_notif_setting_description">Ohranja stalno povezavo z vašimi releji za takojšnjo dostavo obvestil. Prikazuje trajno obvestilo. Porabi več baterije, a zagotavlja, da ne zamudite nobenega sporočila.</string>
|
||||
<string name="always_on_notif_tile_label">Storitev obveščanja</string>
|
||||
<string name="always_on_notif_tile_subtitle_on">Vklop</string>
|
||||
<string name="always_on_notif_tile_subtitle_off">Izklop</string>
|
||||
<string name="notification_service_master_title">Storitev obveščanja v ozadju</string>
|
||||
<string name="notification_service_master_description">Glavno stikalo za sprotna obvestila v ozadju. Izklopite za varčevanje z baterijo — podobno kot način letenja. Ko je stikalo izklopljeno, noben račun v ozadju ne ostane povezan, nastavitev pa se ohrani (tudi po ponovnem zagonu), dokler je znova ne vklopite.</string>
|
||||
<string name="notification_service_accounts_title">Aktivni računi v ozadju</string>
|
||||
<string name="notification_service_participation_title">Ta račun naj ostane aktiven v ozadju</string>
|
||||
<string name="split_notifications_setting_title">Razdeli opozorila glede na sledilce</string>
|
||||
<string name="split_notifications_setting_description">Prikaže dva zavihka z obvestili — Sledim (ljudje, ki jim slediš) in Vsi ostali. Indikator neprebranih obvestil sveti le za aktivnosti ljudi, ki jim slediš.</string>
|
||||
<string name="show_messages_in_notifications_setting_title">Prikaži sporočilo</string>
|
||||
@@ -1785,16 +2200,26 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="connectivity_type_unmetered_wifi_only">Samo z WiFi</string>
|
||||
<string name="connectivity_type_never">Nikoli</string>
|
||||
<!-- Compact label for the segmented control, where "Unmetered WiFi" does not fit. -->
|
||||
<string name="connectivity_type_unmetered_wifi_only_short">Wi-Fi</string>
|
||||
<string name="ui_feature_set_type_complete">Celovit</string>
|
||||
<string name="ui_feature_set_type_simplified">Poenostavljen</string>
|
||||
<string name="ui_feature_set_type_performance">Optimiziran</string>
|
||||
<!-- Compact labels for the segmented control. -->
|
||||
<string name="ui_feature_set_type_complete_short">Polno</string>
|
||||
<string name="gallery_type_classic">Klasično</string>
|
||||
<string name="gallery_type_modern">Moderno</string>
|
||||
<string name="system">Sistemska</string>
|
||||
<string name="light">Svetla</string>
|
||||
<string name="dark">Temna</string>
|
||||
<string name="application_preferences">Nastavitve aplikacije</string>
|
||||
<string name="settings_section_appearance">Prikaz</string>
|
||||
<string name="settings_section_media">Medijske vsebine in podatki</string>
|
||||
<string name="settings_section_general">Splošno</string>
|
||||
<string name="settings_section_tor_engine">Povezava</string>
|
||||
<string name="settings_section_tor_routing">Usmeri preko Tor</string>
|
||||
<string name="settings_section_profile_sections">Odseki in viri vsebin</string>
|
||||
<string name="settings_section_home_tabs">Vidni zavihki</string>
|
||||
<string name="settings_section_reminders">Opomniki</string>
|
||||
<string name="wallet_connect">Wallet Connect</string>
|
||||
<string name="language">Jezik</string>
|
||||
<string name="theme">Tema</string>
|
||||
@@ -1813,6 +2238,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="font_family_serif">Serif</string>
|
||||
<string name="font_family_monospace">Monospace</string>
|
||||
<!-- Compact labels for the segmented control, rendered in each option's own typeface. -->
|
||||
<string name="font_family_system_short">Sistem</string>
|
||||
<string name="font_family_sans_serif_short">Sans</string>
|
||||
<string name="font_family_serif_short">Serif</string>
|
||||
<string name="font_family_monospace_short">Mono</string>
|
||||
<string name="font_size">Velikost pisave</string>
|
||||
<string name="font_size_description">Prilagoditev velikosti besedila v aplikaciji</string>
|
||||
<string name="font_size_small">Majhno</string>
|
||||
@@ -1844,6 +2273,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="default_relays_longer">Ponastavi na privzeto</string>
|
||||
<string name="geohash_title">Prikaži lokacijo kot </string>
|
||||
<string name="geohash_explainer">Doda Geohash vaše lokacije k objavi. Javnost bo vedela, da se nahajate v radiju 5km (3milj) od trenutne lokacije</string>
|
||||
<string name="geohash_teleport_title">Teleportiraj</string>
|
||||
<string name="geohash_teleport_action">✈ Teleportiraj se sem</string>
|
||||
<string name="location_pick_on_map">Izberi lokacijo na karti</string>
|
||||
<string name="location_change_place">Spremeni lokacijo na karti</string>
|
||||
<string name="geohash_exclusive">Lokacijsko-ekskluzivna objava</string>
|
||||
<string name="geohash_exclusive_explainer">To bodo videli samo sledilci lokacije. Vaši splošni sledilci tega ne bodo videli.</string>
|
||||
<string name="hashtag_exclusive">Ekskluzivna objava ključnika</string>
|
||||
@@ -1897,8 +2330,32 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="messages_create_public_private_chat_description">Nova javna ali zasebna skupina</string>
|
||||
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
|
||||
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
|
||||
<string name="new_conversation_title">Začni pogovor</string>
|
||||
<string name="new_conversation_best_for">Najboljše za</string>
|
||||
<string name="new_conversation_pros">Dobro</string>
|
||||
<string name="new_conversation_cons">Kompromisi</string>
|
||||
<string name="new_conversation_section_direct">Zasebno</string>
|
||||
<string name="new_conversation_section_encrypted">Šifrirane skupine</string>
|
||||
<string name="new_conversation_section_relay">Zavedanje o relejih</string>
|
||||
<string name="new_conversation_dm_title">Zasebno sporočilo</string>
|
||||
<string name="new_conversation_dm_tagline">Zasebna sporočila, šifrirana od konca do konca (E2EE)</string>
|
||||
<string name="new_conversation_dm_chip">1:1–5 sob</string>
|
||||
<string name="new_conversation_dm_best">Klepeti ena na ena in v manjših skupinah z ljudmi, ki jih poznate.</string>
|
||||
<string name="new_conversation_dm_cta">Novo sporočilo</string>
|
||||
<string name="new_conversation_dm_pro_1">1 na 1 in manjše skupine</string>
|
||||
<string name="new_conversation_dm_pro_2">Šifrirano od konca do konca (E2EE)</string>
|
||||
<string name="new_conversation_dm_con_1">Uporabnikov ni mogoče dodati pozneje</string>
|
||||
<string name="new_conversation_marmot_title">Marmot skupina</string>
|
||||
<string name="new_conversation_marmot_tagline">Večje zasebne skupine</string>
|
||||
<string name="new_conversation_marmot_chip">Vezano na napravo</string>
|
||||
<string name="new_conversation_marmot_best">Večje zasebne skupine za tiste, ki prerasejo male zasebne klepete.</string>
|
||||
<string name="new_conversation_marmot_cta">Ustvari skupino</string>
|
||||
<string name="new_conversation_marmot_pro_1">Presega omejitve malih klepetov</string>
|
||||
<string name="new_conversation_marmot_pro_2">Šifriran skupinski klepet</string>
|
||||
<string name="new_conversation_marmot_con_1">ezano na napravo — ne bo prikazano, če se prijavite drugje</string>
|
||||
<string name="new_conversation_concord_title">Concord skupnost</string>
|
||||
<string name="relay_groups_title">Skupine relejev</string>
|
||||
<string name="relay_group_view_inline">V vrstici</string>
|
||||
<string name="relay_group_view_inline">Vrstično</string>
|
||||
<string name="relay_group_view_grouped">Po releju</string>
|
||||
<string name="relay_group_view_inline_desc">Prikaži vsako skupino kot ločen pogovor, pomešan z vašimi klepeti.</string>
|
||||
<string name="relay_group_view_grouped_desc">Strni skupine posameznega releja v eno vrstico, prikazano pri njegovem najnovejšem sporočilu.</string>
|
||||
@@ -1906,6 +2363,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="relay_group_server_label">Skupine relejev</string>
|
||||
<string name="relay_groups_button">Skupine</string>
|
||||
<string name="messages_settings">Sporočila</string>
|
||||
<string name="chat_type_concord_desc">Šifrirane skupnosti z več tematskimi kanali (Concord).</string>
|
||||
<string name="relay_group_create_title">Ustvari skupino</string>
|
||||
<string name="relay_group_relay_no_nip29">Ta relej ne oglašuje podpore za skupine NIP-29. Skupina, ustvarjena tukaj, ne bo delovala – relej ne bo upravljal njenega imena, članov in sporočil. Izberite rele, ki podpira skupine na ravni releja.</string>
|
||||
<string name="relay_group_create_name">Ime supine</string>
|
||||
@@ -2694,9 +3152,101 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="remove_location">Odstrani lokacijo</string>
|
||||
<string name="add_content_warning">Dodaj opozorilo o vsebini</string>
|
||||
<string name="remove_content_warning">Odstrani opozorilo o vsebini</string>
|
||||
<string name="chat_system_created_channel_unnamed">%1$s je ustvaril/-a kanal</string>
|
||||
<string name="chat_system_updated_channel">%1$s je posodobil/-a profil kanala</string>
|
||||
<string name="buzz_message_edited">(urejeno)</string>
|
||||
<string name="buzz_diff_truncated">(razlika je odrezana)</string>
|
||||
<string name="buzz_canvas_title">Delovna površina</string>
|
||||
<string name="buzz_canvas_empty">V tem delovnem prostoru še ni bila deljena nobena delovna površina.</string>
|
||||
<string name="buzz_canvas_edit">Uredi delovno površino</string>
|
||||
<string name="buzz_canvas_save">Shrani delovno površino</string>
|
||||
<string name="buzz_canvas_body_label">DElovna površina (Markdown)</string>
|
||||
<string name="buzz_edit_message">Uredi</string>
|
||||
<string name="buzz_editing_banner">Urejanje sporočila</string>
|
||||
<string name="edit_message">Uredi</string>
|
||||
<string name="message_edited">(urejeno)</string>
|
||||
<string name="concord_editing_banner">Urejanje sporočila</string>
|
||||
<string name="buzz_typing_one">%1$s tipka…</string>
|
||||
<string name="buzz_typing_two">%1$s in %2$s tipkata…</string>
|
||||
<string name="buzz_typing_many">Nekaj ljudi tipka…</string>
|
||||
<string name="buzz_workspaces_title">Buzz delovni prostori</string>
|
||||
<string name="buzz_workspaces_hero_tagline">Kjer ljudje in agenti ustvarjajo skupaj</string>
|
||||
<string name="buzz_workspaces_leave">Zapusti delovni prostor</string>
|
||||
<string name="buzz_workspaces_section">Vaši delovni prostori</string>
|
||||
<string name="buzz_workspaces_empty_title">Ni delovnih prostorov</string>
|
||||
<string name="buzz_workspaces_empty_body">Delovni prostori Buzz so releji, kjer ljudje in AI-agenti ustvarjajo skupaj. Pridružite se enemu, se prikaže tukaj.</string>
|
||||
<string name="buzz_workspaces_browse">Brskaj po skupinah</string>
|
||||
<string name="buzz_console_card_title">Konzola za agenta</string>
|
||||
<string name="buzz_console_card_subtitle">Stroški · Liki · Opazovalec v živo</string>
|
||||
<string name="buzz_forum_new_title">Nova tema na forumu</string>
|
||||
<string name="buzz_forum_body_label">O čem želitš razpravljati?</string>
|
||||
<string name="buzz_forum_post_action">Pošlji temo</string>
|
||||
<string name="buzz_forum_thread_title">Niz objav foruma</string>
|
||||
<string name="buzz_forum_no_replies">Še ni odgovorov. Bodi prvi, ki bo odgovoril.</string>
|
||||
<string name="buzz_dm_title">Zasebna sporočila</string>
|
||||
<string name="buzz_dm_nav_label">ZS Buzz</string>
|
||||
<string name="buzz_dm_card_subtitle">Zasebni pogovori v vaših delovnih prostorih</string>
|
||||
<string name="buzz_dm_new">Novo sporočilo</string>
|
||||
<string name="buzz_dm_empty_title">Ni še zasebnih sporočil</string>
|
||||
<string name="buzz_dm_empty_body">Začnite zasebni pogovor s komerkoli v delovnem prostoru Buzz.</string>
|
||||
<string name="buzz_dm_more">Več</string>
|
||||
<string name="buzz_dm_hide">Skrij pogovore</string>
|
||||
<string name="buzz_dm_add_member">Dodaj člana</string>
|
||||
<string name="buzz_dm_add_member_title">Dodaj nekoga v to ZS</string>
|
||||
<string name="buzz_dm_add_member_invalid">Neveljaven npub ali hex ključ </string>
|
||||
<string name="buzz_dm_just_you">Samo vi</string>
|
||||
<string name="buzz_dm_workspace">Delovni prostor</string>
|
||||
<string name="buzz_dm_recipients">Za</string>
|
||||
<string name="buzz_dm_add_hint">Dodaj nekoga (npub ali hex)</string>
|
||||
<string name="buzz_dm_start">Začni pogovor</string>
|
||||
<string name="buzz_dm_opening">Odpiram…</string>
|
||||
<string name="buzz_dm_remove">Odstrani</string>
|
||||
<string name="buzz_invite_title">Povabilo v delovni prostor</string>
|
||||
<string name="buzz_invite_workspace">Delovni prostor</string>
|
||||
<string name="buzz_invite_role">Vloga</string>
|
||||
<string name="buzz_invite_body">Delovni prostor se bo odprl v varnem brskalniku znotraj aplikacije, kjer si boste lahko ogledali pogoje uporabe in dokončali pridružitev. Pripet se boste z vašim ključem Amethyst — brez gesla.</string>
|
||||
<string name="buzz_invite_continue">Nadaljuj v brskaniku</string>
|
||||
<string name="buzz_invite_invalid">To ne izgleda kot veljavna povezava za povabilo v Buzz.</string>
|
||||
<string name="buzz_invite_expired">To povabilo je poteklo. Zaprosite za novo.</string>
|
||||
<string name="buzz_invite_after_browser">Dokončajte sprejemanje pogojev v brskalniku in se nato vrnite — vaš delovni prostor vas bo čakal.</string>
|
||||
<string name="buzz_invite_open_workspace">Odpri moje delovne prostore</string>
|
||||
<string name="buzz_import_title">Uvozi kanale delovnega prostora</string>
|
||||
<string name="buzz_import_loading">Iskanje vaših kanalov…</string>
|
||||
<string name="buzz_import_your_channels">Vaši kanali v tem delovnem prostoru</string>
|
||||
<string name="buzz_import_add">Dodaj</string>
|
||||
<string name="buzz_import_add_all">Dodaj vse</string>
|
||||
<string name="buzz_import_added">Dodano</string>
|
||||
<string name="buzz_import_empty_title">Ni najdenih kanalov</string>
|
||||
<string name="relay_group_section_channels">Kanali</string>
|
||||
<string name="relay_group_section_forums">Forumi</string>
|
||||
<string name="buzz_agent_working">Delam…</string>
|
||||
<string name="relay_group_add_member">Dodaj člana</string>
|
||||
<string name="buzz_community_add_people">Dodaj ljudi v ta delovni prostor</string>
|
||||
<string name="buzz_invite_create">Ustvari povezavo povabila</string>
|
||||
<string name="buzz_invite_link_title">Povezava povabila</string>
|
||||
<string name="buzz_invite_share">Deli</string>
|
||||
<string name="buzz_invite_copy">Kopiraj</string>
|
||||
<string name="buzz_invite_error_title">Ne morem ustvarii povabila</string>
|
||||
<string name="buzz_invite_dismiss">OK</string>
|
||||
<string name="buzz_pin">Pripni kanal</string>
|
||||
<string name="buzz_unpin">Odpni kanal</string>
|
||||
<string name="buzz_dm_section_empty">Še ni pogovorov</string>
|
||||
<plurals name="buzz_dm_see_all_count">
|
||||
<item quantity="one">Prikaži še %1$d pogovor</item>
|
||||
<item quantity="two">Prikaži še %1$d pogovora</item>
|
||||
<item quantity="few">Prikaži še %1$d pogovore</item>
|
||||
<item quantity="other">Prikaži vseh %1$d pogovorov</item>
|
||||
</plurals>
|
||||
<string name="chat_delivery_details_title">Dostava sporočila</string>
|
||||
<string name="close">Zapri</string>
|
||||
<string name="chat_delivery_pending">Čakanje, da rele sprejme to sporočilo</string>
|
||||
<string name="chat_delivery_no_relay_info">Za to sporočilo ni podatkov o releju</string>
|
||||
<string name="chat_delivery_accepted">Sprejeto s strani vsaj enega releja</string>
|
||||
<string name="chat_delivery_delivered_all">Dostavljeno relejem vseh prejemnikov</string>
|
||||
<string name="add_expiration_date">Dodaj datum poteka</string>
|
||||
<string name="remove_expiration_date">Odstrani datum poteka</string>
|
||||
<string name="expiration_date_label">Datum poteka</string>
|
||||
<string name="expiration_info_description">Avtor je zahteval, da releji to opombo izbrišejo po %1$s. Ko poteče, bo izginila iz virov.</string>
|
||||
<string name="expiration_date_explainer">Nostr odjemalci bodo po tem datumu skrili objavo (NIP-40)</string>
|
||||
<string name="expiration_date_select">Izberite datum in čas poteka</string>
|
||||
<string name="expiration_expires_in">Se izteče čez %1$s</string>
|
||||
@@ -2872,6 +3422,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="nsite_open">Odpri</string>
|
||||
<string name="ots_info_title">Dokaz časovnega žiga</string>
|
||||
<string name="ots_info_description">Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas.</string>
|
||||
<string name="ots_info_pending_description">Za to objavo je bilo zahtevano dokazilo o časovnem žigu, ki čaka na zapis v verigo blokov Bitcoin. Postopek se običajno zaključi v nekaj urah.</string>
|
||||
<string name="edit_article">Uredi članek</string>
|
||||
<string name="edit_post">Uredi objavo</string>
|
||||
<string name="proposal_to_edit">Prošnja za izboljšavo objave</string>
|
||||
@@ -2888,6 +3439,8 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="it_s_not_possible_to_react_to_a_draft_note">Ni mogoče reagirati na osnutek zapiska</string>
|
||||
<string name="it_s_not_possible_to_zap_to_a_draft_note">Osnutke zapisa ni mogoče Zap-niti</string>
|
||||
<string name="draft_note">Osnutek zapiska</string>
|
||||
<string name="draft">Osnutek</string>
|
||||
<string name="bounty_label">Nagrada</string>
|
||||
<string name="load_from_text">Iz zapiska</string>
|
||||
<string name="dvm_looking_for_app">Iščem aplikacijo</string>
|
||||
<string name="dvm_waiting_status">Zahteva poslana, čakam na odgovor</string>
|
||||
@@ -2952,7 +3505,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="favorite_dvms_empty_headline">Pripni svoje najljubše algoritme</string>
|
||||
<string name="favorite_dvms_empty_step1">Tapni \"%1$s\" spodaj za brskanje med algoritmi.</string>
|
||||
<!-- %1$s is replaced at runtime by an inline star icon, not text. Keep the placeholder. -->
|
||||
<string name="favorite_dvms_empty_step2">Tapni %1$s ob viru vsebin, daga ohraniš tukaj.</string>
|
||||
<string name="favorite_dvms_empty_step2">Tapni %1$s ob viru vsebin, da ga ohraniš tukaj.</string>
|
||||
<string name="favorite_dvms_empty_cta">Dodaj nekaj virov vsebin</string>
|
||||
<string name="favorite_dvms_add_more">Dodaj več…</string>
|
||||
<string name="dvm_home_status_requesting">Poizvedba pri %1$s za vir vsebin…</string>
|
||||
@@ -3010,6 +3563,105 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="crashreport_found">Najdeno je poročilo o napaki</string>
|
||||
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Želite poslati poročilo o napaki Amethyst-u preko zasebnega sporočila? Vaši osebni podatki NE bojo posredovani.</string>
|
||||
<string name="crashreport_found_send">Pošlji</string>
|
||||
<string name="resource_usage_title">Uporaba virov aplikacije</string>
|
||||
<string name="resource_usage_search_keywords">Poraba baterije in podatkov, energija, omrežje, diagnostika ter glavna knjiga</string>
|
||||
<string name="resource_usage_today">Danes</string>
|
||||
<string name="resource_usage_week">Zadnjih 7 dni</string>
|
||||
<string name="resource_usage_tile_cellular">Mobilni podatki danes</string>
|
||||
<string name="resource_usage_tile_wifi">Wi-Fi danes</string>
|
||||
<string name="resource_usage_tile_relay">Uporaba releja danes</string>
|
||||
<string name="resource_usage_tile_in_app">Danes v aplikaciji</string>
|
||||
<string name="resource_usage_trend_section">Količina podatkov na dan</string>
|
||||
<string name="resource_usage_legend_wifi">Wi-Fi (MB)</string>
|
||||
<string name="resource_usage_activity_section">Aktivnost (7 dni)</string>
|
||||
<string name="resource_usage_cellular_bg">Mobilni podatki (v ozadju)</string>
|
||||
<string name="resource_usage_cellular_fg">Mobilni podatki (v aplikaciji)</string>
|
||||
<string name="resource_usage_wifi">Wi-Fi podatki</string>
|
||||
<string name="resource_usage_relay_time">Čas povezave z releji</string>
|
||||
<string name="resource_usage_relay_time_bg_mobile">\u2026 v ozadju na mobilnih podatkih</string>
|
||||
<string name="resource_usage_wakelock">Obdelava obvestil (CPU buden)</string>
|
||||
<string name="resource_usage_worker_runs">Izvedena opravila v ozadju</string>
|
||||
<string name="resource_usage_reconnects">Neuspele ponovne povezave z relejem</string>
|
||||
<string name="resource_usage_http_requests">Spletne zahteve</string>
|
||||
<string name="resource_usage_radio_bursts">Obuditve radijskega modula za prenose (ocena)</string>
|
||||
<string name="resource_usage_http_active">Aktivni čas prenosa</string>
|
||||
<string name="resource_usage_media_play">Čas predvajanja</string>
|
||||
<string name="resource_usage_verifies">Preverjeni podpisi</string>
|
||||
<string name="resource_usage_cpu">Porabljen procesorski čas</string>
|
||||
<string name="resource_usage_fg_time">Čas v aplikaciji</string>
|
||||
<string name="resource_usage_app_starts">Zagoni procesov aplikacije</string>
|
||||
<string name="resource_usage_decrypts">Dešifrirana sporočila</string>
|
||||
<string name="resource_usage_remote_signs">Oddaljeni podpisi</string>
|
||||
<string name="resource_usage_pow">Rudarjenje PoW</string>
|
||||
<string name="resource_usage_tor">Čas delovanja Tor</string>
|
||||
<string name="resource_usage_calls">Čas trajanja klicev</string>
|
||||
<string name="resource_usage_nests">Čas v zvočnih sobah</string>
|
||||
<string name="resource_usage_location">Spremljanje lokacije</string>
|
||||
<string name="resource_usage_battery_drain">Poraba baterije (v aplikaciji / v ozadju)</string>
|
||||
<string name="resource_usage_alwayson_section">Storitev obvestil v ozadju</string>
|
||||
<string name="resource_usage_alwayson_explanation">Ohranitve povezav z vašimireleji odprte tudi, ko je aplikacija zaprta, da obvestila prispijejo brez Googlovih potisnih strežnikov. Ko je možnost omogočena, predstavlja največji strošek baterije v ozadju. Če vam je baterija pomembnejša od trenutnih obvestil, lahko spremenite način dostave ali možnost izklopite.</string>
|
||||
<string name="resource_usage_alwayson_uptime">Čas delovanja</string>
|
||||
<plurals name="resource_usage_alwayson_starts">
|
||||
<item quantity="one">%1$d zagon</item>
|
||||
<item quantity="two">%1$d zagona</item>
|
||||
<item quantity="few">%1$d zagoni</item>
|
||||
<item quantity="other">%1$d zagonov</item>
|
||||
</plurals>
|
||||
<string name="resource_usage_alwayson_bg_relay">Ohranjene povezave z releji med zaprtjem</string>
|
||||
<string name="resource_usage_alwayson_battery">Poraba baterije v tem času (celotna naprava)</string>
|
||||
<string name="resource_usage_alwayson_settings_button">Spremeni nastavitve obvestil</string>
|
||||
<string name="resource_usage_tor_settings_button">Možnosti zasebnosti</string>
|
||||
<string name="resource_usage_tile_battery_rate">Baterija / ur v aplikaciji</string>
|
||||
<string name="resource_usage_tile_data_rate">Podatki / ur v aplikaciji</string>
|
||||
<string name="resource_usage_tile_avg_relays">Povp. povezave z releji</string>
|
||||
<string name="resource_usage_by_subsystem_cellular">Podatki po funkcijah (7 dni)</string>
|
||||
<string name="resource_usage_cell_of_total">%1$s · %2$s skupno</string>
|
||||
<string name="resource_usage_screen_time_section">Čas zaslona (7 dni)</string>
|
||||
<string name="resource_usage_tor_section">Vgrajen Tor</string>
|
||||
<string name="resource_usage_tor_explanation">Preusmerja promet aplikacije skozi omrežje Tor za večjo zasebnost. Med delovanjem porabi več baterije zaradi šifriranja in ohranjanja povezav, vsak zagon pa zahteva vzpostavitev povezave (bootstrap). Če vaš model groženj to dopušča, lahko uporabo Tora prilagodite ali izklopite.</string>
|
||||
<string name="resource_usage_by_subsystem">Poraba podatkov po funkcijah (7 dni)</string>
|
||||
<string name="resource_usage_subsystem_relay">Sinhronizacija relejev</string>
|
||||
<string name="resource_usage_subsystem_image">Slike</string>
|
||||
<string name="resource_usage_subsystem_video">Video in zvok</string>
|
||||
<string name="resource_usage_subsystem_uploads">Prenosi</string>
|
||||
<string name="resource_usage_subsystem_money">Denarnica in zapi</string>
|
||||
<string name="resource_usage_subsystem_nip05">Preverjanje naslova</string>
|
||||
<string name="resource_usage_subsystem_preview">Predogled povezav</string>
|
||||
<string name="resource_usage_subsystem_other">Drugo</string>
|
||||
<string name="resource_usage_empty">Še ni podatkov o uporabi</string>
|
||||
<string name="resource_usage_memory_section">Trenutni pomnilnik</string>
|
||||
<string name="resource_usage_memory_heap">Pomnilnik aplikacije (heap)</string>
|
||||
<string name="resource_usage_memory_native">Lokalni pomnilnik</string>
|
||||
<string name="resource_usage_memory_image_cache">Pomnilnik slik (RAM)</string>
|
||||
<string name="resource_usage_memory_image_disk">Pomnilnik slik (disk)</string>
|
||||
<string name="resource_usage_memory_notes">Zapiski v pomnilniku</string>
|
||||
<string name="resource_usage_memory_users">Profili v pomnilniku </string>
|
||||
<string name="resource_usage_memory_addressables">Dosegljivi dogodki v pomnilniku</string>
|
||||
<string name="resource_usage_memory_chatrooms">Seznami sob v pomnilniku</string>
|
||||
<string name="resource_usage_memory_device_class">Kategorija pomnilnika naprave</string>
|
||||
<string name="resource_usage_send_section">Pošlji razvijalcem</string>
|
||||
<string name="resource_usage_send_explanation">Če Amethyst porablja preveč baterije ali podatkov, lahko to poročilo pošljete razvijalcem v šifriranem neposrednem sporočilu (DM). Vsebuje le številke s tega zaslona in tehnične števce za njimi – brez objav, stikov ali podrobnosti o brskanju. Nič ne bo poslano, dokler na zaslonu s sporočilom ne tapnete Pošlji.</string>
|
||||
<string name="resource_usage_send_button">Pošlji poročilo prek ZS</string>
|
||||
<string name="resource_usage_alert_title">Zaznana visoka poraba virov</string>
|
||||
<string name="resource_usage_alert_message">Aplikacija Amethyst je nedavno porabila več virov kot pričakovano: %1$s. Želite razvijalcem poslati poročilo o uporabi v šifriranem zasebnem sporočilu (ZS)? Preden bo karkoli poslano, boste videli celotno poročilo.</string>
|
||||
<string name="resource_usage_reason_bg_data">%1$s mobilnih podatkov v ozadju v enem dnevu</string>
|
||||
<string name="resource_usage_reason_conn_time">%1$s povezav z releji v ozadju prek mobilnih podatkov v enem dnevu</string>
|
||||
<string name="resource_usage_reason_wakelock">Procesor je bil zaradi obdelave obvestil v enem dnevu buden %1$s</string>
|
||||
<plurals name="resource_usage_reason_churn">
|
||||
<item quantity="one">Process aplikacije se je v enem dnevu znova zagnal %1$d-krat</item>
|
||||
<item quantity="two">Process aplikacije se je v enem dnevu znova zagnal %1$d-krat</item>
|
||||
<item quantity="few">Process aplikacije se je v enem dnevu znova zagnal %1$d-krat</item>
|
||||
<item quantity="other">Process aplikacije se je v enem dnevu znova zagnal %1$d-krat</item>
|
||||
</plurals>
|
||||
<plurals name="resource_usage_reason_reconnects">
|
||||
<item quantity="one">Aplikacija se je z releji v enem dnevu znova povezala %1$d-krat</item>
|
||||
<item quantity="two">Aplikacija se je z releji v enem dnevu znova povezala %1$d-krat</item>
|
||||
<item quantity="few">Aplikacija se je z releji v enem dnevu znova povezala %1$d-krat</item>
|
||||
<item quantity="other">Aplikacija se je z releji v enem dnevu znova povezala %1$d-krat</item>
|
||||
</plurals>
|
||||
<string name="resource_usage_alert_send">Preglej in pošlji</string>
|
||||
<string name="resource_usage_alert_not_now">Ne zdaj</string>
|
||||
<string name="resource_usage_alert_opt_out">Ne sprašuj več</string>
|
||||
<string name="this_message_will_disappear_in">To sporočilo bo izginilo čez %1$s</string>
|
||||
<string name="select_signer">Izberi podpisnika</string>
|
||||
<string name="in_the_list">Že na seznamu</string>
|
||||
@@ -3374,6 +4026,8 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="playback_actions_dialog_title">Predvajaj</string>
|
||||
<string name="video_quality_auto">Samodejno</string>
|
||||
<!-- Nicknames (NIP-85 contact cards); the dialog strings live in commons -->
|
||||
<string name="edit_nickname">Uredi vzdevek</string>
|
||||
<string name="nickname_private">Vidno le vam</string>
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<string name="cast_to_device">Prenesi v napravo</string>
|
||||
<string name="cast_stop_casting">Prenehaj s prenašanjem</string>
|
||||
@@ -3569,6 +4223,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="compose_signature_setting_description">Doda se na konec sporočila pri ustvarjanju nove objave, odgovora, citata ali članka. Pustite prazno, da onemogočite.</string>
|
||||
<string name="compose_signature_setting_hint">Vaš podpis</string>
|
||||
<string name="pow_settings_title">Dokazilo o delu (PoW)</string>
|
||||
<string name="pow_info_description">Avtor je porabil računsko moč za rudarjenje tega zapisa do zahtevnosti %1$s – dokaz o opravljenem delu (PoW), ki otežuje pošiljanje neželene pošte.</string>
|
||||
<string name="pow_difficulty_title">Težavnost</string>
|
||||
<string name="pow_difficulty_explainer">Pred objavo sporočila, rudari (NIP-13) dokaz o delu (PoW), da jih lahko releji in bralci ločijo od neželene vsebine (spam). Višje vrednosti zahtevajo eksponentno več časa za rudarjenje. Objave se po končanem rudarjenju samodejno objavijo v ozadju.</string>
|
||||
<string name="pow_difficulty_off">Ugasni</string>
|
||||
@@ -3632,6 +4287,11 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="pow_notification_channel_name">Rudarjenje z dokazom o delu (PoW)</string>
|
||||
<string name="pow_notification_channel_description">Prikazuje objave, ki še rudarijo svoj NIP-13 dokaz o delu, da lahko zaključijo postopek, potem ko zapustite aplikacijo.</string>
|
||||
<string name="pow_notification_cancel_all">Prekliči</string>
|
||||
<string name="pow_notification_send_without_pow">Pošlji zdaj</string>
|
||||
<string name="pow_cancel_dialog_title">Prekinem rudarjenje?</string>
|
||||
<string name="pow_cancel_dialog_message">Ta objava še vedno rudari dokaz o opravljenem delu. Lahko jo pošljete zdaj brez dokaza ali pa jo zavržete.</string>
|
||||
<string name="pow_cancel_dialog_send_without_pow">Pošlji brez PoW</string>
|
||||
<string name="pow_cancel_dialog_discard">Zavrzi objavo</string>
|
||||
<string name="pow_difficulty_estimate">≈ %1$s na objavo v tej napravi</string>
|
||||
<string name="pow_estimate_instant"><1 s</string>
|
||||
<plurals name="pow_estimate_seconds">
|
||||
@@ -3763,6 +4423,10 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="marmot_group_name_placeholder">Vnesi ime skupine</string>
|
||||
<string name="marmot_group_description_placeholder">Vnesite opis skupine (neobvezno)</string>
|
||||
<string name="marmot_edit_info_footer">Spremembe bodo potrjene prek MLS in poslane vsem članom skupine.</string>
|
||||
<string name="marmot_group_icon">Ikona skupine</string>
|
||||
<string name="marmot_add_photo">Dodaj fotografijo</string>
|
||||
<string name="marmot_change_photo">Zamenjaj sliko</string>
|
||||
<string name="marmot_remove_photo">Odstrani fotografijo</string>
|
||||
<string name="marmot_group_info_updated">Podatki o skupini so posodobljeni</string>
|
||||
<string name="marmot_failed_to_update">Posodobitev ni uspela: %1$s</string>
|
||||
<string name="marmot_failed_to_create_group">Skupine ni bilo mogoče ustvariti: %1$s</string>
|
||||
@@ -3818,4 +4482,14 @@ Ali želite za to uporabiti trenutne releje iz odhodnega predala?</string>
|
||||
<string name="error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user">Napaka pri razčlenjevanju JSON med pridobivanjem fakture iz %1$s. Preverite uporabnikove nastavitve za \"Lightning\"</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">Ni mogoče ustvariti lightning fakture. Sporočilo od %1$s: %2$s</string>
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">Ni mogoče ustvariti lightning fakture od %1$s: Element \'pr\' ni bil najden v JSON-u.</string>
|
||||
<string name="geohash_chat_open">Odpri lokacijski klepet</string>
|
||||
<string name="geohash_chat">Lokacijski klepet</string>
|
||||
<string name="new_conversation_location_title">Lokacijski kanal</string>
|
||||
<string name="new_conversation_location_tagline">Klepetajte z ljudmi v bližini lokacije (geohash)</string>
|
||||
<string name="new_conversation_location_chip">Lokalno</string>
|
||||
<string name="new_conversation_location_best">Odlično za klepet z ljudmi v bližini – v soseski, mestu ali regiji.</string>
|
||||
<string name="new_conversation_location_cta">Izberi lokacijo</string>
|
||||
<string name="new_conversation_location_pro_1">Anonimno — enkratna identiteta za posamezno področje, nikoli vaš npub.</string>
|
||||
<string name="new_conversation_location_pro_2">Združljivo z Bitchatom — doseže bližnje uporabnike na istih relejih.</string>
|
||||
<string name="new_conversation_location_con_1">Javno in minljivo — brez zgodovine sporočil, dostopno vsem v celici.</string>
|
||||
</resources>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="chat_preview_decrypting">Dekrypterar…</string>
|
||||
<!-- Prefix on a Messages-list preview when the newest message was sent by the logged-in user. %1$s is the message text -->
|
||||
<string name="chat_preview_you_prefix">Du: %1$s</string>
|
||||
<string name="group_picture">Grupp bild</string>
|
||||
<string name="explicit_content">Explicit Innehåll</string>
|
||||
<string name="relay_notice">Relay-meddelande</string>
|
||||
@@ -344,6 +345,14 @@
|
||||
<item quantity="one">%1$d kanal</item>
|
||||
<item quantity="other">%1$d kanaler</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_workspaces_count">
|
||||
<item quantity="one">%1$d arbetsyta</item>
|
||||
<item quantity="other">%1$d arbetsytor</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_workspaces_channel_count">
|
||||
<item quantity="one">%1$d kanal</item>
|
||||
<item quantity="other">%1$d kanaler</item>
|
||||
</plurals>
|
||||
<plurals name="concord_member_count">
|
||||
<item quantity="one">%1$d medlem</item>
|
||||
<item quantity="other">%1$d medlemmar</item>
|
||||
@@ -400,6 +409,7 @@
|
||||
<!-- Title of the minichat (thread) screen opened from a chat message. -->
|
||||
<string name="chat_minichat_title">Tråd</string>
|
||||
<!-- Title of the picked-image confirmation dialog when attaching a picture to a thread reply. -->
|
||||
<string name="chat_send_image_title">Skicka bild</string>
|
||||
<!-- Chip on a chat message that opens its thread ("minichat") of kind-1111 replies. -->
|
||||
<plurals name="chat_minichat_reply_count">
|
||||
<item quantity="one">%1$d svar</item>
|
||||
@@ -1250,6 +1260,7 @@
|
||||
<string name="nest_create_submit">Starta utrymme</string>
|
||||
<string name="nest_create_schedule_toggle">Schemalägg för senare</string>
|
||||
<string name="nest_create_when">Välj starttid</string>
|
||||
<string name="agent_console_title">Agentkonsol</string>
|
||||
<string name="nests_servers_title">Nest-servrar</string>
|
||||
<string name="nests_servers_explainer">Välj vilka MoQ-värdservrar Amethyst publicerar dina nests till. Den första posten används som standard när du startar ett nytt utrymme.</string>
|
||||
<string name="nests_servers_my_section">Dina servrar</string>
|
||||
@@ -1592,6 +1603,10 @@
|
||||
<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_stored_on">Lagrad på</string>
|
||||
<string name="blossom_file_details">Filinformation</string>
|
||||
<string name="blossom_on_all_servers">På alla servrar</string>
|
||||
<string name="blossom_not_on_all_servers">Inte på alla 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>
|
||||
@@ -1609,6 +1624,33 @@
|
||||
<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="blossom_more_actions">Fler åtgärder</string>
|
||||
<string name="blossom_import_menu">Importera filer…</string>
|
||||
<string name="blossom_import_title">Importera filer</string>
|
||||
<string name="blossom_import_intro">Sök på andra Blossom-servrar efter filer du laddat upp någon annanstans och kopiera dem till dina egna servrar.</string>
|
||||
<string name="blossom_import_sources_section">Servrar att söka på</string>
|
||||
<string name="blossom_import_enable_all">Aktivera alla</string>
|
||||
<string name="blossom_import_disable_all">Inaktivera alla</string>
|
||||
<string name="blossom_import_add_url_label">Eller klistra in en serveradress</string>
|
||||
<string name="blossom_import_scan">Sök på servrar</string>
|
||||
<string name="blossom_import_scanning">Söker…</string>
|
||||
<string name="blossom_import_source_failed">Kunde inte nå den här servern</string>
|
||||
<string name="blossom_import_none_found">Inga nya filer hittades på de valda servrarna.</string>
|
||||
<string name="blossom_import_no_targets">Lägg till dina egna Blossom-servrar först, så att de importerade filerna har någonstans att kopieras till.</string>
|
||||
<string name="blossom_import_manage_servers">Hantera mina servrar</string>
|
||||
<string name="blossom_import_busy">En filsynkronisering pågår redan. Försök importera igen när den är klar.</string>
|
||||
<plurals name="blossom_import_files_found">
|
||||
<item quantity="one">%1$d fil</item>
|
||||
<item quantity="other">%1$d filer</item>
|
||||
</plurals>
|
||||
<plurals name="blossom_import_found_files">
|
||||
<item quantity="one">Hittade %1$d fil att importera till dina servrar.</item>
|
||||
<item quantity="other">Hittade %1$d filer att importera till dina servrar.</item>
|
||||
</plurals>
|
||||
<plurals name="blossom_import_start_button">
|
||||
<item quantity="one">Importera %1$d fil</item>
|
||||
<item quantity="other">Importera %1$d filer</item>
|
||||
</plurals>
|
||||
<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>
|
||||
@@ -1637,6 +1679,20 @@
|
||||
<string name="no_payment_app_found">Ingen app hittades för att hantera denna betalning. Installera en kompatibel plånbok.</string>
|
||||
<string name="no_payment_app_found_for_type">Ingen app installerad för att hantera %1$s-betalningar. Installera en kompatibel plånbok.</string>
|
||||
<string name="error_dialog_payment_error">Det gick inte att öppna betalningen</string>
|
||||
<string name="bolt12_offers">BOLT12-erbjudanden</string>
|
||||
<string name="bolt12_offers_explainer">Publicera återanvändbara BOLT12-erbjudanden så att andra kan zappa dig via Lightning utan en separat faktura varje gång.</string>
|
||||
<string name="bolt12_offers_section_explainer">Lägg till ett eller flera BOLT12-erbjudanden (lno1…). De delas offentligt så att vem som helst kan betala dig.</string>
|
||||
<string name="no_bolt12_offers_message">Inga BOLT12-erbjudanden angivna. Lägg till ett nedan ↓</string>
|
||||
<string name="bolt12_offer">BOLT12-erbjudande (lno1…)</string>
|
||||
<string name="invalid_bolt12_offer">Inte ett giltigt BOLT12-erbjudande</string>
|
||||
<string name="delete_bolt12_offer">Ta bort BOLT12-erbjudande</string>
|
||||
<string name="bolt12_pay_with_wallet">Betala med ansluten plånbok</string>
|
||||
<string name="bolt12_payment_amount_sats">Belopp (sats)</string>
|
||||
<string name="bolt12_payment_sent">BOLT12-betalning skickad</string>
|
||||
<string name="bolt12_payment_failed">BOLT12-betalningen misslyckades: %1$s</string>
|
||||
<string name="bolt12_zap_error">BOLT12-zapfel</string>
|
||||
<string name="bolt12_zap_paid_no_receipt">Betalt via BOLT12, men plånboken returnerade inget bevis, så inget zapkvitto publicerades.</string>
|
||||
<string name="bolt12_zap_invalid_receipt">Betalt via BOLT12, men betalningsbeviset kunde inte valideras, så inget zapkvitto publicerades.</string>
|
||||
<string name="uploading_state_ready">Ej påbörjad</string>
|
||||
<string name="uploading_state_compressing">Komprimerar</string>
|
||||
<string name="uploading_state_uploading">Laddar upp</string>
|
||||
@@ -1743,6 +1799,10 @@
|
||||
<string name="app_notification_zaps_channel_message">%1$s sats</string>
|
||||
<string name="app_notification_zaps_channel_message_from">Från %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">till %1$s</string>
|
||||
<string name="app_notification_payments_channel_name">Mottagna betalningar</string>
|
||||
<string name="app_notification_payments_channel_description">Meddelar dig när din anslutna plånbok tar emot en betalning som inte är en Nostr-zap</string>
|
||||
<string name="app_notification_payments_channel_message">Tog emot %1$s sats</string>
|
||||
<string name="app_notification_payments_summary">Nya betalningar</string>
|
||||
<string name="app_notification_reply_label">Svara</string>
|
||||
<string name="app_notification_mark_read_label">Markera som läst</string>
|
||||
<string name="app_notification_me">Jag</string>
|
||||
@@ -2367,6 +2427,12 @@
|
||||
<string name="relay_group_browse_your_relays">Reläer du är på</string>
|
||||
<string name="relay_group_browse_popular">Populära reläer</string>
|
||||
<string name="relay_group_no_messages_yet">Inga meddelanden ännu</string>
|
||||
<string name="channel_invite_title">Tillagd i %1$s</string>
|
||||
<string name="channel_invite_body">%1$s lade till dig i den här kanalen på %2$s. Visa den i Meddelanden?</string>
|
||||
<string name="channel_invite_unknown_actor">Någon</string>
|
||||
<string name="channel_invite_accept">Visa</string>
|
||||
<string name="channel_invite_ignore">Ignorera</string>
|
||||
<string name="channel_invite_leave">Lämna</string>
|
||||
<string name="relay_group_join_to_post">Gå med i den här gruppen för att skicka meddelanden.</string>
|
||||
<string name="relay_group_invite_only_to_post">Den här gruppen är endast för inbjudna — du behöver en inbjudan för att publicera.</string>
|
||||
<plurals name="relay_group_member_count">
|
||||
@@ -2385,6 +2451,10 @@
|
||||
<item quantity="one">%1$d person du följer</item>
|
||||
<item quantity="other">%1$d personer du följer</item>
|
||||
</plurals>
|
||||
<plurals name="relay_group_relay_group_count">
|
||||
<item quantity="one">%1$d grupp</item>
|
||||
<item quantity="other">%1$d grupper</item>
|
||||
</plurals>
|
||||
<string name="messages_new_message">Privat</string>
|
||||
<string name="messages_new_message_to">Till</string>
|
||||
<string name="messages_new_message_subject">Ämne</string>
|
||||
@@ -3069,9 +3139,86 @@
|
||||
<string name="chat_system_created_channel">%1$s skapade kanalen %2$s</string>
|
||||
<string name="chat_system_created_channel_unnamed">%1$s skapade kanalen</string>
|
||||
<string name="chat_system_updated_channel">%1$s uppdaterade kanalprofilen</string>
|
||||
<string name="buzz_message_edited">(redigerat)</string>
|
||||
<string name="buzz_diff_truncated">(diff avkortad)</string>
|
||||
<string name="buzz_canvas_empty">Ingen canvas har delats i den här arbetsytan än.</string>
|
||||
<string name="buzz_canvas_edit">Redigera canvas</string>
|
||||
<string name="buzz_canvas_save">Spara canvas</string>
|
||||
<string name="buzz_edit_message">Redigera</string>
|
||||
<string name="buzz_editing_banner">Redigerar meddelande</string>
|
||||
<string name="edit_message">Redigera</string>
|
||||
<string name="message_edited">(redigerat)</string>
|
||||
<string name="concord_editing_banner">Redigerar meddelande</string>
|
||||
<string name="buzz_typing_one">%1$s skriver…</string>
|
||||
<string name="buzz_typing_two">%1$s och %2$s skriver…</string>
|
||||
<string name="buzz_typing_many">Flera personer skriver…</string>
|
||||
<string name="buzz_workspaces_title">Buzz-arbetsytor</string>
|
||||
<string name="buzz_workspaces_hero_tagline">Där människor och agenter bygger tillsammans</string>
|
||||
<string name="buzz_workspaces_leave">Lämna arbetsytan</string>
|
||||
<string name="buzz_workspaces_section">Dina arbetsytor</string>
|
||||
<string name="buzz_workspaces_empty_title">Inga arbetsytor än</string>
|
||||
<string name="buzz_workspaces_empty_body">Buzz-arbetsytor är reläer där människor och AI-agenter bygger tillsammans. Gå med i en för att se den här.</string>
|
||||
<string name="buzz_workspaces_browse">Bläddra bland grupper</string>
|
||||
<string name="buzz_console_card_title">Agentkonsol</string>
|
||||
<string name="buzz_console_card_subtitle">Kostnader · Personas · Liveobservatör</string>
|
||||
<string name="buzz_forum_new_title">Nytt forumämne</string>
|
||||
<string name="buzz_forum_body_label">Vad vill du diskutera?</string>
|
||||
<string name="buzz_forum_post_action">Publicera ämne</string>
|
||||
<string name="buzz_forum_thread_title">Forumtråd</string>
|
||||
<string name="buzz_forum_no_replies">Inga svar än. Bli först med att svara.</string>
|
||||
<string name="buzz_dm_title">Direktmeddelanden</string>
|
||||
<string name="buzz_dm_nav_label">Buzz-DM</string>
|
||||
<string name="buzz_dm_card_subtitle">Privata konversationer i dina arbetsytor</string>
|
||||
<string name="buzz_dm_new">Nytt meddelande</string>
|
||||
<string name="buzz_dm_empty_title">Inga direktmeddelanden än</string>
|
||||
<string name="buzz_dm_empty_body">Starta en privat konversation med vem som helst i en Buzz-arbetsyta.</string>
|
||||
<string name="buzz_dm_more">Mer</string>
|
||||
<string name="buzz_dm_hide">Dölj konversation</string>
|
||||
<string name="buzz_dm_add_member">Lägg till medlem</string>
|
||||
<string name="buzz_dm_add_member_title">Lägg till någon i det här DM:et</string>
|
||||
<string name="buzz_dm_add_member_invalid">Inte en giltig npub- eller hexnyckel</string>
|
||||
<string name="buzz_dm_just_you">Bara du</string>
|
||||
<string name="buzz_dm_workspace">Arbetsyta</string>
|
||||
<string name="buzz_dm_recipients">Till</string>
|
||||
<string name="buzz_dm_add_hint">Lägg till någon (npub eller hex)</string>
|
||||
<string name="buzz_dm_start">Starta konversation</string>
|
||||
<string name="buzz_dm_opening">Öppnar…</string>
|
||||
<string name="buzz_dm_remove">Ta bort</string>
|
||||
<string name="buzz_invite_title">Arbetsyteinbjudan</string>
|
||||
<string name="buzz_invite_heading">Gå med i den här arbetsytan</string>
|
||||
<string name="buzz_invite_workspace">Arbetsyta</string>
|
||||
<string name="buzz_invite_role">Roll</string>
|
||||
<string name="buzz_invite_body">Du öppnar arbetsytan i en säker webbläsare i appen för att granska villkoren och slutföra anslutningen. Du loggas in med din Amethyst-nyckel — inget lösenord.</string>
|
||||
<string name="buzz_invite_continue">Fortsätt i webbläsaren</string>
|
||||
<string name="buzz_invite_invalid">Det här ser inte ut som en giltig Buzz-inbjudningslänk.</string>
|
||||
<string name="buzz_invite_expired">Den här inbjudan har gått ut. Be om en ny.</string>
|
||||
<string name="buzz_invite_after_browser">Slutför godkännandet av villkoren i webbläsaren och kom sedan tillbaka — din arbetsyta väntar.</string>
|
||||
<string name="buzz_invite_open_workspace">Öppna mina arbetsytor</string>
|
||||
<string name="buzz_import_title">Importera arbetsytans kanaler</string>
|
||||
<string name="buzz_import_loading">Hittar dina kanaler…</string>
|
||||
<string name="buzz_import_your_channels">Dina kanaler i den här arbetsytan</string>
|
||||
<string name="buzz_import_add">Lägg till</string>
|
||||
<string name="buzz_import_add_all">Lägg till alla</string>
|
||||
<string name="buzz_import_added">Tillagd</string>
|
||||
<string name="buzz_import_empty_title">Inga kanaler hittades</string>
|
||||
<string name="buzz_import_empty_body">Du verkar inte vara medlem i några kanaler på det här relät än. Acceptera arbetsyteinbjudan i webbläsaren först och försök sedan igen.</string>
|
||||
<string name="relay_group_section_channels">Kanaler</string>
|
||||
<string name="relay_group_section_forums">Forum</string>
|
||||
<string name="buzz_agent_working">Arbetar…</string>
|
||||
<string name="relay_group_add_member">Lägg till medlem</string>
|
||||
<string name="buzz_community_add_people">Lägg till personer i den här arbetsytan</string>
|
||||
<string name="buzz_invite_create">Skapa inbjudningslänk</string>
|
||||
<string name="buzz_invite_link_title">Inbjudningslänk</string>
|
||||
<string name="buzz_invite_share">Dela</string>
|
||||
<string name="buzz_invite_copy">Kopiera</string>
|
||||
<string name="buzz_invite_error_title">Kunde inte skapa inbjudan</string>
|
||||
<string name="buzz_pin">Fäst kanal</string>
|
||||
<string name="buzz_unpin">Lossa kanal</string>
|
||||
<string name="buzz_dm_section_empty">Inga konversationer än</string>
|
||||
<plurals name="buzz_dm_see_all_count">
|
||||
<item quantity="one">Visa %1$d konversation till</item>
|
||||
<item quantity="other">Visa alla %1$d konversationer</item>
|
||||
</plurals>
|
||||
<string name="chat_delivery_details_title">Meddelandeleverans</string>
|
||||
<string name="close">Stäng</string>
|
||||
<string name="chat_delivery_pending">Väntar på att ett relä ska acceptera detta meddelande</string>
|
||||
|
||||
@@ -2653,6 +2653,12 @@
|
||||
<string name="relay_group_browse_your_relays">Relays you\'re on</string>
|
||||
<string name="relay_group_browse_popular">Popular relays</string>
|
||||
<string name="relay_group_no_messages_yet">No messages yet</string>
|
||||
<string name="channel_invite_title">Added to %1$s</string>
|
||||
<string name="channel_invite_body">%1$s added you to this channel on %2$s. Show it in Messages?</string>
|
||||
<string name="channel_invite_unknown_actor">Someone</string>
|
||||
<string name="channel_invite_accept">Show</string>
|
||||
<string name="channel_invite_ignore">Ignore</string>
|
||||
<string name="channel_invite_leave">Leave</string>
|
||||
<string name="relay_group_join_to_post">Join this group to send messages.</string>
|
||||
<string name="relay_group_invite_only_to_post">This group is invite-only — you need an invite to post.</string>
|
||||
<plurals name="relay_group_member_count">
|
||||
@@ -3441,6 +3447,48 @@
|
||||
<string name="chat_system_updated_channel">%1$s updated the channel profile</string>
|
||||
<string name="buzz_message_edited">(edited)</string>
|
||||
<string name="buzz_diff_truncated">(diff truncated)</string>
|
||||
|
||||
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
|
||||
<string name="buzz_system_member_joined">%1$s joined</string>
|
||||
<string name="buzz_system_member_added">%1$s was added by %2$s</string>
|
||||
<string name="buzz_system_member_left">%1$s left</string>
|
||||
<string name="buzz_system_member_removed">%1$s was removed by %2$s</string>
|
||||
<string name="buzz_system_topic_changed">%1$s set the topic to \"%2$s\"</string>
|
||||
<string name="buzz_system_topic_cleared">%1$s cleared the topic</string>
|
||||
<string name="buzz_system_purpose_changed">%1$s set the purpose to \"%2$s\"</string>
|
||||
<string name="buzz_system_purpose_cleared">%1$s cleared the purpose</string>
|
||||
<string name="buzz_system_visibility_open">%1$s made this channel open — anyone can find and join it</string>
|
||||
<string name="buzz_system_visibility_private">%1$s made this channel private — invite only</string>
|
||||
<string name="buzz_system_visibility_other">%1$s changed the visibility to %2$s</string>
|
||||
<string name="buzz_system_ttl_set">%1$s set messages to disappear after %2$s</string>
|
||||
<string name="buzz_system_ttl_cleared">%1$s turned off disappearing messages</string>
|
||||
<string name="buzz_system_channel_archived">%1$s archived this channel</string>
|
||||
<string name="buzz_system_channel_unarchived">%1$s restored this channel</string>
|
||||
<string name="buzz_system_channel_created">%1$s created this channel</string>
|
||||
<string name="buzz_system_channel_deleted">%1$s deleted this channel</string>
|
||||
<string name="buzz_system_message_deleted">%1$s deleted a message</string>
|
||||
<string name="buzz_system_message_deleted_reason">%1$s deleted a message: %2$s</string>
|
||||
<string name="buzz_system_dm_created">%1$s started this conversation</string>
|
||||
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
|
||||
<string name="buzz_system_unknown">%1$s: %2$s</string>
|
||||
|
||||
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
|
||||
<string name="buzz_huddle_started">🔊 %1$s started a huddle</string>
|
||||
<string name="buzz_huddle_joined">🔊 %1$s joined the huddle</string>
|
||||
<string name="buzz_huddle_left">🔊 %1$s left the huddle</string>
|
||||
<string name="buzz_huddle_ended">🔊 Huddle ended</string>
|
||||
|
||||
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
|
||||
<string name="buzz_job_requested">⚙ %1$s requested a job</string>
|
||||
<string name="buzz_job_accepted">⚙ %1$s accepted the job</string>
|
||||
<string name="buzz_job_progress">⚙ Job progress</string>
|
||||
<string name="buzz_job_result">⚙ Job result</string>
|
||||
<string name="buzz_job_cancelled">⚙ Job cancelled</string>
|
||||
<string name="buzz_job_error">⚠ Job error</string>
|
||||
|
||||
<!-- Buzz forum votes (kind 45002). -->
|
||||
<string name="buzz_forum_upvoted">▲ %1$s upvoted a post</string>
|
||||
<string name="buzz_forum_downvoted">▼ %1$s downvoted a post</string>
|
||||
<string name="buzz_canvas_title">Canvas</string>
|
||||
<string name="buzz_canvas_empty">No canvas has been shared in this workspace yet.</string>
|
||||
<string name="buzz_canvas_edit">Edit canvas</string>
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.async
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The audio player sizes itself as a square (see AudioPlayerSquare.audioSquare), which is taller than
|
||||
* 16:9. Handing audio the video default therefore cages the square in a shorter, centre-aligned box
|
||||
* that nothing clips, and the player — visualizer included — paints over the note header above and
|
||||
* the reactions row below. Audio must stay ratio-less so the box wraps the square instead.
|
||||
*/
|
||||
class UnknownMediaAspectRatioTest {
|
||||
@Test
|
||||
fun unknownVideoAssumes16by9() {
|
||||
assertEquals(16f / 9f, unknownMediaAspectRatio(null, "https://example.com/a.mp4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liveStreamPlaylistKeeps16by9() {
|
||||
assertEquals(16f / 9f, unknownMediaAspectRatio(null, "https://example.com/stream.m3u8"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bareAudioUrlHasNoRatio() {
|
||||
assertNull(unknownMediaAspectRatio(null, "https://haven.sdbitcoiners.com/f28a5a2e.mp3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioMimeHasNoRatioEvenWithoutAnExtension() {
|
||||
assertNull(unknownMediaAspectRatio("audio/mpeg", "https://example.com/download?id=7"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoMimeWinsOverNothing() {
|
||||
assertEquals(16f / 9f, unknownMediaAspectRatio("video/mp4", "https://example.com/download?id=7"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownEverythingAssumes16by9() {
|
||||
assertEquals(16f / 9f, unknownMediaAspectRatio(null, "https://example.com/download?id=7"))
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.chats
|
||||
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A kind-9 chat message is a thread reply only when its `e` tag carries a NIP-10 marker.
|
||||
*
|
||||
* Three conventions share kind 9 and must not be confused:
|
||||
* - **NIP-C7** spends `q` on the in-chat reply and never mentions `e` at all, which is what leaves a
|
||||
* marked `e` free to mean "thread reply".
|
||||
* - **WhiteNoise / Marmot** thread chat with a **plain, unmarked** `e` — an *in-chat* reply that has to
|
||||
* keep rendering as a quote bubble in the timeline.
|
||||
* - **Buzz** threads with `["e", id, "", "reply"]` (nested: `root` + `reply`), which belongs in the
|
||||
* minichat and must be dropped from the channel timeline.
|
||||
*
|
||||
* Getting this wrong is what put a Buzz thread reply in the main channel as a quote instead of in the
|
||||
* thread on its parent.
|
||||
*/
|
||||
class MinichatReplyTest {
|
||||
private val parentId = "1a05130cc86929f267747b17761d5873a95dbab66d5298c38a352bdfd0edc730"
|
||||
private val rootId = "bf2e60b69fdf6bf3aa11223344556677889900aabbccddeeff00112233445566"
|
||||
private val channel = "6a39da2f-33c0-44f6-a050-c4da0138644a"
|
||||
|
||||
private fun chat(vararg tags: Array<String>) =
|
||||
ChatEvent(
|
||||
id = "id",
|
||||
pubKey = "pk",
|
||||
createdAt = 1L,
|
||||
tags = arrayOf(arrayOf("h", channel), *tags),
|
||||
content = "hi",
|
||||
sig = "sig",
|
||||
)
|
||||
|
||||
/** The exact shape observed on the wire from Buzz's client for a direct reply. */
|
||||
@Test
|
||||
fun `buzz direct reply - reply-marked e tag - is a thread reply`() {
|
||||
assertTrue(isMinichatReply(chat(arrayOf("e", parentId, "", "reply"))))
|
||||
}
|
||||
|
||||
/** Nested reply: `root` + `reply`, matching Buzz's `_buildReplyTags`. */
|
||||
@Test
|
||||
fun `buzz nested reply - root plus reply markers - is a thread reply`() {
|
||||
assertTrue(
|
||||
isMinichatReply(
|
||||
chat(arrayOf("e", rootId, "", "root"), arrayOf("e", parentId, "", "reply")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: WhiteNoise/Marmot use a bare `e`, which is an *in-chat* reply. Matching on the tag
|
||||
* rather than the marker would swallow those into the minichat and empty the timeline.
|
||||
*/
|
||||
@Test
|
||||
fun `whitenoise unmarked e tag stays an in-chat reply`() {
|
||||
assertFalse(isMinichatReply(chat(arrayOf("e", parentId))))
|
||||
assertFalse(isMinichatReply(chat(arrayOf("e", parentId, ""))))
|
||||
}
|
||||
|
||||
/** NIP-C7's own reply mechanism renders inline, not in a thread. */
|
||||
@Test
|
||||
fun `nip-c7 q tag reply stays an in-chat reply`() {
|
||||
assertFalse(isMinichatReply(chat(arrayOf("q", parentId, "", "pk"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plain top-level chat message is not a thread reply`() {
|
||||
assertFalse(isMinichatReply(chat()))
|
||||
}
|
||||
|
||||
/** A `root`-only marker (no `reply`) is a thread root reference, not a reply to that message. */
|
||||
@Test
|
||||
fun `root marker alone is not a reply`() {
|
||||
assertFalse(isMinichatReply(chat(arrayOf("e", rootId, "", "root"))))
|
||||
}
|
||||
}
|
||||
+3
-5
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -50,10 +49,9 @@ class BuzzTimelineKindsTest {
|
||||
assertTrue(openTail.filter.kinds!!.contains(StreamMessageV2Event.KIND))
|
||||
|
||||
val fleet =
|
||||
buildRelayGroupJoinedChatTailFilters(
|
||||
listOf(GroupTag("g1", buzzRelay.url), GroupTag("g2", vanillaRelay.url)),
|
||||
sinceEpoch = 0L,
|
||||
)
|
||||
listOf(GroupId("g1", buzzRelay), GroupId("g2", vanillaRelay)).map {
|
||||
buildRelayGroupJoinedChatTailFilter(it, sinceEpoch = 0L)
|
||||
}
|
||||
fleet.forEach { assertTrue(it.filter.kinds!!.contains(StreamMessageV2Event.KIND)) }
|
||||
}
|
||||
}
|
||||
|
||||
+92
-14
@@ -24,13 +24,16 @@ import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.FiltersChanged
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
@@ -110,21 +113,96 @@ class RelayGroupFilterBuildersTest {
|
||||
filters.filter { it.relay == relayB }.forEach { assertNull(it.filter.since) }
|
||||
}
|
||||
|
||||
// --- Joined chat tail (always-on): batched #h per relay, time floor, NO per-group limit ---
|
||||
// --- Joined chat tail (always-on): ONE single-valued #h per group, time floor, NO limit ---
|
||||
|
||||
@Test
|
||||
fun `joined chat tail batches one h-filter per relay with no count limit`() {
|
||||
val filters = buildRelayGroupJoinedChatTailFilters(joined, 999L)
|
||||
assertEquals(2, filters.size)
|
||||
fun `joined chat tail carries exactly one h value per group with no count limit`() {
|
||||
val filter = buildRelayGroupJoinedChatTailFilter(GroupId("g1", relayA), 999L)
|
||||
|
||||
val a = filters.single { it.relay == relayA }
|
||||
// The joined tail now always carries the Buzz timeline kinds too (see
|
||||
assertEquals(relayA, filter.relay)
|
||||
// The joined tail always carries the Buzz timeline kinds too (see
|
||||
// RELAY_GROUP_ALL_TIMELINE_KINDS / BuzzTimelineKindsTest).
|
||||
assertEquals(timelineKinds + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS, a.filter.kinds)
|
||||
assertEquals(setOf("g1", "g2"), a.filter.tags!!["h"]!!.toSet())
|
||||
assertEquals(999L, a.filter.since)
|
||||
assertNull("a time-floored tail must NOT cap by count (that is what lets it batch)", a.filter.limit)
|
||||
assertNull(a.filter.until)
|
||||
assertEquals(timelineKinds + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS, filter.filter.kinds)
|
||||
assertEquals(listOf("g1"), filter.filter.tags!!["h"])
|
||||
assertEquals(999L, filter.filter.since)
|
||||
assertNull("a time-floored tail must NOT cap by count", filter.filter.limit)
|
||||
assertNull(filter.filter.until)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression guard for the Messages-list freeze: `block/buzz` indexes a live subscription under a
|
||||
* single channel uuid resolved from `#h`, and downgrades any subscription whose filters mention two
|
||||
* or more distinct ids to a *global* one — which by design receives no channel-scoped events. The
|
||||
* stored query still answers correctly, so the symptom was "backfills on launch, then never updates".
|
||||
* Each joined group must therefore get its own single-valued filter (and its own subscription).
|
||||
*/
|
||||
@Test
|
||||
fun `joined chat tail never batches multiple groups into one h filter`() {
|
||||
joined.forEach { tag ->
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)!!
|
||||
val values = buildRelayGroupJoinedChatTailFilter(GroupId(tag.groupId, relay), 1L).filter.tags!!["h"]!!
|
||||
assertEquals("a multi-value #h makes buzz register the sub as global and stop live delivery", 1, values.size)
|
||||
assertEquals(tag.groupId, values.single())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Messages-row preview is bounded by COUNT, never by time. The chat tail floors at `now - 7 days`
|
||||
* to keep recent chat warm; on its own that leaves a channel quiet for longer stuck on its
|
||||
* "No messages yet" placeholder forever, sorted to the bottom by `createdAt = 0`. Roster-driven
|
||||
* protocols fill their rows this way — NIP-28 with `limit = 1`, Concord with `limit = 10` — because
|
||||
* their row set comes from a list event and only needs one newest message per row.
|
||||
*/
|
||||
@Test
|
||||
fun `preview filter asks for one newest message with no time floor on a cold start`() {
|
||||
val filter = buildRelayGroupPreviewFilter(g1OnA, null)
|
||||
|
||||
assertEquals(relayA, filter.relay)
|
||||
assertEquals(listOf("g1"), filter.filter.tags!!["h"])
|
||||
assertEquals(1, filter.filter.limit)
|
||||
assertNull("a cold start must reach back past any window to fill the row", filter.filter.since)
|
||||
assertNull(filter.filter.until)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preview filter rides the per-relay EOSE since once caught up`() {
|
||||
assertEquals(555L, buildRelayGroupPreviewFilter(g1OnA, 555L).filter.since)
|
||||
}
|
||||
|
||||
/** Single-valued `#h`, so it can share the channel-scoped subscription without downgrading it. */
|
||||
@Test
|
||||
fun `preview filter names exactly one channel`() {
|
||||
assertEquals(1, buildRelayGroupPreviewFilter(g1OnA, null).filter.tags!!["h"]!!.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactions/deletions ride the channel's own `#h` subscription, the way the Buzz reference client
|
||||
* does it (its `channelEventKinds` bundles deletion + reaction into the one `#h` channel REQ).
|
||||
* Amethyst's other source of reactions is the shared `#e` EventFinder query, which carries no `#h`
|
||||
* and is therefore registered as a *global* subscription — a class that never receives
|
||||
* channel-scoped events live, and a reaction inherits its target message's channel.
|
||||
*/
|
||||
@Test
|
||||
fun `aux filter carries reactions and deletions scoped to one group`() {
|
||||
val filter = buildRelayGroupAuxFilter(GroupId("g1", relayA), 42L)
|
||||
|
||||
assertEquals(relayA, filter.relay)
|
||||
assertEquals(listOf(DeletionEvent.KIND, ReactionEvent.KIND, DeleteEventEvent.KIND), filter.filter.kinds)
|
||||
assertEquals(listOf("g1"), filter.filter.tags!!["h"])
|
||||
assertEquals(42L, filter.filter.since)
|
||||
assertEquals(RELAY_GROUP_AUX_LIMIT, filter.filter.limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* The aux kinds must stay OUT of the timeline set: that set feeds the backward history pager, whose
|
||||
* `until` cursor walks `created_at`. Reactions eating a page's `limit` would advance the cursor past
|
||||
* chat messages that were never delivered.
|
||||
*/
|
||||
@Test
|
||||
fun `aux kinds are not in the timeline set the history pager pages over`() {
|
||||
RELAY_GROUP_AUX_KINDS.forEach {
|
||||
assertFalse("aux kind $it must not consume history-page limit", it in RELAY_GROUP_ALL_TIMELINE_KINDS)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Open chat tail: a single group's recent #h on the host relay ---
|
||||
@@ -239,7 +317,6 @@ class RelayGroupFilterBuildersTest {
|
||||
@Test
|
||||
fun `empty joined set produces no filters`() {
|
||||
assertTrue(buildRelayGroupStateFilters(emptySet()) { null }.isEmpty())
|
||||
assertTrue(buildRelayGroupJoinedChatTailFilters(emptySet(), 1L).isEmpty())
|
||||
}
|
||||
|
||||
// --- Reconnect stability: a `since`-only bump must NOT trigger a fresh REQ (no full replay) ---
|
||||
@@ -265,8 +342,9 @@ class RelayGroupFilterBuildersTest {
|
||||
|
||||
@Test
|
||||
fun `joined chat tail advancing its time floor is not a resend`() {
|
||||
val earlier = buildRelayGroupJoinedChatTailFilters(joined, 1_000L).map { it.filter }
|
||||
val later = buildRelayGroupJoinedChatTailFilters(joined, 2_000L).map { it.filter } // recentBoundary() crept forward
|
||||
val groups = joined.map { GroupId(it.groupId, RelayUrlNormalizer.normalizeOrNull(it.relayUrl)!!) }
|
||||
val earlier = groups.map { buildRelayGroupJoinedChatTailFilter(it, 1_000L).filter }
|
||||
val later = groups.map { buildRelayGroupJoinedChatTailFilter(it, 2_000L).filter } // recentBoundary() crept forward
|
||||
earlier.zip(later).forEach { (old, new) ->
|
||||
assertFalse(
|
||||
"a forward recent-tail floor bump is since-only → reconnect re-REQs the tail, not a full page",
|
||||
|
||||
@@ -100,6 +100,75 @@ class ArtiGuardStateTest {
|
||||
assertTrue(ArtiGuardState.hasConfirmedGuard(root))
|
||||
}
|
||||
|
||||
/**
|
||||
* The field case this ratio rule exists for: a 60-guard sample with 59 permanently disabled and
|
||||
* exactly ONE still nominally usable, while Arti rejected all sixty at runtime
|
||||
* (`AllGuardsDown { n_accepted: 0, n_rejected: 60 }`) across repeated app restarts. Under the old
|
||||
* `usable == 0` rule the lone survivor vetoed the wipe forever, so Tor stayed ~87% broken and
|
||||
* Concord never folded.
|
||||
*/
|
||||
@Test
|
||||
fun `one usable guard out of sixty is wedged, not healthy`() {
|
||||
val guards =
|
||||
buildString {
|
||||
append("""{ "default": { "guards": [""")
|
||||
repeat(59) {
|
||||
append("""{ "confirmed_at": "2026-07-01T00:00:00Z", "disabled": { "type": "TooManyIndeterminateFailures" }, "unlisted_since": null },""")
|
||||
}
|
||||
append("""{ "confirmed_at": null, "disabled": null, "unlisted_since": null }""")
|
||||
append("] } }")
|
||||
}
|
||||
val root = ArtiGuardState.parse(guards)
|
||||
|
||||
assertTrue(
|
||||
"1 usable of 60 is unrecoverable in practice → must wipe",
|
||||
ArtiGuardState.hasNoUsableGuards(root),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The opposite guard-rail: a young sample must never be wiped just for being small, or a genuine
|
||||
* first bootstrap would loop. Only the strict `usable == 0` rule applies below
|
||||
* [ArtiGuardState.MIN_SAMPLE_TO_JUDGE_RATIO].
|
||||
*/
|
||||
@Test
|
||||
fun `small fresh sample is never wedged by the ratio rule`() {
|
||||
val root =
|
||||
ArtiGuardState.parse(
|
||||
"""
|
||||
{ "default": { "guards": [
|
||||
{ "confirmed_at": null, "disabled": null, "unlisted_since": null },
|
||||
{ "confirmed_at": null, "disabled": { "type": "TooManyIndeterminateFailures" } },
|
||||
{ "confirmed_at": null, "disabled": { "type": "TooManyIndeterminateFailures" } }
|
||||
] } }
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
"1 usable of 3 is a young sample Arti is still filling → leave it alone",
|
||||
ArtiGuardState.hasNoUsableGuards(root),
|
||||
)
|
||||
}
|
||||
|
||||
/** A healthy majority must not trip the ratio rule. */
|
||||
@Test
|
||||
fun `mostly usable large sample is not wedged`() {
|
||||
val guards =
|
||||
buildString {
|
||||
append("""{ "default": { "guards": [""")
|
||||
repeat(20) {
|
||||
append("""{ "confirmed_at": null, "disabled": null, "unlisted_since": null },""")
|
||||
}
|
||||
append("""{ "confirmed_at": null, "disabled": { "type": "TooManyIndeterminateFailures" } }""")
|
||||
append("] } }")
|
||||
}
|
||||
|
||||
assertFalse(
|
||||
"20 usable of 21 is healthy",
|
||||
ArtiGuardState.hasNoUsableGuards(ArtiGuardState.parse(guards)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty selection is neither wedged nor confirmed`() {
|
||||
val root = ArtiGuardState.parse("""{ "default": { "guards": [] }, "restricted": { "guards": [] } }""")
|
||||
|
||||
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.tor
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorType
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -506,6 +508,11 @@ private class FakeTorBackend : TorBackend {
|
||||
/** Simulates a persisted confirmed guard on disk (prior successful bootstrap). */
|
||||
var bootstrappedBefore = false
|
||||
|
||||
/** Lets a test fire Arti's "every guard was rejected" signal (see [TorBackend.guardsDownSignal]). */
|
||||
val guardsDown = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
|
||||
override val guardsDownSignal: Flow<Unit> = guardsDown
|
||||
|
||||
override suspend fun hasBootstrappedBefore(): Boolean = bootstrappedBefore
|
||||
|
||||
override suspend fun start() {
|
||||
|
||||
+1
-1
@@ -715,7 +715,7 @@ output then also reports `paid` + the preimage.
|
||||
| `amy zap user USER SATS [--comment X] [--anon\|--private] [--with NDEBIT] [--timeout SECS]` | Profile zap: build the zap request and fetch a BOLT11 from USER's LN service. |
|
||||
| `amy zap event EVENT-ID SATS [--comment X] [--anon\|--private] [--with NDEBIT] [--timeout SECS]` | Same, attributed to a specific event (must be in the local store). Zap splits are honored — one invoice per recipient. |
|
||||
|
||||
### BOLT12 zaps (NIP-XX)
|
||||
### BOLT12 zaps (NIP-B1)
|
||||
|
||||
BOLT12 zaps (kinds 9736/9737, offers in kind:10058). amy has no NWC payment
|
||||
rail, so sending is a **two-step, out-of-band** flow: `bolt12 intent` signs the
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| Buzz agent jobs (`amy buzz job` / `agent serve`) | ✅ | `BuzzJobCommands` (request/list/show/cancel, kinds 43001-43006) + `BuzzAgentCommands` (`agent serve` responder loop: gate on `--accept-from`, run `--exec`, report accept/progress/result/error). Correlation + state via the shared `BuzzJobAggregator` in `commons`. Schema provisional (43001-43006 reserved upstream). See `cli/plans/2026-07-25-buzz-agent-support-channel.md`. |
|
||||
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` |
|
||||
| NIP-57 zaps (send) | ✅ partial | `ZapCommand` — `zap user`/`zap event` build the kind:9734 request and fetch the BOLT11 (zap splits honored, one invoice per recipient); `--with NDEBIT` auto-pays through a CLINK debit pointer. Receipt (kind:9735) verification still 🆕. |
|
||||
| BOLT12 zaps (NIP-XX, kinds 9736/9737/10058) | ✅ partial | `Bolt12Commands` + `Bolt12SendCommands` over shared `commons` `Bolt12ZapActions` — `bolt12 decode` (offer/proof), `verify` (validate a kind:9736), `offer get/set` (kind:10058), and the two-step send `intent`→`zap` (out-of-band proof, since amy has no NWC rail). Interop harness + NWC-fetched proofs still 🆕. |
|
||||
| BOLT12 zaps (NIP-B1, kinds 9736/9737/10058) | ✅ partial | `Bolt12Commands` + `Bolt12SendCommands` over shared `commons` `Bolt12ZapActions` — `bolt12 decode` (offer/proof), `verify` (validate a kind:9736), `offer get/set` (kind:10058), and the two-step send `intent`→`zap` (out-of-band proof, since amy has no NWC rail). Interop harness + NWC-fetched proofs still 🆕. |
|
||||
| NIP-65 outbox model queries | ✅ | `OutboxCommand` — `amy outbox USER [--refresh]`, cache-first. |
|
||||
| CLINK offers + debits (`amy offer` / `amy debit`) | ✅ | `OfferCommands` + `DebitCommands` — pointer decode, NIP-05 discover, kind:21001/21002 round-trips, `offer pay --with NDEBIT` end-to-end settlement. `--timeout` is SECONDS. |
|
||||
| Geochat (Bitchat geohash, ephemeral kind:20000) | ✅ | `GeochatCommands` — listen/send/keys with per-geohash throwaway identity + geo-nearest relay routing; doubles as the Bitchat interop harness. |
|
||||
|
||||
@@ -750,7 +750,7 @@ private fun printUsage() {
|
||||
| [--comment X] [--anon|--private] event (must be in local store)
|
||||
| [--timeout SECS]
|
||||
|
|
||||
|BOLT12 zaps (NIP-XX):
|
||||
|BOLT12 zaps (NIP-B1):
|
||||
| bolt12 decode LNO1|LNP1 decode a BOLT12 offer or payer proof
|
||||
| bolt12 verify EVENT-ID validate a kind:9736 zap in the local store
|
||||
| bolt12 offer get USER fetch + show a user's kind:10058 offers
|
||||
|
||||
@@ -28,13 +28,13 @@ import com.vitorpamplona.amethyst.commons.actions.Bolt12ZapActions
|
||||
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.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
/**
|
||||
* `amy bolt12 …` — NIP-XX BOLT12 zaps from the command line, for headless interop
|
||||
* `amy bolt12 …` — NIP-B1 BOLT12 zaps from the command line, for headless interop
|
||||
* testing of the quartz BOLT12 stack.
|
||||
*
|
||||
* Sub-verbs:
|
||||
@@ -54,7 +54,7 @@ import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
object Bolt12Commands {
|
||||
val USAGE: String =
|
||||
"""
|
||||
|BOLT12 zaps (NIP-XX):
|
||||
|BOLT12 zaps (NIP-B1):
|
||||
| bolt12 decode LNO1|LNP1 decode an offer or payer proof to fields
|
||||
| bolt12 verify EVENT-ID validate a kind:9736 zap in the local store
|
||||
| bolt12 offer get USER fetch + show a user's kind:10058 offers
|
||||
|
||||
@@ -28,9 +28,9 @@ import com.vitorpamplona.amethyst.commons.actions.Bolt12ZapActions
|
||||
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.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
|
||||
|
||||
/**
|
||||
* The BOLT12 send-side sub-verbs of `amy bolt12` (split from [Bolt12Commands] to keep
|
||||
|
||||
+10
-10
@@ -24,19 +24,19 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12PayerProof
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.builder.Bolt12ZapBuilder
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Offer
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12PayerProof
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.intent.Bolt12ZapIntentEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidator
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
|
||||
/**
|
||||
* NIP-XX BOLT12 zap building, decoding, and validation — the shared, UI-free
|
||||
* NIP-B1 BOLT12 zap building, decoding, and validation — the shared, UI-free
|
||||
* surface a non-Android caller (amy CLI, interop harnesses) drives.
|
||||
*
|
||||
* Assembly-only: it re-exposes [Bolt12ZapBuilder] / [Bolt12ZapValidator] and the
|
||||
|
||||
+2
-2
@@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.commons.model
|
||||
import androidx.compose.runtime.Stable
|
||||
|
||||
/**
|
||||
* Per-payment NIP-XX BOLT12 zap entry attached to a target Note.
|
||||
* Per-payment NIP-B1 BOLT12 zap entry attached to a target Note.
|
||||
*
|
||||
* Unlike NIP-BC onchain zaps (which carry an async chain-verification state
|
||||
* machine), a BOLT12 zap is validated **synchronously** at consumption time —
|
||||
* the `lnp` payer proof is a self-contained cryptographic settlement proof — so
|
||||
* every entry stored here has already passed [com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator]
|
||||
* every entry stored here has already passed [com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidator]
|
||||
* and its amount is counted directly, the same way a NIP-57 lightning zap
|
||||
* receipt's amount is.
|
||||
*
|
||||
|
||||
@@ -287,7 +287,7 @@ open class Note(
|
||||
private set
|
||||
|
||||
/**
|
||||
* NIP-XX BOLT12 zaps (kind 9736) targeting this note.
|
||||
* NIP-B1 BOLT12 zaps (kind 9736) targeting this note.
|
||||
* Key: the payer proof's `invoice_payment_hash` (hex) — the spec's dedup key,
|
||||
* so two zap events proving the same settled payment collapse to one entry.
|
||||
* Value: entry with the source Bolt12ZapEvent note (so `source.author` is the
|
||||
@@ -829,7 +829,7 @@ open class Note(
|
||||
} else {
|
||||
// Same verification status: dedup by the settled payment and, if amounts
|
||||
// differ, keep the LOWER — so a re-publish with a bigger amount tag can't
|
||||
// inflate the total (NIP-XX). Both share the same cryptoVerified flag.
|
||||
// inflate the total (NIP-B1). Both share the same cryptoVerified flag.
|
||||
if (entry.amountMillisats < existing.amountMillisats) entry else existing
|
||||
}
|
||||
if (merged == existing) return@withLock false
|
||||
@@ -846,10 +846,10 @@ open class Note(
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a NIP-XX BOLT12 zap targeting this note. [source] is the kind:9736
|
||||
* Register a NIP-B1 BOLT12 zap targeting this note. [source] is the kind:9736
|
||||
* event's own note — `source.author` is the payer shown in the reactions
|
||||
* gallery and notifications. [amountMillisats] and [cryptoVerified] come from
|
||||
* the synchronous [com.vitorpamplona.quartz.nipXXBolt12Zaps.verify.Bolt12ZapValidator]
|
||||
* the synchronous [com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidator]
|
||||
* verdict; the caller MUST only call this for a `Valid` result. Deduplicated by
|
||||
* [paymentHashHex] (the proof's `invoice_payment_hash`).
|
||||
*/
|
||||
@@ -1189,7 +1189,7 @@ open class Note(
|
||||
sumOfAmounts += BigDecimal(entry.claimedSats)
|
||||
}
|
||||
|
||||
// NIP-XX BOLT12 zaps — count only the crypto-verified ones. An unverified
|
||||
// NIP-B1 BOLT12 zaps — count only the crypto-verified ones. An unverified
|
||||
// (compressed, or offer-unbindable) proof carries a self-chosen preimage,
|
||||
// amount, and payment hash with no settled-payment guarantee, so counting it
|
||||
// would let anyone inflate a note's total for free. Unverified entries stay
|
||||
|
||||
+1
-1
@@ -30,8 +30,8 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
@@ -116,7 +116,7 @@ class User(
|
||||
|
||||
fun nutzapInfo() = nutzapInfoNote.event as? NutzapInfoEvent
|
||||
|
||||
/** This user's published BOLT12 offer list (NIP-XX kind 10058), or null if none seen. */
|
||||
/** This user's published BOLT12 offer list (NIP-B1 kind 10058), or null if none seen. */
|
||||
fun bolt12OfferList() = bolt12OfferListNote.event as? Bolt12OfferListEvent
|
||||
|
||||
/** The canonical raw BOLT12 offers (`lno1...`) this user accepts, empty when none published. */
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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.commons.model.buzz
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** Somebody added [viewer] to a channel: who did it, where, and when. */
|
||||
@Immutable
|
||||
class BuzzChannelInvite(
|
||||
val channelId: String,
|
||||
val relay: NormalizedRelayUrl,
|
||||
val actor: HexKey?,
|
||||
val createdAt: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* App-wide, per-viewer set of channels somebody **else** added the viewer to, awaiting the viewer's
|
||||
* decision about whether they appear on Messages.
|
||||
*
|
||||
* On a Buzz relay, membership is server-side: another member issues the add, the relay writes you into
|
||||
* the channel's kind-39002 roster, and you can immediately read and post. The relay then addresses you a
|
||||
* kind-44100 with `{"actor": …}` naming who did it — and it emits the *same* kind for a self-join, with
|
||||
* `actor == you`, which is the only thing separating the two cases.
|
||||
*
|
||||
* Amethyst used to funnel every 44100 into [BuzzDmChannels], which silently subscribed the viewer to the
|
||||
* channel's messages while the Messages list — which reads the self-published kind-10009 — showed no row
|
||||
* for it. So a channel could be simultaneously joined (relay roster, no Join button, composer enabled),
|
||||
* streaming messages, and invisible. Only `t = dm` channels belong in [BuzzDmChannels]; everything else
|
||||
* lands here until the viewer accepts.
|
||||
*
|
||||
* Accepting adds the group to kind-10009 (`Account.follow`), after which the normal joined-group path
|
||||
* owns it and the entry is dropped. Dismissing is a *display* choice recorded in
|
||||
* `AccountSettings.dismissedChannelInvites`; genuinely leaving is a kind-9022 `LeaveRequestEvent`, which
|
||||
* is a different action because the viewer really is a member until the relay says otherwise.
|
||||
*/
|
||||
object BuzzChannelInvites {
|
||||
private val lock = KmpLock()
|
||||
private val byViewer = HashMap<HexKey, MutableMap<String, BuzzChannelInvite>>()
|
||||
private val mutableFlow = MutableStateFlow<Map<HexKey, Map<String, BuzzChannelInvite>>>(emptyMap())
|
||||
|
||||
/** Per-viewer pending invites (`channelId` -> who/where/when). */
|
||||
val flow: StateFlow<Map<HexKey, Map<String, BuzzChannelInvite>>> = mutableFlow
|
||||
|
||||
/**
|
||||
* Records that somebody added [viewer] to [channelId]. Returns true when this is newly seen, so
|
||||
* callers can invalidate a feed; a repeat of the same (viewer, channel) returns false rather than
|
||||
* churning the flow — the relay re-sends the notification on every reconnect.
|
||||
*/
|
||||
fun record(
|
||||
viewer: HexKey,
|
||||
invite: BuzzChannelInvite,
|
||||
): Boolean =
|
||||
lock.withLock {
|
||||
val invites = byViewer.getOrPut(viewer) { mutableMapOf() }
|
||||
if (invites.containsKey(invite.channelId)) return@withLock false
|
||||
invites[invite.channelId] = invite
|
||||
mutableFlow.value = snapshot()
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops an invite once it is no longer pending — the viewer accepted it (now in kind-10009), left the
|
||||
* channel, or the relay reported a kind-44101 removal.
|
||||
*/
|
||||
fun remove(
|
||||
viewer: HexKey,
|
||||
channelId: String,
|
||||
): Boolean =
|
||||
lock.withLock {
|
||||
val invites = byViewer[viewer] ?: return@withLock false
|
||||
if (invites.remove(channelId) == null) return@withLock false
|
||||
mutableFlow.value = snapshot()
|
||||
true
|
||||
}
|
||||
|
||||
/** Invites pending for [viewer], possibly empty. */
|
||||
fun invitesFor(viewer: HexKey): Map<String, BuzzChannelInvite> = mutableFlow.value[viewer] ?: emptyMap()
|
||||
|
||||
private fun snapshot(): Map<HexKey, Map<String, BuzzChannelInvite>> = byViewer.mapValues { it.value.toMap() }
|
||||
|
||||
/** Test-only: clears all state so unit tests don't leak into each other. */
|
||||
fun clearForTesting() =
|
||||
lock.withLock {
|
||||
byViewer.clear()
|
||||
mutableFlow.value = emptyMap()
|
||||
}
|
||||
}
|
||||
+17
@@ -68,6 +68,23 @@ object BuzzDmChannels {
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a channel that turned out not to be a DM. Discovery provisionally records every kind-44100
|
||||
* here so the channel's kind-39000 can be fetched by id; once that reveals a `t` other than `dm` the
|
||||
* entry is withdrawn, because the always-on DM tail must not silently subscribe the viewer to a named
|
||||
* channel somebody added them to (see [BuzzChannelInvites]).
|
||||
*/
|
||||
fun remove(
|
||||
viewer: HexKey,
|
||||
channelId: String,
|
||||
): Boolean =
|
||||
lock.withLock {
|
||||
val channels = byViewer[viewer] ?: return@withLock false
|
||||
if (channels.remove(channelId) == null) return@withLock false
|
||||
mutableFlow.value = snapshot()
|
||||
true
|
||||
}
|
||||
|
||||
/** The DM channels [viewer] is in (`channelId` -> relay), possibly empty. */
|
||||
fun channelsFor(viewer: HexKey): Map<String, NormalizedRelayUrl> = mutableFlow.value[viewer] ?: emptyMap()
|
||||
|
||||
|
||||
+2
-2
@@ -35,8 +35,8 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
/**
|
||||
* Nostr event kinds that can generate a notification when they tag the
|
||||
@@ -71,7 +71,7 @@ object NotificationKinds {
|
||||
NutzapEvent.KIND, // 9321 — NIP-61 Cashu nutzap
|
||||
LnZapEvent.KIND, // 9735 — NIP-57 zap receipt
|
||||
OnchainZapEvent.KIND, // 8333 — onchain zap
|
||||
Bolt12ZapEvent.KIND, // 9736 — NIP-XX BOLT12 zap
|
||||
Bolt12ZapEvent.KIND, // 9736 — NIP-B1 BOLT12 zap
|
||||
// NIP-17 file-header messages (encrypted file DMs)
|
||||
ChatMessageEncryptedFileHeaderEvent.KIND,
|
||||
)
|
||||
|
||||
+23
-3
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.preview
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.util.codePointToChars
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
|
||||
data class MetaTag(
|
||||
@@ -179,13 +180,32 @@ object MetaTagsParser {
|
||||
)
|
||||
|
||||
fun replaceCharRefs(match: MatchResult): String {
|
||||
val bcr = BASE_CHAR_REFS[match.groupValues[2]]
|
||||
val isNumeric = match.groupValues[1].isNotEmpty()
|
||||
val ref = match.groupValues[2]
|
||||
val terminated = match.groupValues[3].isNotEmpty()
|
||||
|
||||
// Numeric character references (" / ") must be terminated by ';'
|
||||
if (isNumeric) {
|
||||
if (!terminated) return match.value
|
||||
val codePoint =
|
||||
if (ref.startsWith("x", ignoreCase = true)) {
|
||||
ref.drop(1).toIntOrNull(16)
|
||||
} else {
|
||||
ref.toIntOrNull(10)
|
||||
} ?: return match.value
|
||||
if (codePoint !in 0..0x10FFFF || codePoint in 0xD800..0xDFFF) {
|
||||
return match.value
|
||||
}
|
||||
return codePointToChars(codePoint).concatToString()
|
||||
}
|
||||
|
||||
val bcr = BASE_CHAR_REFS[ref]
|
||||
if (bcr != null) {
|
||||
return bcr
|
||||
}
|
||||
// non-base char refs must be terminated by ';'
|
||||
if (match.groupValues[3].isNotEmpty()) {
|
||||
val cr = CHAR_REFS[match.groupValues[2]]
|
||||
if (terminated) {
|
||||
val cr = CHAR_REFS[ref]
|
||||
if (cr != null) {
|
||||
return cr
|
||||
}
|
||||
|
||||
+33
-9
@@ -495,12 +495,25 @@ class RichTextParser {
|
||||
)
|
||||
|
||||
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
|
||||
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a", "f4a")
|
||||
|
||||
// Audio is folded into [videoExt] because both play through the same video pipeline — but
|
||||
// audio has no picture, so anything that reasons about the shape of the media (aspect
|
||||
// ratios, player sizing) has to tell them apart. `m3u8` stays video-only: a playlist
|
||||
// carries either.
|
||||
//
|
||||
// Composing [videoExt] out of [audioExt] is what keeps "every audio extension is also a
|
||||
// video extension" true by construction rather than by convention — the two lists cannot
|
||||
// drift apart. It is also why [audioExt] is declared first; the compiler rejects the
|
||||
// reverse order outright ("Variable 'audioExt' must be initialized"), so this note is
|
||||
// intent, not a guard rail.
|
||||
val audioExt = listOf("mp3", "ogg", "wav", "flac", "aac", "opus", "m4a", "f4a")
|
||||
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "m3u8") + audioExt
|
||||
val pdfExt = listOf("pdf")
|
||||
|
||||
val imageExtensions = imageExt + imageExt.map { it.uppercase() }
|
||||
val videoExtensions = videoExt + videoExt.map { it.uppercase() }
|
||||
val pdfExtensions = pdfExt + pdfExt.map { it.uppercase() }
|
||||
val audioExtensions = audioExt + audioExt.map { it.uppercase() }
|
||||
|
||||
val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)")
|
||||
val hashTagsPattern: Regex =
|
||||
@@ -512,14 +525,13 @@ class RichTextParser {
|
||||
it.uppercase()
|
||||
}
|
||||
|
||||
private fun removeQueryParamsForExtensionComparison(fullUrl: String): String =
|
||||
if (fullUrl.contains("?")) {
|
||||
fullUrl.split("?")[0]
|
||||
} else if (fullUrl.contains("#")) {
|
||||
fullUrl.split("#")[0]
|
||||
} else {
|
||||
fullUrl
|
||||
}
|
||||
private fun removeQueryParamsForExtensionComparison(fullUrl: String): String {
|
||||
// Called per URL during feed render — substringBefore allocates nothing when the
|
||||
// separator is absent, unlike split().
|
||||
val queryStart = fullUrl.indexOf('?')
|
||||
if (queryStart >= 0) return fullUrl.substring(0, queryStart)
|
||||
return fullUrl.substringBefore('#')
|
||||
}
|
||||
|
||||
fun isImageExtension(ext: String) = imageExtensions.any { it == ext }
|
||||
|
||||
@@ -542,6 +554,18 @@ class RichTextParser {
|
||||
return videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
}
|
||||
|
||||
fun isAudioUrl(url: String): Boolean {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
|
||||
return audioExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
}
|
||||
|
||||
// A declared MIME type is authoritative when present; the URL extension is only a fallback
|
||||
// for the common bare-URL case.
|
||||
fun isAudioContent(
|
||||
mimeType: String?,
|
||||
url: String,
|
||||
): Boolean = mimeType?.startsWith("audio/") ?: isAudioUrl(url)
|
||||
|
||||
// Mirrors the canonical HLS-playlist MIME list also kept in MediaItemCache.toExoPlayerMimeType.
|
||||
// Called per URL during feed render — uses `equals(ignoreCase)` instead of `lowercase()` to
|
||||
// avoid a per-call String allocation on the common non-HLS path.
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.viewmodels
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
|
||||
/**
|
||||
* One in-flight kind-9735 zap to render as a floating overlay on the
|
||||
|
||||
+5
-5
@@ -22,11 +22,11 @@ package com.vitorpamplona.amethyst.commons.actions
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Offer
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.Bolt12Values
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvRecord
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.bolt12.TlvStream
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Bech32
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Offer
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.Bolt12Values
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.TlvRecord
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.bolt12.TlvStream
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Verifies how validated NIP-XX BOLT12 zaps fold into a Note's aggregate zap
|
||||
* Verifies how validated NIP-B1 BOLT12 zaps fold into a Note's aggregate zap
|
||||
* accounting — the model contract the LocalCache ingest path and the reaction-row
|
||||
* counter depend on.
|
||||
*/
|
||||
@@ -57,7 +57,7 @@ class NoteBolt12ZapTest {
|
||||
|
||||
@Test
|
||||
fun keepsTheLowerAmountWhenTwoProofsShareAPaymentHash() {
|
||||
// NIP-XX: same proof identifier, differing amounts → count the LOWER, in either order.
|
||||
// NIP-B1: same proof identifier, differing amounts → count the LOWER, in either order.
|
||||
val a = note("a".repeat(64))
|
||||
a.addBolt12Zap(note("b".repeat(64)), "h", amountMillisats = 21_000_000L, cryptoVerified = true)
|
||||
a.addBolt12Zap(note("c".repeat(64)), "h", amountMillisats = 5_000_000L, cryptoVerified = true)
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.commons.preview
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class MetaTagsParserCharRefTest {
|
||||
@Test
|
||||
fun decodesNumericCharacterReferencesInContent() {
|
||||
val input =
|
||||
"""
|
||||
|<html><head>
|
||||
| <meta property="og:description" content=""You can always buy more tokens, not more time."">
|
||||
| <meta property="og:title" content=""hex quotes"">
|
||||
|</head></html>
|
||||
""".trimMargin()
|
||||
|
||||
val metaTags = MetaTagsParser.parse(input).toList()
|
||||
assertEquals(2, metaTags.size)
|
||||
assertEquals(
|
||||
"\"You can always buy more tokens, not more time.\"",
|
||||
metaTags[0].attr("content"),
|
||||
)
|
||||
assertEquals("\"hex quotes\"", metaTags[1].attr("content"))
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.commons.richtext
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RichTextParserAudioUrlTest {
|
||||
@Test
|
||||
fun mp3IsAudio() {
|
||||
assertTrue(RichTextParser.isAudioUrl("https://haven.sdbitcoiners.com/f28a5a2e.mp3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyAudioContainerIsAudio() {
|
||||
RichTextParser.audioExt.forEach {
|
||||
assertTrue(RichTextParser.isAudioUrl("https://example.com/a.$it"), it)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uppercaseIsAudio() {
|
||||
assertTrue(RichTextParser.isAudioUrl("https://example.com/A.MP3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queryParamsAndFragmentsAreIgnored() {
|
||||
assertTrue(RichTextParser.isAudioUrl("https://example.com/a.mp3?x=1"))
|
||||
assertTrue(RichTextParser.isAudioUrl("https://example.com/a.mp3#t=10"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoIsNotAudio() {
|
||||
assertFalse(RichTextParser.isAudioUrl("https://example.com/a.mp4"))
|
||||
assertFalse(RichTextParser.isAudioUrl("https://example.com/a.webm"))
|
||||
assertFalse(RichTextParser.isAudioUrl("https://example.com/a.mov"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hlsPlaylistIsNotAudio() {
|
||||
// A .m3u8 carries either, and a live stream is the reason the 16:9 default exists.
|
||||
assertFalse(RichTextParser.isAudioUrl("https://example.com/stream.m3u8"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imagesAndUnknownAreNotAudio() {
|
||||
assertFalse(RichTextParser.isAudioUrl("https://example.com/a.jpg"))
|
||||
assertFalse(RichTextParser.isAudioUrl("https://example.com/nothing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mimeTypeIsAuthoritativeOverTheUrl() {
|
||||
assertTrue(RichTextParser.isAudioContent("audio/mpeg", "https://example.com/download?id=7"))
|
||||
assertFalse(RichTextParser.isAudioContent("video/mp4", "https://example.com/a.mp3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun urlExtensionIsTheFallbackWithoutAMimeType() {
|
||||
assertTrue(RichTextParser.isAudioContent(null, "https://example.com/a.mp3"))
|
||||
assertFalse(RichTextParser.isAudioContent(null, "https://example.com/a.mp4"))
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -45,7 +45,7 @@ import com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker
|
||||
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nipXXBolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
@@ -135,6 +135,12 @@
|
||||
"Hindi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "StellarStoic",
|
||||
"languages": [
|
||||
"Slovenian"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "hypnotichemionus4",
|
||||
"languages": [
|
||||
@@ -147,12 +153,6 @@
|
||||
"Hungarian"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "StellarStoic",
|
||||
"languages": [
|
||||
"Slovenian"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "anthony-robin",
|
||||
"languages": [
|
||||
|
||||
+10
@@ -49,6 +49,16 @@ class MemberAddedNotificationEvent(
|
||||
/** The channel UUID the member was added to - the `h` tag. */
|
||||
fun channel() = tags.notificationChannel()
|
||||
|
||||
/**
|
||||
* The relay-reported body: who performed the change ([MembershipNotificationContent.actor]) and the
|
||||
* channel it applies to. The relay emits this kind for a self-join too, so the actor is what
|
||||
* separates "I joined" from "somebody added me" — see [MembershipNotificationContent].
|
||||
*/
|
||||
fun notification() = MembershipNotificationContent.parse(content)
|
||||
|
||||
/** The pubkey that performed the add/remove, or null when the body is missing or malformed. */
|
||||
fun actor() = notification()?.actor
|
||||
|
||||
companion object {
|
||||
const val KIND = 44100
|
||||
|
||||
|
||||
+10
@@ -49,6 +49,16 @@ class MemberRemovedNotificationEvent(
|
||||
/** The channel UUID the member was removed from - the `h` tag. */
|
||||
fun channel() = tags.notificationChannel()
|
||||
|
||||
/**
|
||||
* The relay-reported body: who performed the change ([MembershipNotificationContent.actor]) and the
|
||||
* channel it applies to. The relay emits this kind for a self-join too, so the actor is what
|
||||
* separates "I joined" from "somebody added me" — see [MembershipNotificationContent].
|
||||
*/
|
||||
fun notification() = MembershipNotificationContent.parse(content)
|
||||
|
||||
/** The pubkey that performed the add/remove, or null when the body is missing or malformed. */
|
||||
fun actor() = notification()?.actor
|
||||
|
||||
companion object {
|
||||
const val KIND = 44101
|
||||
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.buzz.notifications
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* The JSON body a Buzz relay puts on a kind-44100/44101 membership notification:
|
||||
*
|
||||
* ```json
|
||||
* {"type":"member_added","channel_id":"<uuid>","actor":"<pubkey hex>"}
|
||||
* ```
|
||||
*
|
||||
* Ground truth: `emit_membership_notification` in Buzz's `buzz-relay/src/handlers/side_effects.rs`.
|
||||
*
|
||||
* [actor] is the decisive field. The relay emits the *same* kind whether somebody added you or you
|
||||
* joined yourself — a self-join (kind 9021) is reported with `actor == target`. Without reading it,
|
||||
* "I joined this" and "a stranger put me in this" are indistinguishable, which is what let channels
|
||||
* appear silently.
|
||||
*/
|
||||
class MembershipNotificationContent(
|
||||
val type: String?,
|
||||
val channelId: String?,
|
||||
val actor: HexKey?,
|
||||
) {
|
||||
/** True when this records somebody *else* adding [viewer] — i.e. it needs the viewer's consent. */
|
||||
fun addedBySomeoneElse(viewer: HexKey): Boolean = actor != null && !actor.equals(viewer, ignoreCase = true)
|
||||
|
||||
companion object {
|
||||
const val TYPE_MEMBER_ADDED = "member_added"
|
||||
const val TYPE_MEMBER_REMOVED = "member_removed"
|
||||
|
||||
private val TYPE = Regex("\"type\"\\s*:\\s*\"([^\"]*)\"")
|
||||
private val CHANNEL_ID = Regex("\"channel_id\"\\s*:\\s*\"([^\"]*)\"")
|
||||
private val ACTOR = Regex("\"actor\"\\s*:\\s*\"([0-9a-fA-F]{64})\"")
|
||||
|
||||
/**
|
||||
* Parses the notification body, or null when [content] is blank/unparseable. Regex rather than a
|
||||
* JSON parse because this runs on the consume path for every membership notification and the
|
||||
* body is a fixed three-field object written by the relay; a malformed one must degrade to
|
||||
* "unknown actor" (treated as needing consent) rather than throw.
|
||||
*/
|
||||
fun parse(content: String): MembershipNotificationContent? {
|
||||
if (content.isBlank()) return null
|
||||
return try {
|
||||
MembershipNotificationContent(
|
||||
type = TYPE.find(content)?.groupValues?.get(1),
|
||||
channelId = CHANNEL_ID.find(content)?.groupValues?.get(1),
|
||||
actor =
|
||||
ACTOR
|
||||
.find(content)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.lowercase(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w("MembershipNotification") { "Could not parse membership notification: ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
-5
@@ -31,11 +31,26 @@ import kotlinx.serialization.json.Json
|
||||
* record of a channel state change (join, leave, rename, archive, delete, ...).
|
||||
*
|
||||
* Field names mirror the `serde_json::json!` payloads emitted by `emit_system_message`
|
||||
* and its callers in Buzz's `buzz-relay/src/handlers/side_effects.rs`. Only [type] is
|
||||
* always present; [actor] is present on almost every variant, and the remaining fields
|
||||
* are variant-specific (`member_joined`/`member_removed` carry [target], `topic_changed`
|
||||
* carries [topic], `ttl_changed` carries [ttlSeconds], etc). Unknown keys are ignored
|
||||
* for forward compatibility.
|
||||
* and its callers in Buzz's `crates/buzz-relay/src/handlers/`. Only [type] is always
|
||||
* present; [actor] is present on every variant the relay emits today, and the rest are
|
||||
* variant-specific. Unknown keys are ignored for forward compatibility, so a relay that
|
||||
* grows a new field or a new [type] degrades to a plain line instead of failing to parse.
|
||||
*
|
||||
* The complete vocabulary, read off `side_effects.rs` / `command_executor.rs`:
|
||||
*
|
||||
* | [type] | carries | emitted when |
|
||||
* |--|--|--|
|
||||
* | [MEMBER_JOINED] | [actor], [target] | someone was added ([actor] added [target]) or joined on their own ([actor] == [target]) |
|
||||
* | [MEMBER_LEFT] | [actor], optional [target] | a member left; the explicit-leave path omits [target] |
|
||||
* | [MEMBER_REMOVED] | [actor], [target] | [actor] removed [target] |
|
||||
* | [TOPIC_CHANGED] | [actor], [topic] | the channel topic was set |
|
||||
* | [PURPOSE_CHANGED] | [actor], [purpose] | the channel purpose was set |
|
||||
* | [VISIBILITY_CHANGED] | [actor], [visibility] | flipped between [VISIBILITY_OPEN] and [VISIBILITY_PRIVATE] |
|
||||
* | [TTL_CHANGED] | [actor], [ttlSeconds] | disappearing messages set; `null` [ttlSeconds] means cleared (permanent) |
|
||||
* | [CHANNEL_ARCHIVED] / [CHANNEL_UNARCHIVED] | [actor] | archive flag flipped |
|
||||
* | [CHANNEL_CREATED] / [CHANNEL_DELETED] | [actor] | channel lifecycle |
|
||||
* | [MESSAGE_DELETED] | [actor], [targetEventId], optional [actionId] / [reasonCode] / [publicReason] | a message was deleted (moderation tombstone) |
|
||||
* | [DM_CREATED] | [actor], [participants] | a DM channel was opened |
|
||||
*/
|
||||
@Serializable
|
||||
data class SystemMessagePayload(
|
||||
@@ -46,9 +61,29 @@ data class SystemMessagePayload(
|
||||
val purpose: String? = null,
|
||||
val visibility: String? = null,
|
||||
@SerialName("ttl_seconds") val ttlSeconds: Long? = null,
|
||||
/** The deleted message's id on [MESSAGE_DELETED]. */
|
||||
@SerialName("target_event_id") val targetEventId: String? = null,
|
||||
/** Moderation-action id linking the tombstone back to the action that caused it. */
|
||||
@SerialName("action_id") val actionId: String? = null,
|
||||
/** Machine-readable moderation reason (e.g. `spam`), when the deleter gave one. */
|
||||
@SerialName("reason_code") val reasonCode: String? = null,
|
||||
/** Human-readable moderation reason the relay is willing to show everyone. */
|
||||
@SerialName("public_reason") val publicReason: String? = null,
|
||||
/** Every participant of a newly opened DM, [actor] included. */
|
||||
val participants: List<String>? = null,
|
||||
) {
|
||||
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||
|
||||
/**
|
||||
* The pubkey the sentence is *about* — whose avatar to show. Membership changes are about the
|
||||
* member who joined/left/was removed; everything else is about whoever performed the action.
|
||||
*/
|
||||
fun subject(): String? =
|
||||
when (type) {
|
||||
MEMBER_JOINED, MEMBER_LEFT, MEMBER_REMOVED -> target ?: actor
|
||||
else -> actor
|
||||
}
|
||||
|
||||
companion object {
|
||||
val JSON =
|
||||
Json {
|
||||
@@ -58,5 +93,25 @@ data class SystemMessagePayload(
|
||||
}
|
||||
|
||||
fun decodeFromJson(json: String): SystemMessagePayload = JSON.decodeFromString(json)
|
||||
|
||||
const val MEMBER_JOINED = "member_joined"
|
||||
const val MEMBER_LEFT = "member_left"
|
||||
const val MEMBER_REMOVED = "member_removed"
|
||||
const val TOPIC_CHANGED = "topic_changed"
|
||||
const val PURPOSE_CHANGED = "purpose_changed"
|
||||
const val VISIBILITY_CHANGED = "visibility_changed"
|
||||
const val TTL_CHANGED = "ttl_changed"
|
||||
const val CHANNEL_ARCHIVED = "channel_archived"
|
||||
const val CHANNEL_UNARCHIVED = "channel_unarchived"
|
||||
const val CHANNEL_CREATED = "channel_created"
|
||||
const val CHANNEL_DELETED = "channel_deleted"
|
||||
const val MESSAGE_DELETED = "message_deleted"
|
||||
const val DM_CREATED = "dm_created"
|
||||
|
||||
/** Searchable, anyone can join. */
|
||||
const val VISIBILITY_OPEN = "open"
|
||||
|
||||
/** Hidden, invite-only. */
|
||||
const val VISIBILITY_PRIVATE = "private"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user