Merge branch 'main' into fix/versionname-worktree-cwd

This commit is contained in:
David Kaspar
2026-05-31 12:03:26 +02:00
committed by GitHub
97 changed files with 2094 additions and 470 deletions
@@ -19,13 +19,18 @@ Extract string resource keys from the default `values/strings.xml` that are abse
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated.
Consequences for this skill:
What this means for this skill:
1. **A "missing" key on disk is not always actionable.** It may be Crowdin-stripped (translator already chose source-identical and Crowdin didn't export it) rather than genuinely new.
2. **Don't add source-identical fallbacks locally.** Android's resource resolution falls back to `values/strings.xml` at runtime, so the user already sees the correct text. Local additions will be silently overwritten on Crowdin's next sync anyway.
3. **The only actionable cases are keys Crowdin has never exported.** Whether the translator picked "use English" or simply hasn't translated the key yet, both states are owned by Crowdin and look identical on disk. The local repo cannot distinguish them.
1. **The raw on-disk diff is the candidate set.** A key missing from a locale file is either genuinely untranslated *or* a source-identical entry Crowdin stripped. Both are reported; the human decides which to skip. The Crowdin web UI ("N untranslated") is the ground truth for what genuinely needs work.
2. **Source-identical entries are a small, recognizable minority.** Brand terms (`Nowhere X`), single-word loanwords (`Apps` / `Feed` / `Issues`), and bare version/format strings (`v%1$s`) are the usual cases. Skip these by inspection rather than translating them to something identical.
3. **Don't add source-identical fallbacks.** Android falls back to `values/strings.xml` at runtime, so a key intentionally kept as English already renders correctly, and Crowdin's next sync would strip a local duplicate anyway.
The Step 2.5 filter below uses the **most recent Crowdin export commit reachable from `HEAD`** (subject: `"New Crowdin translations by GitHub Action"`) as the cutoff: any key added to `values/strings.xml` after that commit is genuinely new (Crowdin hasn't exported it yet); anything older is Crowdin's responsibility regardless of why it's missing. The reachable-from-HEAD check survives the common workflow of deleting the `l10n_crowdin_translations` branch after merging.
> **Historical note:** an earlier version of this skill tried to auto-filter the
> candidate list with a git "sync-timestamp" heuristic (skip any key added before
> the last `New Crowdin translations` commit). It was **dropped** because it
> produced false negatives: a key added shortly before an export that translators
> simply hadn't reached yet is genuinely missing, but the heuristic classified it
> as "Crowdin already decided." Trust the raw diff + the Crowdin UI instead.
## Target Locales
@@ -72,74 +77,27 @@ comm -23 \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
```
This gives two lists of missing key names — keep them separate; `<plurals>` translations need the per-locale CLDR category set (see Step 5 → "Plurals: handle with care"). Do NOT diff each locale separately for strings — assume the same keys are missing in all target locales (but DO repeat the `<plurals>` diff per locale if you suspect Crowdin asymmetric stripping).
This gives two lists of missing key names — keep them separate; `<plurals>` translations need the per-locale CLDR category set (see Step 5 → "Plurals: handle with care").
> **Caveat:** Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys). If the cs-rCZ list looks suspiciously short, run the same diff for each target locale individually and union the results before Step 2.5.
### 2.5. Filter out keys Crowdin has already seen (sync-timestamp check)
A missing key is **only actionable if Crowdin has never exported it**. Once a key has been pushed to Crowdin and exported back, the translator may have chosen "use English" — Crowdin stores that choice in its own database and strips the entry from the exported `strings.xml`. From disk we cannot tell "translator picked English" from "Crowdin never saw the key": both look identical.
The reliable signal is **time**: compare when the key was added to `values/strings.xml` against the timestamp of the **most recent Crowdin export that has been merged into the current branch**. Crowdin's GitHub Action produces commits with the literal subject `New Crowdin translations by GitHub Action`; finding the latest such commit reachable from `HEAD` works even if the `l10n_crowdin_translations` branch has been deleted post-merge (a common cleanup workflow).
- Key added **before** that commit → Crowdin saw it on a prior export; translator made a decision; the absence on disk is a deliberate "use English" or "leave blank" choice. **Skip.**
- Key added **after** → Crowdin has not exported it yet; genuinely new and actionable.
Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys), so **cs-rCZ is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs-rCZ set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
```bash
# Latest Crowdin export reachable from HEAD (survives branch deletion).
sync_ts=$(git log -1 --format=%ct --grep='^New Crowdin translations by GitHub Action$' 2>/dev/null)
if [ -z "$sync_ts" ]; then
echo "WARNING: no Crowdin export commit found in history; treating all missing as actionable" >&2
sync_ts=0
else
echo "Crowdin sync cutoff: $(git log -1 --format='%ci %h' --grep='^New Crowdin translations by GitHub Action$')"
fi
# For each locale, list only keys added after the Crowdin sync (truly new).
# Run for BOTH <string> and <plurals>.
for locale in cs-rCZ de-rDE sv-rSE; do
echo "=== $locale: genuinely new (post-sync) <string> keys ==="
comm -23 \
for locale in cs-rCZ de-rDE sv-rSE pt-rBR; do
ns=$(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
| grep -v 'translatable="false"' | sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-$locale/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
| while IFS= read -r key; do
added_ts=$(git log -1 --format=%ct -S "name=\"$key\"" -- amethyst/src/main/res/values/strings.xml)
if [ -n "$added_ts" ] && [ "$added_ts" -gt "$sync_ts" ]; then
echo "$key"
fi
done
echo "=== $locale: genuinely new (post-sync) <plurals> keys ==="
comm -23 \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) | wc -l)
np=$(comm -23 \
<(grep '<plurals name=' amethyst/src/main/res/values/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<plurals name=' amethyst/src/main/res/values-$locale/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
| while IFS= read -r key; do
added_ts=$(git log -1 --format=%ct -S "name=\"$key\"" -- amethyst/src/main/res/values/strings.xml)
if [ -n "$added_ts" ] && [ "$added_ts" -gt "$sync_ts" ]; then
echo "$key"
fi
done
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) | wc -l)
echo "$locale: strings=$ns plurals=$np total=$((ns+np))"
done
```
**Why this beats using the `l10n_crowdin_translations` branch tip:**
- The branch is often deleted after merge — the branch tip then doesn't exist or points to a stale ref.
- The branch tip may include Crowdin commits that haven't been merged to main yet. Those changes aren't in our working tree, so they don't affect what's on disk for us. The "reachable from HEAD" cutoff matches what we can actually observe in `values-*/strings.xml`.
**Only the listed (post-sync) keys are actionable.** Anything older is either:
- A deliberate "use English" choice in Crowdin (brand terms like `Nowhere X`, loanwords like `Apps` / `Feed` / `Issues`, version prefixes like `v%1$s`), or
- A pending translation the translator hasn't filled in yet — still Crowdin's job, not ours.
In both cases, Android's resource resolution falls back to `values/strings.xml` at runtime, so there is no user-visible bug. Adding source-identical fallbacks locally is noise that the next sync will strip again.
Report the pre-sync skipped count as a one-liner ("N keys predate the last Crowdin sync, skipped — Crowdin owns them"). Do not list them or propose translations.
If no Crowdin export commit can be found in history (`sync_ts=0` fallback), warn the user and fall back to treating all missing keys as actionable — but flag that the workflow is degraded.
The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set (minus any source-identical entries you skip by inspection — see Background).
### 3. Get English values for missing keys
@@ -301,10 +259,9 @@ When adding translated strings to locale files:
- **Forgetting `translatable="false"`** — these should never appear in locale files
- **Diffing only `<string name=`** — `<plurals>` is a separate resource type; a source `<plurals>` missing from a locale will never show up in a `<string>` diff. Always run the diff twice (once per resource type) as shown in Step 2. The same goes for `<string-array>` if the project uses it.
- **Treating every missing key as actionable** — Crowdin strips on export any translation the translator marked as "use English", and we cannot distinguish that from "never seen" by looking at disk. Use the Step 2.5 sync-timestamp filter: only keys added to `values/strings.xml` after the last `l10n_crowdin_translations` sync are genuinely new.
- **Trying to detect "stripped" from git history alone** — the on-disk locale file only sees keys the translator typed a non-identical value for. The "translator opened the key and picked English from the start" case never touches disk, so a history-only check misses it. Use the sync-timestamp cutoff instead.
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so there is no user-visible bug to fix.
- **Skipping per-locale diffs when only diffing cs-rCZ** — Crowdin can strip different keys in different locales (each translator's choice), so cs-rCZ is not a reliable upper bound. Diff each target locale, then apply the sync-timestamp filter.
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
- **Skipping per-locale diffs when only diffing cs-rCZ** — Crowdin can strip different keys in different locales (each translator's choice), so cs-rCZ is not a reliable upper bound. Diff each target locale and union the results.
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
- **Hardcoding `"1"` in a `<plurals>` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken
import com.vitorpamplona.amethyst.service.cashu.CashuParser
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.runBlocking
@@ -25,11 +25,11 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
@@ -35,7 +35,13 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListD
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
@@ -72,20 +78,16 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSetsState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkListsState
import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.FollowListsState
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListsState
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
@@ -97,7 +99,6 @@ import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayLis
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState
import com.vitorpamplona.amethyst.model.nip62Vanish.VanishRequestsState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryptionCache
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState
import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState
@@ -111,7 +112,6 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches
import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
@@ -192,6 +192,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@@ -741,6 +742,48 @@ class Account(
cache.justConsumeMyOwnEvent(event)
}
/**
* NIP-32: tags [note] with [hashtag] by publishing a kind 1985 label event using the
* `#t` tag-association namespace. Fire-and-forget; signs and broadcasts immediately.
*/
suspend fun labelHashtag(
note: Note,
hashtag: String,
) {
createLabelHashtagEvent(note, hashtag)?.let { (event, relays) ->
cache.justConsumeMyOwnEvent(event)
client.publish(event, relays)
}
}
/**
* Builds and signs a NIP-32 hashtag label event for [note] without sending it.
* Returns the signed event and target relays for tracked broadcasting, or null if
* the account can't write or the note has no underlying event.
*/
suspend fun createLabelHashtagEvent(
note: Note,
hashtag: String,
): Pair<Event, Set<NormalizedRelayUrl>>? {
if (!signer.isWriteable()) return null
val eventHint = note.toEventHint<Event>() ?: return null
val template = LabelEvent.buildHashtagLabel(eventHint, hashtag)
val event = signer.sign(template)
val relays = computeRelayListToBroadcast(event)
return event to relays
}
/**
* Consumes a label event into local cache. Called when tracked broadcasting succeeds.
*/
fun consumeLabelEvent(event: Event) {
cache.justConsumeMyOwnEvent(event)
}
suspend fun createZapRequestFor(
event: Event,
pollOption: Int?,
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
@@ -385,21 +385,6 @@ class AccountSettings(
return false
}
fun moveNwcWallet(
fromIndex: Int,
toIndex: Int,
): Boolean {
val wallets = nwcWallets.value.toMutableList()
if (fromIndex in wallets.indices && toIndex in wallets.indices && fromIndex != toIndex) {
val item = wallets.removeAt(fromIndex)
wallets.add(toIndex, item)
nwcWallets.tryEmit(wallets)
saveAccountSettings()
return true
}
return false
}
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URINorm?): Boolean {
if (newServer == null) {
if (nwcWallets.value.isNotEmpty()) {
@@ -143,6 +143,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
@@ -1525,6 +1526,43 @@ object LocalCache : ILocalCache, ICacheProvider {
return false
}
fun consume(
event: LabelEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return true
if (wasVerified || justVerify(event)) {
val author = getOrCreateUser(event.pubKey)
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
// Attach NIP-32 hashtag labels (namespace #t) to the labeled events so the
// hashtag feed can surface posts tagged by a follow and attribute the labeler.
val hashtags = event.hashtagAssociations()
if (hashtags.isNotEmpty()) {
event.labeledEvents().mapNotNull { checkGetOrCreateNote(it) }.forEach { target ->
hashtags.forEach { hashtag -> target.addLabel(hashtag, note) }
// If the labeled post is already in cache, re-notify feed observers so the
// hashtag feed can pick it up now that a (possibly followed) user labeled it.
if (target.event != null) refreshNewNoteObservers(target)
}
}
refreshNewNoteObservers(note)
return true
}
return false
}
fun consume(
event: ReportEvent,
relay: NormalizedRelayUrl?,
@@ -3730,6 +3768,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consume(event, relay, wasVerified)
}
is LabelEvent -> {
consume(event, relay, wasVerified)
}
is ContactCardEvent -> {
consume(event, relay, wasVerified)
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip30CustomEmojis
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.OwnedEmojiPack
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists
import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.interestSets
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip51Lists.interestSets.InterestSet
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.muteList
import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.peopleList
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
@@ -203,6 +203,22 @@ class CashuWalletState(
.flowOn(Dispatchers.Default)
.stateIn(scope, SharingStarted.Eagerly, 0L)
/**
* Spendable cashu balance per mint URL — the reactive twin of
* [peekMintBalances], for UI that needs to re-render as proofs arrive
* (the wallet screen's mint list). Derived from [_tokenEntries] exactly
* like [balanceSats]. May overstate by NUT-07 stale proofs; the actual
* spend scrubs before melting.
*/
val mintBalances: StateFlow<Map<String, Long>> =
_tokenEntries
.map { entries ->
entries
.groupBy { it.content.mint }
.mapValues { (_, byMint) -> byMint.sumOf { it.content.totalAmount() } }
}.flowOn(Dispatchers.Default)
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
private val _history = MutableStateFlow<List<CashuSpendingHistoryEvent>>(emptyList())
val history: StateFlow<List<CashuSpendingHistoryEvent>> = _history.asStateFlow()
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip72Communities
import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
@@ -20,11 +20,11 @@
*/
package com.vitorpamplona.amethyst.model.topNavFeeds
import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryptionCache
class FeedDecryptionCaches(
val peopleListCache: PeopleListDecryptionCache,
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.trustedAssertions
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -41,7 +42,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.model.trustedAssertions.TrustProviderListState as ITrustProviderListState
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListState as ITrustProviderListState
class TrustProviderListState(
val signer: NostrSigner,
@@ -1,39 +0,0 @@
/*
* 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.service
import kotlin.math.roundToInt
fun countToHumanReadableBytes(counter: Int) =
when {
counter >= 1000000000 -> "${(counter / 1000000000f).roundToInt()} GB"
counter >= 1000000 -> "${(counter / 1000000f).roundToInt()} MB"
counter >= 1000 -> "${(counter / 1000f).roundToInt()} KB"
else -> "$counter"
}
fun countToHumanReadableBytes(counter: Long) =
when {
counter >= 1000000000 -> "${(counter / 1000000000f).roundToInt()} GB"
counter >= 1000000 -> "${(counter / 1000000f).roundToInt()} MB"
counter >= 1000 -> "${(counter / 1000f).roundToInt()} KB"
else -> "$counter"
}
@@ -1,43 +0,0 @@
/*
* 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.service
import kotlin.math.roundToInt
fun countToHumanReadable(
counter: Int,
str: String,
) = when {
counter >= 1000000000 -> "${(counter / 1000000000f).roundToInt()}G $str"
counter >= 1000000 -> "${(counter / 1000000f).roundToInt()}M $str"
counter >= 1000 -> "${(counter / 1000f).roundToInt()}K $str"
else -> "$counter $str"
}
fun countToHumanReadable(
counter: Long,
str: String,
) = when {
counter >= 1000000000 -> "${(counter / 1000000000f).roundToInt()}G $str"
counter >= 1000000 -> "${(counter / 1000000f).roundToInt()}M $str"
counter >= 1000 -> "${(counter / 1000f).roundToInt()}K $str"
else -> "$counter $str"
}
@@ -1,135 +0,0 @@
/*
* 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.service
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2
fun String.firstFullCharOld(): String =
when (this.length) {
0, 1 -> {
this
}
2, 3 -> {
if (isUTF16Char(0)) this.take(2) else this.take(1)
}
else -> {
val first = isUTF16Char(0)
val second = isUTF16Char(2)
if (first && second) {
this.take(4)
} else if (first) {
this.take(2)
} else {
this.take(1)
}
}
}
fun String.firstFullChar(): String {
var isInJoin = false
var hasHadSecondChance = false
var start = 0
var previousCharLength = 0
var next: Int
var codePoint: Int
var i = 0
while (i < this.length) {
codePoint = codePointAt(i)
// Skips if it starts with the join char 0x200D
if (codePoint == 0x200D && previousCharLength == 0) {
next = offsetByCodePoints(i, 1)
start = next
} else {
// If join, searches for the next char
if (codePoint == 0xFE0F) {
} else if (codePoint == 0x200D) {
isInJoin = true
} else {
// stops when two chars are not joined together
if (previousCharLength > 0 && !isInJoin) {
if (Character.charCount(codePoint) == 1 || hasHadSecondChance) {
break
} else {
hasHadSecondChance = true
}
} else {
hasHadSecondChance = false
}
isInJoin = false
}
// next char to evaluate
next = offsetByCodePoints(i, 1)
previousCharLength += (next - i)
}
i = next
}
// if ends in join, then seachers backwards until a char is found.
if (isInJoin) {
i = previousCharLength - 1
while (i > 0) {
if (this[i].code == 0x200D) {
previousCharLength -= 1
} else {
break
}
i -= 1
}
}
return substring(start, start + previousCharLength)
}
fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists<String>): String {
if (length <= 2) {
return firstFullChar()
}
if (this[0] == ':') {
// makes sure an emoji exists
val emojiParts = this.split(":", limit = 3)
if (emojiParts.size >= 2) {
val emojiName = emojiParts[1]
val emojiUrl = tags.lists.firstOrNull { it.size > 1 && it[1] == emojiName }?.getOrNull(2)
if (emojiUrl != null) {
return ":$emojiName:$emojiUrl"
}
}
}
if (EmojiCoder.isCoded(this)) {
return EmojiCoder.cropToFirstMessage(this)
}
return firstFullChar()
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.cashu
import android.util.LruCache
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import kotlinx.collections.immutable.ImmutableList
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.cashu
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken
import com.vitorpamplona.amethyst.service.cashu.v3.V3Parser
import com.vitorpamplona.amethyst.service.cashu.v4.V4Parser
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -24,7 +24,7 @@ import android.content.Context
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.asTextOrNull
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.cashu.melt
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken
class MeltResult(
val token: CashuToken,
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.cashu.v3
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.cashu.v3
import com.vitorpamplona.amethyst.service.cashu.Proof
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.Proof
import kotlinx.serialization.Serializable
@Serializable
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.cashu.v4
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.service.cashu.Proof
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.Proof
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlinx.collections.immutable.ImmutableList
@@ -55,8 +55,8 @@ import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken
import com.vitorpamplona.amethyst.service.cashu.CachedCashuParser
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.note.CopyIcon
@@ -201,6 +201,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainTransactionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.TopUpMintScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
@@ -348,6 +349,8 @@ fun BuildNavigation(
composableFromBottomArgs<Route.ReloadMint> { ReloadMintScreen(it.requestId, accountViewModel, nav) }
composableFromBottomArgs<Route.TopUpMint> { TopUpMintScreen(it.mintUrl, accountViewModel, nav) }
composableFromBottomArgs<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
composable<Route.Search> { SearchScreen(accountViewModel, nav) }
@@ -650,6 +650,11 @@ sealed class Route {
data class ReloadMint(
val requestId: String,
) : Route()
@Serializable
data class TopUpMint(
val mintUrl: String,
) : Route()
}
inline fun <reified T : Route> isBaseRoute(navController: NavHostController): Boolean = navController.currentBackStackEntry?.destination?.hasRoute<T>() == true
@@ -73,9 +73,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.util.firstFullChar
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.firstFullChar
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMapNotNull
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
@@ -0,0 +1,103 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* NIP-32: lets the user tag any post with a hashtag by publishing a kind 1985 label
* event (under the `#t` tag-association namespace). The label is public so that the
* author's and the labeler's followers can discover the post in the hashtag feed.
*/
@Composable
fun AddHashtagLabelDialog(
note: Note,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
var hashtag by remember { mutableStateOf("") }
// Strip the leading '#', drop whitespace and lowercase so the stored label matches the
// hashtag-feed convention. A blank result disables the confirm button.
val sanitized =
hashtag
.trim()
.removePrefix("#")
.filterNot { it.isWhitespace() }
.lowercase()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.add_hashtag_label_title)) },
text = {
Column {
Text(stringRes(R.string.add_hashtag_label_explainer))
OutlinedTextField(
value = hashtag,
onValueChange = { hashtag = it },
singleLine = true,
label = { Text(stringRes(R.string.add_hashtag_label_field)) },
prefix = { Text("#") },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
enabled = sanitized.isNotEmpty(),
onClick = {
accountViewModel.labelWithHashtag(note, sanitized)
onDismiss()
},
) {
Text(stringRes(R.string.add_hashtag_label_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.cancel))
}
},
)
}
@@ -115,6 +115,7 @@ fun NoteDropDownMenu(
nav: INav,
) {
var reportDialogShowing by remember { mutableStateOf(false) }
var addLabelDialogShowing by remember { mutableStateOf(false) }
val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle(
DropDownParams(
@@ -286,6 +287,9 @@ fun NoteDropDownMenu(
}
}
}
M3ActionRow(icon = MaterialSymbols.Tag, text = stringRes(R.string.add_hashtag_label)) {
addLabelDialogShowing = true
}
// Pick exactly one curation flow per kind: music tracks go to playlists, emoji
// packs go to the emoji list, everything else gets the standard bookmark rows.
// Showing both at once is noisy and makes "bookmark" feel like the catch-all when
@@ -387,6 +391,17 @@ fun NoteDropDownMenu(
onDismiss()
}
}
if (addLabelDialogShowing) {
AddHashtagLabelDialog(
note = note,
accountViewModel = accountViewModel,
onDismiss = {
addLabelDialogShowing = false
onDismiss()
},
)
}
}
@Composable
@@ -49,9 +49,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.countToHumanReadableBytes
import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -45,9 +45,9 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.countToHumanReadableBytes
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.LoadNote
@@ -51,10 +51,22 @@ fun RefresheableFeedView(
scrollStateKey: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
onLoaded: (@Composable (FeedState.Loaded, LazyListState) -> Unit)? = null,
) {
RefresheableBox(viewModel, enablePullRefresh) {
SaveableFeedState(viewModel.feedState, scrollStateKey) { listState ->
RenderFeedState(viewModel, accountViewModel, listState, nav, routeForLastRead)
if (onLoaded != null) {
RenderFeedState(
viewModel,
accountViewModel,
listState,
nav,
routeForLastRead,
onLoaded = { onLoaded(it, listState) },
)
} else {
RenderFeedState(viewModel, accountViewModel, listState, nav, routeForLastRead)
}
}
}
}
@@ -24,10 +24,10 @@ import android.content.Context
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip51Lists.interestSets.InterestSet
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSet
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.stringRes
@@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
import com.vitorpamplona.amethyst.commons.tor.TorType
@@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
@@ -1068,6 +1068,16 @@ class AccountViewModel(
direct = { account.removeBookmark(note, false) },
)
/** NIP-32: tags [note] with [hashtag] by publishing a kind 1985 label event. */
fun labelWithHashtag(
note: Note,
hashtag: String,
) = launchTrackedOrDirect(
createTracked = { account.createLabelHashtagEvent(note, hashtag) },
consumeTracked = account::consumeLabelEvent,
direct = { account.labelHashtag(note, hashtag) },
)
fun removeDeletedBookmarks(
deletedEventIds: Set<String>,
deletedAddresses: Set<Address>,
@@ -50,7 +50,7 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
@@ -42,10 +42,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.PinListState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -35,10 +35,10 @@ import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.PinListState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.fabBottomBarPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -31,8 +31,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
@@ -36,8 +36,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -36,8 +36,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -59,7 +59,7 @@ import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.OwnedEmojiPack
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
@@ -56,7 +56,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.OwnedEmojiPack
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
@@ -31,8 +31,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.OwnedEmojiPack
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
@@ -0,0 +1,160 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash
/**
* Hashtag feed renderer that adds an attribution banner above any post that a followed user
* surfaced through a NIP-32 hashtag label (rather than the author tagging it directly).
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HashtagFeedLoaded(
tag: String,
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
Column(Modifier.fillMaxWidth().animateItem()) {
HashtagLabelAttribution(item, tag, accountViewModel)
NoteCompose(
item,
modifier = Modifier.fillMaxWidth(),
routeForLastRead = null,
isBoostedNote = false,
isHiddenFeed = items.showHidden,
quotesLeft = 3,
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
/**
* Shows "🏷 #tag added by <user>" when a followed user labeled this post with the hashtag
* and the author didn't include it themselves. Renders nothing otherwise.
*/
@Composable
fun HashtagLabelAttribution(
note: Note,
tag: String,
accountViewModel: AccountViewModel,
) {
val labelState by note
.flow()
.labels.stateFlow
.collectAsStateWithLifecycle()
// The author already used the hashtag — nothing to attribute.
if (note.event?.isTaggedHash(tag) == true) return
val follows = accountViewModel.account.followingKeySet()
val labeler =
labelState.note.labels[tag.lowercase()]
?.firstOrNull { it.author?.pubkeyHex in follows }
?.author ?: return
Row(
modifier = Modifier.fillMaxWidth().then(HalfStartPadding),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Tag,
contentDescription = null,
modifier = Size16Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
Spacer(StdHorzSpacer)
Text(
"#$tag",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
Spacer(StdHorzSpacer)
Text(
stringRes(R.string.hashtag_label_added_by),
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
Spacer(StdHorzSpacer)
UsernameDisplay(
labeler,
fontWeight = FontWeight.Bold,
textColor = MaterialTheme.colorScheme.placeholderText,
accountViewModel = accountViewModel,
)
}
}
@@ -113,6 +113,9 @@ fun HashtagScreen(
null,
accountViewModel = accountViewModel,
nav = nav,
onLoaded = { state, listState ->
HashtagFeedLoaded(tag.hashtag, state, listState, accountViewModel, nav)
},
)
}
}
@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
@@ -53,9 +54,10 @@ class HashtagFeedFilter(
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + tag
override fun feed(): List<Note> {
val follows = account.followingKeySet()
val notes =
cache.notes.filterIntoSet { _, it ->
acceptableEvent(it, tag)
acceptableEvent(it, tag, follows)
}
return sort(notes)
@@ -63,16 +65,39 @@ class HashtagFeedFilter(
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> = collection.filterTo(HashSet()) { acceptableEvent(it, tag) }
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val follows = account.followingKeySet()
return collection.filterTo(HashSet()) { acceptableEvent(it, tag, follows) }
}
fun acceptableEvent(
it: Note,
hashTag: String,
follows: Set<HexKey>,
): Boolean =
(acceptableViaHashtag(it.event, hashTag) || acceptableViaScope(it.event, hashTag)) &&
(
acceptableViaHashtag(it.event, hashTag) ||
acceptableViaScope(it.event, hashTag) ||
acceptableViaFollowLabel(it, hashTag, follows)
) &&
!it.isHiddenFor(account.hiddenUsers.flow.value) &&
account.isAcceptable(it)
/**
* NIP-32: accept a post that a followed user has tagged with this hashtag via a kind
* 1985 label event (namespace `#t`), even if the post's author never used the hashtag.
* Requires the target to actually have an event so it can render.
*/
fun acceptableViaFollowLabel(
note: Note,
hashTag: String,
follows: Set<HexKey>,
): Boolean {
if (note.event == null) return false
val labelers = note.labels[hashTag.lowercase()] ?: return false
return labelers.any { it.author?.pubkeyHex in follows }
}
fun acceptableViaHashtag(
event: Event?,
hashTag: String,
@@ -0,0 +1,103 @@
/*
* 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.hashtag.datasource
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent
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.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
import com.vitorpamplona.quartz.utils.mapOfSet
import kotlin.math.min
/**
* Builds the relay filters that power the NIP-32 side of the hashtag feed:
*
* 1. A subscription for kind 1985 label events that tag posts with this hashtag (the `l`
* tag under the `#t` namespace), routed through the NIP-65 outbox model: each follow's
* labels are requested from THAT follow's outbox relays, with the relay's filter
* restricted to the follows that actually write there. This is what guarantees we pick
* up a follow's labels even if they never reach the hashtag's own relays.
* 2. Requests for the underlying posts a *followed* user has labeled but that aren't in
* cache yet, so they can actually render in the feed. This rotates as new label
* events arrive (the sub-assembler re-runs after each EOSE).
*/
fun filterHashtagLabels(
account: Account,
hashtag: String,
relays: Set<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val labelValues = hashtagAlts(hashtag).sorted()
// Outbox routing: relay -> the follows who publish to it.
val followsPerRelay = account.followsPerRelay.value
val labelFilters =
followsPerRelay.mapNotNull { (relay, authors) ->
if (authors.isEmpty()) return@mapNotNull null
val authorList = authors.sorted()
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(LabelEvent.KIND),
authors = authorList,
tags = mapOf("l" to labelValues),
limit = min(authorList.size * 5, 500),
since = since?.get(relay)?.time,
),
)
}
val follows = account.followingKeySet()
// Collect the posts that a followed user labeled with this hashtag but that we don't
// have the full event for yet, and ask their likely relays for them.
val labelNotes =
LocalCache.notes.filterIntoSet { _, note ->
val noteEvent = note.event
noteEvent is LabelEvent &&
noteEvent.pubKey in follows &&
hashtag in noteEvent.hashtagAssociations()
}
val missingTargets =
mapOfSet {
labelNotes.forEach { labelNote ->
(labelNote.event as LabelEvent).labeledEvents().forEach { targetId ->
val target = LocalCache.getNoteIfExists(targetId)
if (target?.event == null) {
val targetNote = LocalCache.getOrCreateNote(targetId)
potentialRelaysToFindEvent(targetNote).ifEmpty { relays }.forEach { relayUrl ->
add(relayUrl, targetId)
}
}
}
}
}
return labelFilters + filterMissingEvents(missingTargets)
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -28,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class HashtagQueryState(
val hashtag: String,
val relays: Set<NormalizedRelayUrl>,
val account: Account,
) {
val lowercaseHashtag = hashtag.lowercase()
}
@@ -38,6 +40,7 @@ class HashtagFilterAssembler(
val group =
listOf(
HashtagFeedFilterSubAssembler(client, ::allKeys),
HashtagLabelSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
@@ -35,7 +35,11 @@ fun HashtagFilterAssemblerSubscription(
// even if they are tracking the same tag.
val state =
remember(tag) {
HashtagQueryState(tag.hashtag, accountViewModel.account.followOutboxesOrProxy.flow.value)
HashtagQueryState(
tag.hashtag,
accountViewModel.account.followOutboxesOrProxy.flow.value,
accountViewModel.account,
)
}
LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().hashtags)
@@ -0,0 +1,46 @@
/*
* 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.hashtag.datasource
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
/**
* NIP-32: streams kind 1985 label events that tag posts with the screen's hashtag and
* pulls in the posts a followed user labeled. Rotates after each EOSE (`invalidateAfterEose`)
* so newly-arrived label events get their target posts fetched on the next pass.
*/
class HashtagLabelSubAssembler(
client: INostrClient,
allKeys: () -> Set<HashtagQueryState>,
) : PerUniqueIdEoseManager<HashtagQueryState, String>(client, allKeys, invalidateAfterEose = true) {
override fun updateFilter(
key: HashtagQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> = filterHashtagLabels(key.account, key.lowercaseHashtag, key.relays, since)
/**
* Only one key per hashtag. Prefixed so it doesn't collide with the main feed sub-assembler.
*/
override fun id(key: HashtagQueryState) = "label-" + key.lowercaseHashtag
}
@@ -49,7 +49,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSet
import com.vitorpamplona.amethyst.commons.model.nip51Lists.interestSets.InterestSet
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -36,7 +36,7 @@ import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSet
import com.vitorpamplona.amethyst.commons.model.nip51Lists.interestSets.InterestSet
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
@@ -27,8 +27,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.nip51Lists.interestSets.InterestSet
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSet
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Stable
@@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
import com.vitorpamplona.amethyst.commons.util.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -39,7 +39,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.countToHumanReadable
import com.vitorpamplona.amethyst.commons.util.countToHumanReadable
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font10SP
import com.vitorpamplona.amethyst.ui.theme.Size10Modifier
@@ -36,8 +36,8 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.countToHumanReadable
import com.vitorpamplona.amethyst.service.countToHumanReadableBytes
import com.vitorpamplona.amethyst.commons.util.countToHumanReadable
import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -115,6 +115,7 @@ fun CashuWalletScreen(
val discovering by viewModel.discovering.collectAsState()
val mints by viewModel.mints.collectAsState()
val balanceSats by viewModel.balanceSats.collectAsState()
val mintBalances by viewModel.mintBalances.collectAsState()
val history by viewModel.history.collectAsState()
val pendingQuotes by viewModel.pendingQuotes.collectAsState()
@@ -158,6 +159,7 @@ fun CashuWalletScreen(
modifier = Modifier.padding(padding),
balanceSats = balanceSats,
mints = mints,
mintBalances = mintBalances,
history = history,
pendingQuoteCount = pendingQuotes.size,
accountViewModel = accountViewModel,
@@ -166,7 +168,7 @@ fun CashuWalletScreen(
onSendLn = { sendLnOpen = true },
onSendToken = { sendTokenOpen = true },
onRedeem = { redeemOpen = true },
onRecommendMint = { viewModel.recommendMint(it) },
onTopUpMint = { nav.nav(Route.TopUpMint(it)) },
onResumePendingQuote = {
pendingQuotes.firstOrNull()?.let {
viewModel.resumeMintQuote(it)
@@ -295,6 +297,7 @@ private fun CashuWalletContent(
modifier: Modifier,
balanceSats: Long,
mints: List<String>,
mintBalances: Map<String, Long>,
history: List<CashuSpendingHistoryEvent>,
pendingQuoteCount: Int,
accountViewModel: AccountViewModel,
@@ -303,7 +306,7 @@ private fun CashuWalletContent(
onSendLn: () -> Unit,
onSendToken: () -> Unit,
onRedeem: () -> Unit,
onRecommendMint: (String) -> Unit,
onTopUpMint: (String) -> Unit,
onResumePendingQuote: () -> Unit,
) {
LazyColumn(
@@ -340,7 +343,7 @@ private fun CashuWalletContent(
)
}
items(mints, key = { it }) { mint ->
MintRow(mint = mint, onRecommend = { onRecommendMint(mint) })
MintRow(mint = mint, balanceSats = mintBalances[mint] ?: 0L, onTopUp = { onTopUpMint(mint) })
}
if (history.isNotEmpty()) {
@@ -526,14 +529,19 @@ private fun ActionTile(
@Composable
private fun MintRow(
mint: String,
onRecommend: (() -> Unit)? = null,
balanceSats: Long,
onTopUp: () -> Unit,
) {
val formattedBalance =
remember(balanceSats) {
NumberFormat.getIntegerInstance().format(balanceSats)
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
modifier = Modifier.padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Material3Icon(
@@ -543,15 +551,20 @@ private fun MintRow(
)
Spacer(modifier = Modifier.width(12.dp))
Text(text = mint, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
if (onRecommend != null) {
IconButton(onClick = onRecommend, modifier = Modifier.size(28.dp)) {
Icon(
symbol = MaterialSymbols.ThumbUp,
contentDescription = stringRes(R.string.cashu_recommend_mint),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$formattedBalance ${stringRes(R.string.wallet_sats)}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
IconButton(onClick = onTopUp) {
Icon(
symbol = MaterialSymbols.AddCircle,
contentDescription = stringRes(R.string.topup_mint_action),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@@ -146,6 +146,7 @@ class CashuWalletViewModel : ViewModel() {
val walletEvent get() = state.walletEvent
val mints get() = state.mints
val balanceSats get() = state.balanceSats
val mintBalances get() = state.mintBalances
val tokenEntries: StateFlow<List<TokenEntry>> get() = state.tokenEntries
val history get() = state.history
val pendingQuotes get() = state.pendingQuotes
@@ -58,9 +58,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -154,9 +152,6 @@ fun OnchainSection(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Spacer(modifier = Modifier.height(12.dp))
AddressBlock(address = address)
Spacer(modifier = Modifier.height(12.dp))
ActionRow(address = address, accountViewModel = accountViewModel, orange = orange)
}
@@ -337,23 +332,6 @@ private fun BalanceBlock(
}
}
@Composable
private fun AddressBlock(address: String) {
Text(
text = "Your Taproot address",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = address,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun ActionRow(
address: String,
@@ -99,9 +99,9 @@ fun navigateToReloadMint(
nav.nav(Route.ReloadMint(uid))
}
private fun shortMint(url: String): String = url.removePrefix("https://").removePrefix("http://").removeSuffix("/")
internal fun shortMint(url: String): String = url.removePrefix("https://").removePrefix("http://").removeSuffix("/")
private fun sats(value: Long): String = showAmount(value.toBigDecimal().setScale(1))
internal fun sats(value: Long): String = showAmount(value.toBigDecimal().setScale(1))
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -359,7 +359,7 @@ fun ReloadMintScreen(
}
@Composable
private fun SectionHeader(text: String) {
internal fun SectionHeader(text: String) {
Text(
text = text,
color = MaterialTheme.colorScheme.primary,
@@ -368,7 +368,7 @@ private fun SectionHeader(text: String) {
}
@Composable
private fun SourceRow(
internal fun SourceRow(
source: ReloadSource,
selected: Boolean,
onSelect: () -> Unit,
@@ -0,0 +1,224 @@
/*
* 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.wallet
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.launch
import androidx.compose.material3.Icon as Material3Icon
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopUpMintScreen(
mintUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: TopUpMintViewModel = viewModel()
LaunchedEffect(mintUrl) { viewModel.init(accountViewModel, mintUrl) }
val ui by viewModel.uiState.collectAsStateWithLifecycle()
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var amountText by remember(mintUrl) { mutableStateOf("") }
if (ui.status is TopUpStatus.Done) {
LaunchedEffect(Unit) { nav.popBack() }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.topup_mint_title)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
// ── Header: the mint we're topping up + its current balance ────
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Material3Icon(
imageVector = CustomHashTagIcons.Cashu,
contentDescription = null,
modifier = Modifier.size(26.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = shortMint(ui.targetMint),
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.reload_mint_available, sats(ui.targetBalanceSats)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
}
// ── Amount to add ─────────────────────────────────────────────
SectionHeader(stringRes(R.string.topup_mint_amount_label))
OutlinedTextField(
value = amountText,
onValueChange = { input ->
val digits = input.filter { it.isDigit() }.take(12)
amountText = digits
viewModel.setAmount(digits.toLongOrNull() ?: 0L)
},
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = { Text(stringRes(R.string.sats)) },
modifier = Modifier.fillMaxWidth(),
)
// ── Funds from ────────────────────────────────────────────────
SectionHeader(stringRes(R.string.reload_mint_section_from))
ui.sources.forEach { source ->
SourceRow(
source = source,
selected = source == ui.selectedSource,
onSelect = { viewModel.selectSource(source) },
)
}
Spacer(Modifier.height(4.dp))
when (val status = ui.status) {
is TopUpStatus.Working -> {
LinearProgressIndicator(progress = { status.progress }, modifier = Modifier.fillMaxWidth())
Text(status.step, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
}
is TopUpStatus.AwaitingInvoice -> {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
CircularProgressIndicator(modifier = Modifier.height(20.dp))
Text(stringRes(R.string.reload_mint_awaiting_payment), style = MaterialTheme.typography.bodyMedium)
}
OutlinedButton(
onClick = { scope.launch { clipboard.setText(status.invoice) } },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.reload_mint_copy_invoice))
}
}
is TopUpStatus.Failed -> {
Text(status.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
Button(onClick = { viewModel.confirm() }, modifier = Modifier.fillMaxWidth()) {
Text(stringRes(R.string.reload_mint_retry))
}
}
else -> {
val source = ui.selectedSource
val enabled =
ui.amountSats > 0 &&
(
source is ReloadSource.LightningWallet ||
source is ReloadSource.LightningExternal ||
(source is ReloadSource.Mint && source.canCover)
)
Button(
onClick = { viewModel.confirm() },
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.topup_mint_confirm))
}
}
}
}
}
}
@@ -0,0 +1,307 @@
/*
* 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.wallet
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState
import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* Presenter for the standalone "Top up a mint" screen.
*
* Reuses the same funding primitives as [ReloadMintViewModel] —
* [CashuWalletState.rebalance] for a mint-to-mint move and
* [com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletOps.startMintFromLightning] /
* `completeMintFromLightning` for an LN top-up — but without any of the
* zap-specific machinery (recipient, shared-mint intersection, fixed send
* amount + shortfall, terminal nutzap, fund-then-send atomicity). The user
* picks an amount and a source; we add ecash to a fixed target mint and pop.
*/
class TopUpMintViewModel : ViewModel() {
private var accountViewModel: AccountViewModel? = null
private var targetMint: String = ""
private val state: CashuWalletState? get() = accountViewModel?.account?.cashuWalletState
/** The in-flight pipeline, cancelled when the screen leaves (it runs on the
* long-lived AccountViewModel scope, not this VM's). */
private var job: Job? = null
private val _uiState = MutableStateFlow(TopUpUiState())
val uiState: StateFlow<TopUpUiState> = _uiState.asStateFlow()
fun init(
accountViewModel: AccountViewModel,
mintUrl: String,
) {
if (this.accountViewModel != null) return // already initialized
this.accountViewModel = accountViewModel
this.targetMint = mintUrl
_uiState.update { it.copy(targetMint = mintUrl) }
// Wallet proofs/mints arrive from relays and can land after the screen
// opens, so keep the source list + target balance in sync as the wallet
// fills in. Project to the per-mint balance map and distinctUntilChanged
// so unrelated wallet churn doesn't re-run the rebuild on every emission.
val st = state ?: return
viewModelScope.launch {
combine(st.tokenEntries, st.mints) { _, _ -> st.peekMintBalances() }
.distinctUntilChanged()
.collect { rebuild(it) }
}
}
/** Rebuild target balance + funding sources from the current wallet state,
* keeping the user's amount and source pick intact. */
private fun rebuild(balances: Map<String, Long>) {
_uiState.update { cur -> recompute(cur.copy(targetBalanceSats = balances[targetMint] ?: 0L), balances) }
}
/** Recompute fee estimate + sources + a default source for the current amount. */
private fun recompute(
base: TopUpUiState,
balances: Map<String, Long>,
): TopUpUiState {
val moveSats = base.amountSats
// Rough fee cushion for *enabling* a mint source — the real Lightning
// feeReserve is only known once rebalance() fetches the melt quote, so this
// is a heuristic (1%, min 2 sat). A source that clears the estimate but not
// the real quote fails recoverably without moving funds.
val estFee = if (moveSats <= 0L) 0L else (moveSats / 100L).coerceAtLeast(2L)
val needFromSource = moveSats + estFee
val mintSources =
balances
.filterKeys { it != targetMint }
.entries
.sortedByDescending { it.value }
.map { ReloadSource.Mint(it.key, it.value, canCover = moveSats <= 0L || it.value >= needFromSource) }
val lightningSources: List<ReloadSource> =
nwcWallets()
.map { ReloadSource.LightningWallet(it.id, it.name) }
.ifEmpty { listOf(ReloadSource.LightningExternal) }
val sources = mintSources + lightningSources
// Keep the current pick if still valid; else default to the richest mint that
// can cover (no new sats), else the configured/first Lightning wallet.
val keep =
when (val prev = base.selectedSource) {
is ReloadSource.Mint -> mintSources.firstOrNull { it.mintUrl == prev.mintUrl && it.canCover }
is ReloadSource.LightningWallet -> lightningSources.firstOrNull { it is ReloadSource.LightningWallet && it.walletId == prev.walletId }
ReloadSource.LightningExternal -> lightningSources.firstOrNull { it is ReloadSource.LightningExternal }
null -> null
}
val selectedSource: ReloadSource? =
keep
?: mintSources.filter { it.canCover }.maxByOrNull { it.balanceSats }
?: defaultLightningSource(lightningSources)
return base.copy(
estFeeSats = estFee,
sources = sources.toImmutableList(),
selectedSource = selectedSource,
)
}
fun selectSource(source: ReloadSource) {
_uiState.update { it.copy(selectedSource = source) }
}
fun setAmount(sats: Long) {
_uiState.update { recompute(it.copy(amountSats = sats.coerceAtLeast(0L)), currentBalances()) }
}
private fun currentBalances(): Map<String, Long> = state?.peekMintBalances().orEmpty()
fun confirm() {
val vm = accountViewModel ?: return
val s = _uiState.value
val source = s.selectedSource ?: return
val moveSats = s.amountSats
if (moveSats <= 0L) return
// In-flight guard: only start from a resting state so a double tap (or the
// Failed-state retry) can't launch a second pipeline.
if (s.status !is TopUpStatus.Configuring && s.status !is TopUpStatus.Failed) return
setStatus(TopUpStatus.Working("Starting", 0.05f))
job?.cancel()
job =
vm.launchSigner {
try {
when (source) {
is ReloadSource.Mint -> topUpFromMint(source.mintUrl, moveSats)
is ReloadSource.LightningWallet -> topUpFromLightning(moveSats, walletUriFor(source.walletId))
ReloadSource.LightningExternal -> topUpFromLightning(moveSats, null)
}
setStatus(TopUpStatus.Done)
} catch (e: CancellationException) {
throw e // screen left mid-flow — don't mask as a Failed state
} catch (e: Exception) {
setStatus(TopUpStatus.Failed(describeMintError(e)))
}
}
}
private suspend fun topUpFromMint(
sourceMint: String,
moveSats: Long,
) {
val st = state ?: return
setStatus(TopUpStatus.Working("Moving funds", 0.1f))
st.rebalance(
sourceMintUrl = sourceMint,
targetMintUrl = targetMint,
sats = moveSats,
onProgress = { p -> setStatus(TopUpStatus.Working("Moving funds", p.coerceIn(0.1f, 0.95f))) },
)
}
private suspend fun topUpFromLightning(
moveSats: Long,
walletUri: Nip47WalletConnect.Nip47URINorm?,
) {
val vm = accountViewModel ?: return
val st = state ?: return
val ops = st.ops
setStatus(TopUpStatus.Working("Requesting invoice", 0.1f))
val flow = ops.startMintFromLightning(targetMint, moveSats)
if (walletUri != null) {
setStatus(TopUpStatus.Working("Paying from your wallet", 0.35f))
// Fire-and-forget: the mint-quote poll below is the source of truth for
// whether the payment actually landed.
runCatching {
vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
}
} else {
// No NWC — surface the invoice for an external wallet and keep polling.
setStatus(TopUpStatus.AwaitingInvoice(flow.invoice, moveSats))
}
// External payment can take a while; the poll runs on a job tied to the
// screen (cancelled in onCleared), so leaving stops it.
val attempts = 90
val delayMs = 2_000L
var paid = false
var attempt = 0
while (!paid && attempt < attempts) {
paid = ops.checkMintQuote(targetMint, flow.mintQuote.quote).isSettled()
if (!paid) {
delay(delayMs)
attempt++
}
}
if (!paid) {
setStatus(TopUpStatus.Failed("Invoice not paid yet — you can finish it later from the pending quote banner"))
return
}
setStatus(TopUpStatus.Working("Issuing ecash", 0.85f))
ops.completeMintFromLightning(targetMint, flow.quoteEvent, moveSats)
}
private fun setStatus(status: TopUpStatus) {
_uiState.update { it.copy(status = status) }
}
private fun nwcWallets() =
accountViewModel
?.account
?.settings
?.nwcWallets
?.value
.orEmpty()
private fun walletUriFor(walletId: String) = nwcWallets().firstOrNull { it.id == walletId }?.uri
/** The configured default NWC wallet's source, else the first Lightning source. */
private fun defaultLightningSource(lightning: List<ReloadSource>): ReloadSource? {
val defaultId =
accountViewModel
?.account
?.settings
?.defaultNwcWallet()
?.id
return lightning.firstOrNull { it is ReloadSource.LightningWallet && it.walletId == defaultId }
?: lightning.firstOrNull()
}
override fun onCleared() {
// The pipeline runs on the AccountViewModel scope, not this VM's, so it would
// outlive the screen — cancel it when the screen goes away.
job?.cancel()
super.onCleared()
}
}
/** Progress of a standalone mint top-up. Mirrors [ReloadStatus] minus the zap step. */
@Immutable
sealed interface TopUpStatus {
data object Configuring : TopUpStatus
data class Working(
val step: String,
val progress: Float,
) : TopUpStatus
/** External Lightning — the user must pay [invoice]; we keep polling. */
data class AwaitingInvoice(
val invoice: String,
val sats: Long,
) : TopUpStatus
data object Done : TopUpStatus
data class Failed(
val message: String,
) : TopUpStatus
}
@Immutable
data class TopUpUiState(
val targetMint: String = "",
val targetBalanceSats: Long = 0L,
/** The amount to add to the target mint. */
val amountSats: Long = 0L,
val estFeeSats: Long = 0L,
val sources: ImmutableList<ReloadSource> = persistentListOf(),
val selectedSource: ReloadSource? = null,
val status: TopUpStatus = TopUpStatus.Configuring,
)
@@ -21,8 +21,10 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -34,8 +36,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -61,6 +64,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -76,10 +81,6 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayDragState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.draggableRelayItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relayDragHandle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.rememberRelayDragState
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.launch
import java.text.NumberFormat
@@ -221,12 +222,6 @@ private fun MultiWalletHomeContent(
}
}
val dragState =
rememberRelayDragState(
onMove = { from, to -> walletViewModel.moveWallet(from, to) },
itemCount = { walletInfoList.size },
)
LazyColumn(
state = listState,
modifier =
@@ -234,17 +229,10 @@ private fun MultiWalletHomeContent(
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
userScrollEnabled = !dragState.isDragging,
) {
item {
Spacer(modifier = Modifier.height(8.dp))
}
itemsIndexed(walletInfoList, key = { _, info -> info.walletId }) { index, walletInfo ->
items(walletInfoList, key = { info -> info.walletId }) { walletInfo ->
WalletCard(
walletInfo = walletInfo,
index = index,
dragState = if (walletInfoList.size > 1) dragState else null,
onSelect = {
walletViewModel.selectWallet(walletInfo.walletId)
nav.nav(Route.WalletDetail(walletInfo.walletId))
@@ -293,8 +281,6 @@ private fun MultiWalletHomeContent(
@Composable
private fun WalletCard(
walletInfo: WalletInfo,
index: Int,
dragState: RelayDragState?,
onSelect: () -> Unit,
onSetDefault: () -> Unit,
onRename: (String) -> Unit,
@@ -339,7 +325,6 @@ private fun WalletCard(
modifier =
Modifier
.fillMaxWidth()
.let { if (dragState != null) it.draggableRelayItem(index, dragState) else it }
.clickable(onClick = onSelect),
shape = RoundedCornerShape(16.dp),
border =
@@ -366,22 +351,6 @@ private fun WalletCard(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
// Drag handle leads the row — same convention as the
// relay reorder list — so a center-aligned icon doesn't
// land between the balance amount and its "sats" label.
if (dragState != null) {
Icon(
MaterialSymbols.DragIndicator,
contentDescription = stringRes(R.string.wallet_reorder),
modifier =
Modifier
.size(24.dp)
.relayDragHandle(index, dragState),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(8.dp))
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -489,6 +458,29 @@ private fun WalletCard(
}
}
// Mirrors OnchainSection's BitcoinChip so both payment rails render their
// logo the same way — a filled circular chip with a white glyph — instead of
// one being a bare tinted vector. Cashu uses the theme primary in place of
// bitcoin orange to keep the two rails distinct at a glance.
@Composable
private fun CashuChip() {
Box(
modifier =
Modifier
.size(28.dp)
.clip(CircleShape)
.background(color = MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Material3Icon(
imageVector = CustomHashTagIcons.Cashu,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(18.dp),
)
}
}
@Composable
private fun CashuWalletRow(
balanceSats: Long,
@@ -512,12 +504,7 @@ private fun CashuWalletRow(
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Material3Icon(
imageVector = CustomHashTagIcons.Cashu,
contentDescription = null,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary,
)
CashuChip()
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
@@ -296,15 +296,6 @@ class WalletViewModel : ViewModel() {
refreshWalletList()
}
fun moveWallet(
fromIndex: Int,
toIndex: Int,
) {
val acc = account ?: return
acc.settings.moveNwcWallet(fromIndex, toIndex)
refreshWalletList()
}
fun selectWallet(walletId: String) {
_selectedWalletId.value = walletId
_balanceSats.value = walletInfoMap.value[walletId]?.balanceSats
+143 -1
View File
@@ -747,6 +747,17 @@
<string name="articles">Články</string>
<string name="route_music_tracks">Hudba</string>
<string name="route_music_playlists">Playlisty</string>
<string name="route_podcast_episodes">Epizody</string>
<string name="route_podcasts">Podcasty</string>
<string name="podcast_view_episodes">Zobrazit epizody</string>
<string name="podcast_episodes_section">Epizody</string>
<string name="podcast_no_episodes">Zatím nenalezeny žádné epizody</string>
<plurals name="podcast_episode_count">
<item quantity="one">%1$d epizoda</item>
<item quantity="few">%1$d epizody</item>
<item quantity="many">%1$d epizody</item>
<item quantity="other">%1$d epizod</item>
</plurals>
<string name="new_music_track">Nová hudební stopa</string>
<string name="new_music_playlist">Nový playlist</string>
<string name="music_track_title_label">Název</string>
@@ -781,6 +792,11 @@
<string name="add_to_public_bookmarks">Přidat do veřejných záložek</string>
<string name="remove_from_private_bookmarks">Odebrat ze soukromých záložek</string>
<string name="remove_from_public_bookmarks">Odebrat z veřejných záložek</string>
<string name="add_hashtag_label">Přidat hashtag</string>
<string name="add_hashtag_label_title">Přidat hashtag</string>
<string name="add_hashtag_label_explainer">Veřejně označte tento příspěvek hashtagem (štítek NIP-32). Lidé, kteří vás sledují, jej uvidí ve feedu daného hashtagu.</string>
<string name="add_hashtag_label_confirm">Přidat</string>
<string name="hashtag_label_added_by">přidal(a)</string>
<string name="pinned_notes">Připnuté poznámky</string>
<string name="pinned_notes_explainer">Vaše připnuté poznámky</string>
<string name="pin_to_profile">Připnout na profil</string>
@@ -849,6 +865,28 @@
<string name="quick_zap_amounts">Rychlé částky Zap</string>
<string name="quick_zap_amounts_explainer">Zobrazí se při stisknutí tlačítka zap. Klepnutím na částku ji odeberete. Pokud ponecháte prázdné, otevře se pokaždé dialog pro zadání částky.</string>
<string name="send_onchain_instead">Odeslat on-chain</string>
<string name="reload_mint_title">Dobít mint</string>
<string name="reload_mint_topup_label">Částka dobití</string>
<string name="reload_mint_section_to">Mint k dobití</string>
<string name="reload_mint_section_from">Prostředky z</string>
<string name="reload_mint_pay_lightning">Zaplatit pomocí Lightning</string>
<string name="reload_mint_lightning_desc">Vydejte nový ecash z vaší Lightning peněženky</string>
<string name="reload_mint_not_enough">Tady není dost</string>
<string name="reload_mint_available">%1$s sats k dispozici</string>
<string name="reload_mint_confirm">Dobít a poslat zap</string>
<string name="reload_mint_send_confirm">Poslat zap</string>
<string name="reload_mint_awaiting_payment">Čekám na platbu…</string>
<string name="reload_mint_retry">Zkusit znovu</string>
<string name="reload_mint_summary">Dobít %1$s sats do %2$s a poslat %3$s zap %4$s sats · poplatek ≈ %5$s sats</string>
<string name="reload_mint_summary_funded">Poslat zap %1$s %2$s sats z %3$s</string>
<string name="reload_mint_recipient_fallback">příjemce</string>
<string name="reload_mint_needs_more">chybí %1$s sat</string>
<string name="reload_mint_funded">financováno</string>
<string name="reload_mint_copy_invoice">Kopírovat fakturu</string>
<string name="topup_mint_title">Dobít mint</string>
<string name="topup_mint_action">Dobít tento mint</string>
<string name="topup_mint_amount_label">Částka k přidání</string>
<string name="topup_mint_confirm">Dobít</string>
<string name="zap_privacy_section">Soukromí Zap</string>
<string name="zap_type_section_explainer">Určuje, jak je zobrazena vaše identita při odesílání zapsu.</string>
<string name="wallet_connect_connect_app">Připojit peněženku</string>
@@ -1733,6 +1771,99 @@
<string name="wallet_no_wallets">Žádné peněženky nejsou připojeny</string>
<string name="wallet_no_wallets_description">Připojte jednu nebo více NWC peněženek pro odesílání a přijímání plateb.</string>
<string name="wallet_add_connection">Přidat NWC připojení</string>
<string name="wallet_add_choose_type">Zvolte typ peněženky</string>
<string name="wallet_add_nwc_title">Připojit Lightning peněženku (NWC)</string>
<string name="wallet_add_nwc_description">Plaťte přes externí Lightning peněženku pomocí Nostr Wallet Connect.</string>
<string name="wallet_add_cashu_title">Peněženka Cashu (NIP-60)</string>
<string name="wallet_add_cashu_description">Ecash peněženka v aplikaci založená na mintu Cashu. Tokeny jsou uloženy zašifrované na relayích.</string>
<string name="cashu_wallet_title">Peněženka Cashu</string>
<string name="cashu_no_wallet">Zatím žádná peněženka Cashu</string>
<string name="cashu_no_wallet_description">Vytvořte si peněženku Cashu pro uchovávání tokenů ecash a příjem nutzapů.</string>
<string name="cashu_discovering">Hledám vaši peněženku…</string>
<string name="cashu_discovering_description">Peněženky NIP-60 se synchronizují mezi klienty. Pokud jste si nějakou vytvořili v jiné aplikaci pod tímto klíčem Nostr, měla by se za pár sekund objevit.</string>
<string name="cashu_balance">Zůstatek</string>
<string name="cashu_mints">Minty</string>
<string name="cashu_mint_url">URL mintu</string>
<string name="cashu_remove_mint">Odebrat mint</string>
<string name="cashu_history">Historie</string>
<string name="cashu_edit_wallet">Upravit peněženku</string>
<string name="cashu_p2pk_section">Klíč pro nutzap (pokročilé)</string>
<string name="cashu_p2pk_explainer">Samostatný soukromý klíč používaný pouze k příjmu nutzapů NIP-61. Není to klíč vaší identity Nostr.</string>
<string name="cashu_p2pk_autogen">Vygenerovat nový klíč</string>
<string name="cashu_p2pk_autogen_warning_edit">Zneplatní všechny čekající nutzapy uzamčené ke stávajícímu klíči.</string>
<string name="cashu_p2pk_keep_current">Ponechat stávající klíč</string>
<string name="cashu_p2pk_manual_label">Soukromý klíč P2PK (hex)</string>
<string name="wallet_edit_cashu_title">Upravit peněženku Cashu</string>
<string name="wallet_save_changes">Uložit změny</string>
<string name="cashu_mint_no_recs">Zatím žádná doporučení</string>
<string name="cashu_mint_recommended_by">Doporučuje</string>
<plurals name="cashu_mint_plus_others">
<item quantity="one">+%1$d další</item>
<item quantity="few">+%1$d další</item>
<item quantity="many">+%1$d dalšího</item>
<item quantity="other">+%1$d dalších</item>
</plurals>
<plurals name="cashu_mint_recommended_by_others">
<item quantity="one">Doporučuje %1$d další</item>
<item quantity="few">Doporučují %1$d další</item>
<item quantity="many">Doporučuje %1$d dalšího</item>
<item quantity="other">Doporučuje %1$d dalších</item>
</plurals>
<string name="cashu_mint_popular_header">Oblíbené minty</string>
<string name="cashu_mint_matches_header">Odpovídající minty</string>
<string name="cashu_recommend_mint">Doporučit tento mint</string>
<string name="cashu_action_receive">Přijmout</string>
<string name="cashu_action_send_ln">Poslat LN</string>
<string name="cashu_action_send_token">Poslat token</string>
<string name="cashu_action_redeem">Uplatnit</string>
<string name="cashu_amount_sats">Částka (sats)</string>
<string name="cashu_pick_mint">Vyberte mint</string>
<string name="cashu_memo_optional">Poznámka (volitelné)</string>
<string name="cashu_invoice_bolt11">Lightning faktura (bolt11)</string>
<string name="cashu_token_label">Token Cashu</string>
<string name="cashu_copy_invoice">Kopírovat fakturu</string>
<string name="cashu_request_invoice">Vyžádat fakturu</string>
<string name="cashu_discard_invoice">Zahodit fakturu</string>
<string name="cashu_history_incoming">Přijato</string>
<string name="cashu_history_outgoing">Odesláno</string>
<string name="cashu_settings_title">Nastavení peněženky Cashu</string>
<string name="cashu_settings_edit_wallet">Upravit údaje peněženky</string>
<string name="cashu_settings_edit_wallet_subtitle">Minty, klíč pro nutzap</string>
<string name="cashu_settings_my_recommendations">Moje doporučení mintů</string>
<string name="cashu_settings_no_recommendations">Zatím jste nedoporučili žádný mint. Klepnutím na palec nahoru vedle mintu jej veřejně doporučíte.</string>
<string name="cashu_settings_delete_recommendation">Smazat doporučení</string>
<string name="cashu_settings_delete_confirm_title">Stáhnout doporučení?</string>
<string name="cashu_settings_delete_confirm_body">Publikovat žádost o smazání vašeho doporučení kind:38000 pro %1$s. Relaye, které dodržují NIP-09, jej odstraní.</string>
<string name="cashu_settings_add_recommendation">Doporučit mint</string>
<string name="cashu_settings_restore_title">Obnovit ze seedu</string>
<string name="cashu_settings_restore_subtitle">Znovu odvoďte každé minulé tajemství a požádejte každý mint o vrácení všech slepých podpisů, které stále eviduje. Užitečné po ztrátě zařízení nebo smazání vašich token událostí podvodným relayem.</string>
<string name="cashu_settings_restore_running">Prohledávám minty…</string>
<string name="cashu_settings_restore_nothing_found">Nenalezeny žádné obnovitelné proofy.</string>
<string name="cashu_settings_restore_result">Obnoveno %1$s sats z %2$s proofů.</string>
<string name="cashu_pay_invoice">Zaplatit fakturu</string>
<string name="cashu_get_quote">Získat nabídku</string>
<string name="cashu_getting_quote">Žádám mint o nabídku…</string>
<string name="cashu_quote_confirm">Zaplatit %1$s sat + až %2$s sat na poplatcích?</string>
<string name="cashu_verify">Ověřit</string>
<string name="cashu_mint_reachable">✓ Mint je dostupný</string>
<string name="cashu_mint_unreachable">Nelze se připojit k mintu: %1$s</string>
<string name="nutzap_failed_title">Nutzap selhal</string>
<string name="nutzap_failed_no_recipient">V poznámce chybí veřejný klíč příjemce</string>
<string name="nutzap_failed_no_event">Nelze sestavit odkaz na událost</string>
<string name="cashu_create_token">Vytvořit token</string>
<string name="cashu_redeem_button">Uplatnit</string>
<string name="cashu_done">Hotovo</string>
<string name="cashu_requesting_invoice">Žádám mint o fakturu…</string>
<string name="cashu_waiting_for_payment">Čekám na zaplacení faktury…</string>
<string name="cashu_completing_mint">Vydávám proofy…</string>
<string name="cashu_paying_invoice">Platím přes mint…</string>
<string name="cashu_building_token">Vyměňuji proofy…</string>
<string name="cashu_redeeming">Vyměňuji proofy do vaší peněženky…</string>
<string name="cashu_receive_invoice_explainer">Zaplaťte tuto fakturu pro vydání %1$s sats v ecash.</string>
<string name="cashu_received_amount">Přijato %1$s sats.</string>
<string name="cashu_paid_amount">Zaplaceno %1$s sats (poplatky: %2$s sats).</string>
<string name="cashu_token_ready">Token na %1$s sats je připraven. Sdílejte jej s příjemcem.</string>
<string name="cashu_redeemed_amount">Uplatněno %1$s sats do vaší peněženky.</string>
<string name="wallet_paste_uri">Vložit URI nostr+walletconnect://</string>
<string name="wallet_save">Uložit</string>
<string name="wallet_rename">Přejmenovat</string>
@@ -1741,7 +1872,6 @@
<string name="wallet_invalid_uri">Neplatné URI NWC připojení</string>
<string name="wallet_move_up">Posunout nahoru</string>
<string name="wallet_move_down">Posunout dolů</string>
<string name="wallet_reorder">Změnit pořadí peněženek</string>
<string name="wallet_onchain_transactions">On-chain transakce</string>
<string name="wallet_onchain_no_address">Pro tento účet není k dispozici žádná on-chain adresa.</string>
<string name="wallet_onchain_no_backend">Není nakonfigurován žádný on-chain backend.</string>
@@ -2342,6 +2472,10 @@
<string name="kind_audio_track">Audio stopa</string>
<string name="kind_music_track">Hudební stopa</string>
<string name="kind_music_playlist">Hudební playlist</string>
<string name="kind_podcast_episode">Epizoda podcastu</string>
<string name="kind_podcast_metadata">Podcast</string>
<string name="kind_authored_podcasts">Vytvořené podcasty</string>
<string name="kind_favorite_podcasts">Oblíbené podcasty</string>
<string name="kind_badge_awards">Ocenění odznaků</string>
<string name="kind_badge_definitions">Definice odznaků</string>
<string name="kind_accepted_badge_set">Přijatá sada odznaků</string>
@@ -2596,6 +2730,14 @@
<item quantity="many">%1$d let</item>
<item quantity="other">%1$d let</item>
</plurals>
<plurals name="cashu_pending_quotes_title">
<item quantity="one">%1$d čekající faktura</item>
<item quantity="few">%1$d čekající faktury</item>
<item quantity="many">%1$d čekající faktury</item>
<item quantity="other">%1$d čekajících faktur</item>
</plurals>
<string name="cashu_pending_quotes_subtitle">Klepnutím pokračujte a zkontrolujte stav.</string>
<string name="cashu_pending_quotes_resume">Pokračovat</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">Připojování</string>
<string name="event_sync_status_downloading">Stahování</string>
+138 -1
View File
@@ -737,6 +737,14 @@ anz der Bedingungen ist erforderlich</string>
<string name="articles">Artikel</string>
<string name="route_music_tracks">Musik</string>
<string name="route_music_playlists">Wiedergabelisten</string>
<string name="route_podcast_episodes">Folgen</string>
<string name="podcast_view_episodes">Folgen ansehen</string>
<string name="podcast_episodes_section">Folgen</string>
<string name="podcast_no_episodes">Noch keine Folgen gefunden</string>
<plurals name="podcast_episode_count">
<item quantity="one">%1$d Folge</item>
<item quantity="other">%1$d Folgen</item>
</plurals>
<string name="new_music_track">Neuer Musiktitel</string>
<string name="new_music_playlist">Neue Playlist</string>
<string name="music_track_title_label">Titel</string>
@@ -769,6 +777,11 @@ anz der Bedingungen ist erforderlich</string>
<string name="add_to_public_bookmarks">Zu den öffentlichen Lesezeichen hinzufügen</string>
<string name="remove_from_private_bookmarks">Aus den privaten Lesezeichen entfernen</string>
<string name="remove_from_public_bookmarks">Aus den öffentlichen Lesezeichen entfernen</string>
<string name="add_hashtag_label">Hashtag hinzufügen</string>
<string name="add_hashtag_label_title">Hashtag hinzufügen</string>
<string name="add_hashtag_label_explainer">Markiere diesen Beitrag öffentlich mit einem Hashtag (NIP-32-Label). Wer dir folgt, sieht ihn im Feed dieses Hashtags.</string>
<string name="add_hashtag_label_confirm">Hinzufügen</string>
<string name="hashtag_label_added_by">hinzugefügt von</string>
<string name="pinned_notes">Angeheftete Notizen</string>
<string name="pinned_notes_explainer">Deine angepinnten Notizen</string>
<string name="pin_to_profile">An Profil anheften</string>
@@ -837,6 +850,29 @@ anz der Bedingungen ist erforderlich</string>
<string name="quick_zap_amounts">Schnelle Zap-Beträge</string>
<string name="quick_zap_amounts_explainer">Wird beim Drücken der Zap-Schaltfläche angezeigt. Tippe auf einen Betrag, um ihn zu entfernen. Wenn du es leer lässt, wird jedes Mal ein Dialog zur Eingabe eines Betrags geöffnet.</string>
<string name="send_onchain_instead">Stattdessen On-Chain senden</string>
<string name="reload_mint_title">Mint aufladen</string>
<string name="reload_mint_sats_amount">%1$s Sats</string>
<string name="reload_mint_topup_label">Aufladebetrag</string>
<string name="reload_mint_section_to">Aufzuladender Mint</string>
<string name="reload_mint_section_from">Mittel von</string>
<string name="reload_mint_pay_lightning">Mit Lightning bezahlen</string>
<string name="reload_mint_lightning_desc">Präge neues Ecash aus deiner Lightning-Wallet</string>
<string name="reload_mint_not_enough">Hier nicht genug</string>
<string name="reload_mint_available">%1$s Sats verfügbar</string>
<string name="reload_mint_confirm">Aufladen &amp; Zap senden</string>
<string name="reload_mint_send_confirm">Zap senden</string>
<string name="reload_mint_awaiting_payment">Warte auf Zahlung…</string>
<string name="reload_mint_retry">Erneut versuchen</string>
<string name="reload_mint_summary">Lade %1$s Sats zu %2$s auf und zappe %3$s %4$s Sats · Gebühr ≈ %5$s Sats</string>
<string name="reload_mint_summary_funded">Zappe %1$s %2$s Sats von %3$s</string>
<string name="reload_mint_recipient_fallback">der Empfänger</string>
<string name="reload_mint_needs_more">braucht %1$s Sat mehr</string>
<string name="reload_mint_funded">finanziert</string>
<string name="reload_mint_copy_invoice">Rechnung kopieren</string>
<string name="topup_mint_title">Mint aufladen</string>
<string name="topup_mint_action">Diesen Mint aufladen</string>
<string name="topup_mint_amount_label">Hinzuzufügender Betrag</string>
<string name="topup_mint_confirm">Aufladen</string>
<string name="zap_privacy_section">Zap-Datenschutz</string>
<string name="zap_type_section_explainer">Legt fest, wie deine Identität beim Senden eines Zaps angezeigt wird.</string>
<string name="wallet_connect_connect_app">Wallet verbinden</string>
@@ -1716,6 +1752,94 @@ anz der Bedingungen ist erforderlich</string>
<string name="wallet_no_wallets">Keine Wallets verbunden</string>
<string name="wallet_no_wallets_description">Verbinde eine oder mehrere NWC-Wallets, um Zahlungen zu senden und zu empfangen.</string>
<string name="wallet_add_connection">NWC-Verbindung hinzufügen</string>
<string name="wallet_add_choose_type">Wallet-Typ wählen</string>
<string name="wallet_add_nwc_title">Lightning-Wallet verbinden (NWC)</string>
<string name="wallet_add_nwc_description">Bezahle über eine externe Lightning-Wallet mit Nostr Wallet Connect.</string>
<string name="wallet_add_cashu_title">Cashu-Wallet (NIP-60)</string>
<string name="wallet_add_cashu_description">Eine In-App-Ecash-Wallet, die von einem Cashu-Mint gestützt wird. Tokens werden verschlüsselt auf Relays gespeichert.</string>
<string name="cashu_wallet_title">Cashu-Wallet</string>
<string name="cashu_no_wallet">Noch keine Cashu-Wallet</string>
<string name="cashu_no_wallet_description">Erstelle eine Cashu-Wallet, um Ecash-Tokens zu halten und Nutzaps zu empfangen.</string>
<string name="cashu_discovering">Suche nach deiner Wallet…</string>
<string name="cashu_discovering_description">NIP-60-Wallets werden über Clients hinweg synchronisiert. Wenn du in einer anderen App eine mit diesem Nostr-Schlüssel erstellt hast, sollte sie in wenigen Sekunden erscheinen.</string>
<string name="cashu_balance">Guthaben</string>
<string name="cashu_mint_url">Mint-URL</string>
<string name="cashu_remove_mint">Mint entfernen</string>
<string name="cashu_history">Verlauf</string>
<string name="cashu_edit_wallet">Wallet bearbeiten</string>
<string name="cashu_p2pk_section">Nutzap-Schlüssel (erweitert)</string>
<string name="cashu_p2pk_explainer">Ein separater privater Schlüssel, der nur zum Empfangen von NIP-61-Nutzaps dient. Nicht dein Nostr-Identitätsschlüssel.</string>
<string name="cashu_p2pk_autogen">Neuen Schlüssel erzeugen</string>
<string name="cashu_p2pk_autogen_warning_edit">Macht alle ausstehenden Nutzaps ungültig, die an den aktuellen Schlüssel gebunden sind.</string>
<string name="cashu_p2pk_keep_current">Aktuellen Schlüssel behalten</string>
<string name="cashu_p2pk_manual_label">Privater P2PK-Schlüssel (hex)</string>
<string name="wallet_edit_cashu_title">Cashu-Wallet bearbeiten</string>
<string name="wallet_save_changes">Änderungen speichern</string>
<string name="cashu_mint_no_recs">Noch keine Empfehlungen</string>
<string name="cashu_mint_recommended_by">Empfohlen von</string>
<plurals name="cashu_mint_plus_others">
<item quantity="one">+%1$d weiterer</item>
<item quantity="other">+%1$d weitere</item>
</plurals>
<plurals name="cashu_mint_recommended_by_others">
<item quantity="one">Von %1$d weiterem empfohlen</item>
<item quantity="other">Von %1$d weiteren empfohlen</item>
</plurals>
<string name="cashu_mint_popular_header">Beliebte Mints</string>
<string name="cashu_mint_matches_header">Passende Mints</string>
<string name="cashu_recommend_mint">Diesen Mint empfehlen</string>
<string name="cashu_action_receive">Empfangen</string>
<string name="cashu_action_send_ln">LN senden</string>
<string name="cashu_action_send_token">Token senden</string>
<string name="cashu_action_redeem">Einlösen</string>
<string name="cashu_amount_sats">Betrag (Sats)</string>
<string name="cashu_pick_mint">Mint auswählen</string>
<string name="cashu_memo_optional">Notiz (optional)</string>
<string name="cashu_invoice_bolt11">Lightning-Rechnung (bolt11)</string>
<string name="cashu_token_label">Cashu-Token</string>
<string name="cashu_copy_invoice">Rechnung kopieren</string>
<string name="cashu_request_invoice">Rechnung anfordern</string>
<string name="cashu_discard_invoice">Rechnung verwerfen</string>
<string name="cashu_history_incoming">Empfangen</string>
<string name="cashu_history_outgoing">Gesendet</string>
<string name="cashu_settings_title">Cashu-Wallet-Einstellungen</string>
<string name="cashu_settings_edit_wallet">Wallet-Details bearbeiten</string>
<string name="cashu_settings_edit_wallet_subtitle">Mints, Nutzap-Schlüssel</string>
<string name="cashu_settings_my_recommendations">Meine Mint-Empfehlungen</string>
<string name="cashu_settings_no_recommendations">Du hast noch keine Mints empfohlen. Tippe auf das Daumen-hoch-Symbol neben einem Mint, um ihn öffentlich zu empfehlen.</string>
<string name="cashu_settings_delete_recommendation">Empfehlung löschen</string>
<string name="cashu_settings_delete_confirm_title">Empfehlung zurückziehen?</string>
<string name="cashu_settings_delete_confirm_body">Eine Löschanfrage für deine kind:38000-Empfehlung von %1$s veröffentlichen. Relays, die NIP-09 beachten, entfernen sie.</string>
<string name="cashu_settings_add_recommendation">Einen Mint empfehlen</string>
<string name="cashu_settings_restore_title">Aus Seed wiederherstellen</string>
<string name="cashu_settings_restore_subtitle">Leite jedes frühere Geheimnis neu ab und bitte jeden Mint, alle noch gespeicherten blinden Signaturen zurückzugeben. Nützlich nach Geräteverlust oder dem Löschen deiner Token-Events durch ein bösartiges Relay.</string>
<string name="cashu_settings_restore_running">Durchsuche Mints…</string>
<string name="cashu_settings_restore_nothing_found">Keine wiederherstellbaren Proofs gefunden.</string>
<string name="cashu_settings_restore_result">%1$s Sats über %2$s Proofs wiederhergestellt.</string>
<string name="cashu_pay_invoice">Rechnung bezahlen</string>
<string name="cashu_get_quote">Angebot einholen</string>
<string name="cashu_getting_quote">Frage Mint nach einem Angebot…</string>
<string name="cashu_quote_confirm">%1$s Sat + bis zu %2$s Sat Gebühren bezahlen?</string>
<string name="cashu_verify">Prüfen</string>
<string name="cashu_mint_reachable">✓ Mint ist erreichbar</string>
<string name="cashu_mint_unreachable">Mint nicht erreichbar: %1$s</string>
<string name="nutzap_failed_title">Nutzap fehlgeschlagen</string>
<string name="nutzap_failed_no_recipient">Kein Empfänger-Pubkey in der Notiz</string>
<string name="nutzap_failed_no_event">Ereignisreferenz kann nicht erstellt werden</string>
<string name="cashu_create_token">Token erstellen</string>
<string name="cashu_redeem_button">Einlösen</string>
<string name="cashu_done">Fertig</string>
<string name="cashu_requesting_invoice">Frage Mint nach einer Rechnung…</string>
<string name="cashu_waiting_for_payment">Warte auf die Bezahlung der Rechnung…</string>
<string name="cashu_completing_mint">Stelle Proofs aus…</string>
<string name="cashu_paying_invoice">Zahle über Mint…</string>
<string name="cashu_building_token">Tausche Proofs…</string>
<string name="cashu_redeeming">Tausche Proofs in deine Wallet…</string>
<string name="cashu_receive_invoice_explainer">Bezahle diese Rechnung, um %1$s Sats Ecash zu prägen.</string>
<string name="cashu_received_amount">%1$s Sats empfangen.</string>
<string name="cashu_paid_amount">%1$s Sats bezahlt (Gebühren: %2$s Sats).</string>
<string name="cashu_token_ready">Token über %1$s Sats bereit. Teile ihn mit dem Empfänger.</string>
<string name="cashu_redeemed_amount">%1$s Sats in deine Wallet eingelöst.</string>
<string name="wallet_paste_uri">nostr+walletconnect://-URI einfügen</string>
<string name="wallet_save">Speichern</string>
<string name="wallet_rename">Umbenennen</string>
@@ -1724,7 +1848,6 @@ anz der Bedingungen ist erforderlich</string>
<string name="wallet_invalid_uri">Ungültige NWC-Verbindungs-URI</string>
<string name="wallet_move_up">Nach oben</string>
<string name="wallet_move_down">Nach unten</string>
<string name="wallet_reorder">Wallets neu anordnen</string>
<string name="wallet_onchain_transactions">On-Chain-Transaktionen</string>
<string name="wallet_onchain_no_address">Keine On-Chain-Adresse für dieses Konto verfügbar.</string>
<string name="wallet_onchain_no_backend">Kein On-Chain-Backend konfiguriert.</string>
@@ -2317,6 +2440,10 @@ anz der Bedingungen ist erforderlich</string>
<string name="kind_audio_track">Audiospur</string>
<string name="kind_music_track">Musiktitel</string>
<string name="kind_music_playlist">Musik-Playlist</string>
<string name="kind_podcast_episode">Podcast-Folge</string>
<string name="kind_podcast_metadata">Podcast-Sendung</string>
<string name="kind_authored_podcasts">Erstellte Podcasts</string>
<string name="kind_favorite_podcasts">Lieblings-Podcasts</string>
<string name="kind_badge_awards">Abzeichen-Auszeichnungen</string>
<string name="kind_badge_definitions">Abzeichen-Definitionen</string>
<string name="kind_accepted_badge_set">Akzeptiertes Abzeichen-Set</string>
@@ -2559,6 +2686,16 @@ anz der Bedingungen ist erforderlich</string>
<item quantity="one">%1$d Jahr</item>
<item quantity="other">%1$d Jahre</item>
</plurals>
<plurals name="cashu_wallet_subtitle_mints">
<item quantity="one">%1$d Mint</item>
<item quantity="other">%1$d Mints</item>
</plurals>
<plurals name="cashu_pending_quotes_title">
<item quantity="one">%1$d ausstehende Rechnung</item>
<item quantity="other">%1$d ausstehende Rechnungen</item>
</plurals>
<string name="cashu_pending_quotes_subtitle">Tippe, um fortzusetzen und den Status zu prüfen.</string>
<string name="cashu_pending_quotes_resume">Fortsetzen</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">Verbinden</string>
<string name="event_sync_status_downloading">Herunterladen</string>
@@ -1824,7 +1824,6 @@
<string name="wallet_invalid_uri">अमान्य नोस्टर धनकोष संयोजन पता</string>
<string name="wallet_move_up">ऊर्ध्वगमन</string>
<string name="wallet_move_down">अधोगमन</string>
<string name="wallet_reorder">धनकोष पुनःव्यवस्थित करें</string>
<string name="wallet_onchain_transactions">खण्डश्रृंखला सत्यापित लेनदेन</string>
<string name="wallet_onchain_no_address">इस लेखा के लिए कोई खण्डश्रृंखला पता उपलब्ध नहीं।</string>
<string name="wallet_onchain_no_backend">कोई खणडश्रृंखला पृष्ठभाग समाकृत नहीं।</string>
@@ -1824,7 +1824,6 @@
<string name="wallet_invalid_uri">Érvénytelen NWC-kapcsolati cím</string>
<string name="wallet_move_up">Mozgatás felfelé</string>
<string name="wallet_move_down">Mozgatás lefelé</string>
<string name="wallet_reorder">Pénztárca újrarendezése</string>
<string name="wallet_onchain_transactions">Láncon belüli tranzakciók</string>
<string name="wallet_onchain_no_address">Ehhez a fiókhoz nincs elérhető láncon belüli cím.</string>
<string name="wallet_onchain_no_backend">Nincs beállítva lánc-háttérprogram.</string>
@@ -792,6 +792,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="add_to_public_bookmarks">Dodaj do publicznych zakładek</string>
<string name="remove_from_private_bookmarks">Usuń z prywatnych zakładek</string>
<string name="remove_from_public_bookmarks">Usuń z publicznych zakładek</string>
<string name="add_hashtag_label">Dodaj hasztag</string>
<string name="add_hashtag_label_title">Dodaj hasztag</string>
<string name="add_hashtag_label_explainer">Publicznie oznacz ten post hasztagiem (etykietą NIP-32). Osoby, które Cię obserwują, zobaczą go w kanale tego hasztagu.</string>
<string name="add_hashtag_label_field">Hasztag</string>
<string name="add_hashtag_label_confirm">Dodaj</string>
<string name="hashtag_label_added_by">dodany przez</string>
<string name="pinned_notes">Przypięte notatki</string>
<string name="pinned_notes_explainer">Twoje przypięte notatki</string>
<string name="pin_to_profile">Przypnij do profilu</string>
@@ -1436,7 +1442,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="geohash_explainer">Dodaje Geohash twojej lokalizacji do wpisu. Użytkownicy będą wiedzieli, że jesteś mniej niż 5 km od bieżącej lokalizacji</string>
<string name="geohash_exclusive">Post ekskluzywny dla lokalizacji</string>
<string name="geohash_exclusive_explainer">Tylko obserwatorzy z twojej lokalizacji to zobaczą. Twoi ogólni obserwatorzy nie zobaczą tego.</string>
<string name="hashtag_exclusive">Wpis hashtagowy</string>
<string name="hashtag_exclusive">Wpis hasztagowy</string>
<string name="hashtag_exclusive_explainer">Tylko obserwujący ten hashtag to zobaczą. Twoi ogólni obserwujący nie zobaczą tego.</string>
<string name="external_url_scope">Skomentuj na stronie internetowej</string>
<string name="external_id_scope">Skomentuj na zewnętrznym serwisie</string>
@@ -1869,7 +1875,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="wallet_invalid_uri">Nieprawidłowy URI połączenia NWC</string>
<string name="wallet_move_up">W górę</string>
<string name="wallet_move_down">W dół</string>
<string name="wallet_reorder">Powtórne zamów portfel</string>
<string name="wallet_onchain_transactions">Transakcje w łańcuchu</string>
<string name="wallet_onchain_no_address">Brak dostępnego adresu w łańcuchu dla tego konta.</string>
<string name="wallet_onchain_no_backend">Nie skonfigurowano backendu łańcucha.</string>
@@ -2332,7 +2337,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="share_video">Udostępnij film…</string>
<string name="unable_to_share_video">Nie można udostępnić filmu, spróbuj ponownie później…</string>
<string name="downloading_video_for_sharing">Pobieranie filmu…</string>
<string name="search_by_hashtag">Szukaj tagu: #%1$s</string>
<string name="search_by_hashtag">Szukaj hasztagu: #%1$s</string>
<string name="search_scope_all">Wszytko</string>
<string name="search_scope_people">Osoby</string>
<string name="search_scope_notes">Wpisy</string>
@@ -12,6 +12,10 @@
<item quantity="one">Esta publicação tem mais de %1$d hashtag</item>
<item quantity="other">Esta publicação tem mais de %1$d hashtags</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d recurso incluído</item>
<item quantity="other">%1$d recursos incluídos</item>
</plurals>
<string name="post_not_found">postagem não encontrada</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Imagem do Canal</string>
@@ -127,6 +131,7 @@
<string name="write_a_message">Escreva uma mensagem…</string>
<string name="post">Salvar</string>
<string name="save">Salvar</string>
<string name="send">Enviar</string>
<string name="create">Criar</string>
<string name="rename">Renomear</string>
<string name="cancel">Cancelar</string>
@@ -550,6 +555,16 @@
<string name="profile_badges_description">Escolha quais dos selos que você recebeu aparecerão no seu perfil.</string>
<string name="profile_badges_empty">Você ainda não recebeu nenhum selo.</string>
<string name="pictures">Imagens</string>
<string name="nip82_repository_label">Fonte: %1$s</string>
<string name="nip82_download">Baixar</string>
<string name="nip82_section_about">Sobre</string>
<string name="nip82_section_platforms">Plataformas</string>
<string name="nip82_section_topics">Tópicos</string>
<string name="nip82_section_latest_release">Versão mais recente</string>
<string name="nip82_section_comments">Comentários</string>
<string name="nip82_no_comments">Seja o primeiro a comentar.</string>
<string name="nip82_older_releases_show">Mostrar versões antigas</string>
<string name="nip82_older_releases_hide">Ocultar versões antigas</string>
<string name="calendars">Calendários</string>
<string name="shorts">Curtas</string>
<string name="public_chats">Chats públicos</string>
@@ -710,12 +725,51 @@
<string name="nests_servers_empty">Ainda não há servidores. Adicione um abaixo ou escolha um recomendado.</string>
<string name="longs">Vídeos</string>
<string name="articles">Artigos</string>
<string name="route_music_tracks">Música</string>
<string name="route_podcast_episodes">Episódios</string>
<string name="podcast_view_episodes">Ver episódios</string>
<string name="podcast_episodes_section">Episódios</string>
<string name="podcast_no_episodes">Nenhum episódio encontrado ainda</string>
<plurals name="podcast_episode_count">
<item quantity="one">%1$d episódio</item>
<item quantity="other">%1$d episódios</item>
</plurals>
<string name="new_music_track">Nova faixa de música</string>
<string name="new_music_playlist">Nova playlist</string>
<string name="music_track_title_label">Título</string>
<string name="music_track_title_placeholder">Título da faixa</string>
<string name="music_track_artist_label">Artista</string>
<string name="music_track_artist_placeholder">Nome do artista</string>
<string name="music_track_audio_url_label">URL do áudio</string>
<string name="music_track_image_url_label">URL da imagem de capa (opcional)</string>
<string name="music_track_album_label">Álbum (opcional)</string>
<string name="music_track_duration_label">Duração em segundos (opcional)</string>
<string name="music_track_description_label">Letra ou créditos (opcional)</string>
<string name="music_track_delete">Excluir esta faixa</string>
<string name="music_track_delete_confirm">Publicar uma exclusão NIP-09 para esta faixa de música?</string>
<string name="add_to_music_playlist">Adicionar à playlist</string>
<string name="add_to_music_playlist_title">Adicionar à playlist</string>
<string name="add_to_music_playlist_empty">Você ainda não tem playlists. Use o campo acima para criar uma.</string>
<string name="add_to_music_playlist_manage_all">Gerenciar todas as playlists</string>
<string name="music_playlist_new_dialog_title">Nova playlist</string>
<string name="music_playlist_new_title_placeholder">Nome da playlist</string>
<string name="music_playlist_create_action">Criar</string>
<string name="music_playlist_untitled">(playlist sem título)</string>
<plurals name="music_playlist_track_count_short">
<item quantity="one">%1$d faixa</item>
<item quantity="other">%1$d faixas</item>
</plurals>
<string name="private_bookmarks">Itens Salvos Privados</string>
<string name="public_bookmarks">Itens Salvos Públicos</string>
<string name="add_to_private_bookmarks">Adicionar aos Itens Salvos Privados</string>
<string name="add_to_public_bookmarks">Adicionar aos Itens Salvos Públicos</string>
<string name="remove_from_private_bookmarks">Remover dos Itens Salvos Privados</string>
<string name="remove_from_public_bookmarks">Remover dos Itens Salvos Públicos</string>
<string name="add_hashtag_label">Adicionar hashtag</string>
<string name="add_hashtag_label_title">Adicionar uma hashtag</string>
<string name="add_hashtag_label_explainer">Marque publicamente este post com uma hashtag (etiqueta NIP-32). Quem segue você o verá no feed dessa hashtag.</string>
<string name="add_hashtag_label_confirm">Adicionar</string>
<string name="hashtag_label_added_by">adicionado por</string>
<string name="pinned_notes">Notas Fixadas</string>
<string name="pinned_notes_explainer">Suas notas fixadas</string>
<string name="pin_to_profile">Fixar no Perfil</string>
@@ -784,6 +838,28 @@
<string name="quick_zap_amounts">Valores Rápidos de Zap</string>
<string name="quick_zap_amounts_explainer">Exibido ao pressionar o botão de zap. Toque em um valor para removê-lo. Se deixar vazio, abrirá o diálogo para inserir um valor todas as vezes.</string>
<string name="send_onchain_instead">Enviar on-chain em vez disso</string>
<string name="reload_mint_title">Recarregar mint</string>
<string name="reload_mint_topup_label">Quantia da recarga</string>
<string name="reload_mint_section_to">Mint a recarregar</string>
<string name="reload_mint_section_from">Fundos de</string>
<string name="reload_mint_pay_lightning">Pagar com Lightning</string>
<string name="reload_mint_lightning_desc">Emita novo ecash a partir da sua carteira Lightning</string>
<string name="reload_mint_not_enough">Não há o suficiente aqui</string>
<string name="reload_mint_available">%1$s sats disponíveis</string>
<string name="reload_mint_confirm">Recarregar e enviar zap</string>
<string name="reload_mint_send_confirm">Enviar zap</string>
<string name="reload_mint_awaiting_payment">Aguardando pagamento…</string>
<string name="reload_mint_retry">Tentar de novo</string>
<string name="reload_mint_summary">Recarregue %1$s sats para %2$s e envie zap de %3$s %4$s sats · taxa ≈ %5$s sats</string>
<string name="reload_mint_summary_funded">Zap de %1$s %2$s sats de %3$s</string>
<string name="reload_mint_recipient_fallback">o destinatário</string>
<string name="reload_mint_needs_more">faltam %1$s sat</string>
<string name="reload_mint_funded">financiado</string>
<string name="reload_mint_copy_invoice">Copiar fatura</string>
<string name="topup_mint_title">Recarregar mint</string>
<string name="topup_mint_action">Recarregar este mint</string>
<string name="topup_mint_amount_label">Quantia a adicionar</string>
<string name="topup_mint_confirm">Recarregar</string>
<string name="zap_privacy_section">Privacidade do Zap</string>
<string name="zap_type_section_explainer">Controla como sua identidade é exibida ao enviar um Zap.</string>
<string name="wallet_connect_connect_app">Conectar Carteira</string>
@@ -874,6 +950,7 @@
<string name="payment_target_authority">Endereço</string>
<string name="delete_payment_target">Excluir destino de pagamento</string>
<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="uploading_state_ready">Não iniciado</string>
<string name="uploading_state_compressing">Compactando</string>
@@ -1182,6 +1259,8 @@
<string name="posting_policy">Política de postagem</string>
<string name="privacy_policy">Política de privacidade</string>
<string name="terms_and_conditions">Termos &amp; condições</string>
<string name="child_safety_standards">Padrões de segurança infantil</string>
<string name="about_legal">Sobre e Jurídico</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Erros e Avisos deste Relé</string>
<string name="relay_monitor_reports">Relatórios do Monitor de Relay</string>
@@ -1341,6 +1420,26 @@
<string name="external_url_scope">Comentar em um site</string>
<string name="external_id_scope">Comentar em um recurso externo</string>
<string name="long_form_reading_minutes">%1$d min de leitura</string>
<plurals name="music_playlist_track_count">
<item quantity="one">%1$d faixa</item>
<item quantity="other">%1$d faixas</item>
</plurals>
<plurals name="music_playlist_more_tracks">
<item quantity="one">+%1$d faixa a mais</item>
<item quantity="other">+%1$d faixas a mais</item>
</plurals>
<string name="music_playlist_collaborative">Colaborativa</string>
<string name="music_playlist_private">Privada</string>
<string name="music_playlist_unknown_track">Faixa desconhecida</string>
<string name="music_playlist_loading_track">Carregando faixa…</string>
<string name="music_track_cover_upload_cta">Enviar uma imagem de capa</string>
<string name="music_track_cover_upload_hint">Escolha uma imagem quadrada para ser a arte desta faixa.</string>
<string name="music_track_audio_upload_cta">Enviar um arquivo de áudio</string>
<string name="music_track_audio_upload_hint">Escolha um MP3, WAV ou FLAC. Ele será enviado ao seu servidor de mídia ao salvar.</string>
<string name="music_track_audio_picked">Arquivo de áudio pronto para envio</string>
<string name="music_track_uploading">Enviando…</string>
<string name="music_track_uploading_banner">Enviando capa + áudio e publicando. Isso continuará mesmo se você sair da tela.</string>
<string name="music_track_upload_failed">Falha no envio: %1$s</string>
<string name="loading_location">Carregando localização</string>
<string name="lack_location_permissions">Sem permissões para localização</string>
<string name="add_sensitive_content_explainer">Adiciona aviso de conteúdo sensível antes de mostrar seu conteúdo. Isso é ideal para qualquer conteúdo NSFW ou conteúdo que algumas pessoas possam considerar ofensivo ou perturbador</string>
@@ -1435,6 +1534,9 @@
<string name="wallet_number">Carteira %1$s</string>
<string name="error_opening_external_signer">Erro ao abrir o aplicativo para assinar evento</string>
<string name="error_opening_external_signer_description">O aplicativo de assinatura não pôde ser encontrado. Verifique se o aplicativo não foi desinstalado</string>
<string name="error_video_playback_failed">Não é possível reproduzir este vídeo</string>
<string name="error_video_playback_failed_description">O codec ou formato não é compatível com este dispositivo (%1$s). Tente abri-lo no seu navegador.</string>
<string name="error_video_open_in_browser">Abrir no navegador</string>
<string name="sign_request_rejected2">Solicitação de assinatura rejeitada</string>
<string name="sign_request_rejected">Solicitação de assinatura rejeitada</string>
<string name="sign_request_rejected_description">Certifique-se de que a aplicação assinante autorizou esta transação</string>
@@ -1583,6 +1685,7 @@
<string name="metadata_strip_failed_upload">Enviar mesmo assim</string>
<string name="metadata_strip_failed_upload_cancelled">Não foi possível remover metadados privados da mídia. Envio cancelado.</string>
<string name="upload_cancelled">Envio cancelado</string>
<string name="avif_metadata_strip_failed">Não foi possível remover os metadados do AVIF: %1$s</string>
<string name="edit_draft">Editar rascunho</string>
<string name="login_with_qr_code">Entrar com Código QR</string>
<string name="route">Rota</string>
@@ -1637,6 +1740,94 @@
<string name="wallet_no_wallets">Nenhuma carteira conectada</string>
<string name="wallet_no_wallets_description">Conecte uma ou mais carteiras NWC para enviar e receber pagamentos.</string>
<string name="wallet_add_connection">Adicionar conexão NWC</string>
<string name="wallet_add_choose_type">Escolha o tipo de carteira</string>
<string name="wallet_add_nwc_title">Conectar uma carteira Lightning (NWC)</string>
<string name="wallet_add_nwc_description">Pague por uma carteira Lightning externa usando o Nostr Wallet Connect.</string>
<string name="wallet_add_cashu_title">Carteira Cashu (NIP-60)</string>
<string name="wallet_add_cashu_description">Uma carteira ecash dentro do app, baseada em um mint Cashu. Os tokens são armazenados criptografados em relays.</string>
<string name="cashu_wallet_title">Carteira Cashu</string>
<string name="cashu_no_wallet">Ainda sem carteira Cashu</string>
<string name="cashu_no_wallet_description">Crie uma carteira Cashu para guardar tokens ecash e receber nutzaps.</string>
<string name="cashu_discovering">Procurando sua carteira…</string>
<string name="cashu_discovering_description">Carteiras NIP-60 sincronizam entre clientes. Se você criou uma em outro app com esta chave Nostr, ela deve aparecer em alguns segundos.</string>
<string name="cashu_balance">Saldo</string>
<string name="cashu_mint_url">URL do mint</string>
<string name="cashu_remove_mint">Remover mint</string>
<string name="cashu_history">Histórico</string>
<string name="cashu_edit_wallet">Editar carteira</string>
<string name="cashu_p2pk_section">Chave de nutzap (avançado)</string>
<string name="cashu_p2pk_explainer">Uma chave privada separada, usada apenas para receber nutzaps NIP-61. Não é a chave da sua identidade Nostr.</string>
<string name="cashu_p2pk_autogen">Gerar uma nova chave</string>
<string name="cashu_p2pk_autogen_warning_edit">Invalidará quaisquer nutzaps pendentes vinculados à chave atual.</string>
<string name="cashu_p2pk_keep_current">Manter chave atual</string>
<string name="cashu_p2pk_manual_label">Chave privada P2PK (hex)</string>
<string name="wallet_edit_cashu_title">Editar carteira Cashu</string>
<string name="wallet_save_changes">Salvar alterações</string>
<string name="cashu_mint_no_recs">Ainda sem recomendações</string>
<string name="cashu_mint_recommended_by">Recomendado por</string>
<plurals name="cashu_mint_plus_others">
<item quantity="one">+%1$d mais</item>
<item quantity="other">+%1$d mais</item>
</plurals>
<plurals name="cashu_mint_recommended_by_others">
<item quantity="one">Recomendado por %1$d outro</item>
<item quantity="other">Recomendado por %1$d outros</item>
</plurals>
<string name="cashu_mint_popular_header">Mints populares</string>
<string name="cashu_mint_matches_header">Mints correspondentes</string>
<string name="cashu_recommend_mint">Recomendar este mint</string>
<string name="cashu_action_receive">Receber</string>
<string name="cashu_action_send_ln">Enviar LN</string>
<string name="cashu_action_send_token">Enviar token</string>
<string name="cashu_action_redeem">Resgatar</string>
<string name="cashu_amount_sats">Quantia (sats)</string>
<string name="cashu_pick_mint">Escolha um mint</string>
<string name="cashu_memo_optional">Memo (opcional)</string>
<string name="cashu_invoice_bolt11">Fatura Lightning (bolt11)</string>
<string name="cashu_token_label">Token Cashu</string>
<string name="cashu_copy_invoice">Copiar fatura</string>
<string name="cashu_request_invoice">Solicitar fatura</string>
<string name="cashu_discard_invoice">Descartar fatura</string>
<string name="cashu_history_incoming">Recebido</string>
<string name="cashu_history_outgoing">Enviado</string>
<string name="cashu_settings_title">Configurações da carteira Cashu</string>
<string name="cashu_settings_edit_wallet">Editar detalhes da carteira</string>
<string name="cashu_settings_edit_wallet_subtitle">Mints, chave de nutzap</string>
<string name="cashu_settings_my_recommendations">Minhas recomendações de mint</string>
<string name="cashu_settings_no_recommendations">Você ainda não recomendou nenhum mint. Toque no joinha ao lado de um mint para recomendá-lo publicamente.</string>
<string name="cashu_settings_delete_recommendation">Excluir recomendação</string>
<string name="cashu_settings_delete_confirm_title">Retirar recomendação?</string>
<string name="cashu_settings_delete_confirm_body">Publicar um pedido de exclusão para sua recomendação kind:38000 de %1$s. Relays que respeitam o NIP-09 irão removê-la.</string>
<string name="cashu_settings_add_recommendation">Recomendar um mint</string>
<string name="cashu_settings_restore_title">Recuperar a partir da seed</string>
<string name="cashu_settings_restore_subtitle">Rederive cada segredo antigo e peça a cada mint que retorne quaisquer assinaturas cegas ainda registradas. Útil após a perda de um dispositivo ou a exclusão dos seus eventos de token por um relay malicioso.</string>
<string name="cashu_settings_restore_running">Escaneando mints…</string>
<string name="cashu_settings_restore_nothing_found">Nenhuma prova recuperável encontrada.</string>
<string name="cashu_settings_restore_result">Recuperados %1$s sats em %2$s provas.</string>
<string name="cashu_pay_invoice">Pagar fatura</string>
<string name="cashu_get_quote">Obter cotação</string>
<string name="cashu_getting_quote">Pedindo cotação ao mint…</string>
<string name="cashu_quote_confirm">Pagar %1$s sat + até %2$s sat em taxas?</string>
<string name="cashu_verify">Verificar</string>
<string name="cashu_mint_reachable">✓ O mint está acessível</string>
<string name="cashu_mint_unreachable">Não foi possível acessar o mint: %1$s</string>
<string name="nutzap_failed_title">Falha no nutzap</string>
<string name="nutzap_failed_no_recipient">Sem chave pública do destinatário na nota</string>
<string name="nutzap_failed_no_event">Não foi possível construir a referência do evento</string>
<string name="cashu_create_token">Criar token</string>
<string name="cashu_redeem_button">Resgatar</string>
<string name="cashu_done">Concluído</string>
<string name="cashu_requesting_invoice">Pedindo uma fatura ao mint…</string>
<string name="cashu_waiting_for_payment">Aguardando o pagamento da fatura…</string>
<string name="cashu_completing_mint">Emitindo provas…</string>
<string name="cashu_paying_invoice">Pagando via mint…</string>
<string name="cashu_building_token">Trocando provas…</string>
<string name="cashu_redeeming">Trocando provas para sua carteira…</string>
<string name="cashu_receive_invoice_explainer">Pague esta fatura para emitir %1$s sats de ecash.</string>
<string name="cashu_received_amount">Recebido %1$s sats.</string>
<string name="cashu_paid_amount">Pago %1$s sats (taxas: %2$s sats).</string>
<string name="cashu_token_ready">Token de %1$s sats pronto. Compartilhe com o destinatário.</string>
<string name="cashu_redeemed_amount">Resgatado %1$s sats para sua carteira.</string>
<string name="wallet_paste_uri">Colar URI nostr+walletconnect://</string>
<string name="wallet_save">Salvar</string>
<string name="wallet_rename">Renomear</string>
@@ -1738,6 +1929,7 @@
<string name="calendar_rsvp_going">Vou</string>
<string name="calendar_rsvp_maybe">Talvez</string>
<string name="calendar_rsvp_not_going">Não vou</string>
<string name="calendar_rsvp_section">Confirmações (%1$d)</string>
<string name="calendar_rsvp_none">Sem RSVPs ainda.</string>
<string name="calendar_participants_section">Participantes (%1$d)</string>
<string name="calendar_event_in_calendars">Em calendários (%1$d)</string>
@@ -2226,6 +2418,12 @@
<string name="kind_user_settings">Configurações do Usuário</string>
<string name="kind_audio_header">Cabeçalho de Áudio</string>
<string name="kind_audio_track">Faixa de Áudio</string>
<string name="kind_music_track">Faixa de música</string>
<string name="kind_music_playlist">Playlist de música</string>
<string name="kind_podcast_episode">Episódio de podcast</string>
<string name="kind_podcast_metadata">Programa de podcast</string>
<string name="kind_authored_podcasts">Podcasts criados</string>
<string name="kind_favorite_podcasts">Podcasts favoritos</string>
<string name="kind_badge_awards">Concessões de Emblemas</string>
<string name="kind_badge_definitions">Definições de Emblemas</string>
<string name="kind_accepted_badge_set">Conjunto de Distintivos Aceito</string>
@@ -2468,6 +2666,12 @@
<item quantity="one">%1$d ano</item>
<item quantity="other">%1$d anos</item>
</plurals>
<plurals name="cashu_pending_quotes_title">
<item quantity="one">%1$d fatura pendente</item>
<item quantity="other">%1$d faturas pendentes</item>
</plurals>
<string name="cashu_pending_quotes_subtitle">Toque para retomar e verificar o status.</string>
<string name="cashu_pending_quotes_resume">Retomar</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">Conectando</string>
<string name="event_sync_status_downloading">Baixando</string>
+134 -1
View File
@@ -731,6 +731,15 @@
<string name="articles">Artiklar</string>
<string name="route_music_tracks">Musik</string>
<string name="route_music_playlists">Spellistor</string>
<string name="route_podcast_episodes">Avsnitt</string>
<string name="route_podcasts">Poddar</string>
<string name="podcast_view_episodes">Visa avsnitt</string>
<string name="podcast_episodes_section">Avsnitt</string>
<string name="podcast_no_episodes">Inga avsnitt hittades än</string>
<plurals name="podcast_episode_count">
<item quantity="one">%1$d avsnitt</item>
<item quantity="other">%1$d avsnitt</item>
</plurals>
<string name="new_music_track">Nytt musikspår</string>
<string name="new_music_playlist">Ny spellista</string>
<string name="music_track_title_label">Titel</string>
@@ -763,6 +772,11 @@
<string name="add_to_public_bookmarks">Lägg till i Publika Bokmärken</string>
<string name="remove_from_private_bookmarks">Ta bort från Privata Bokmärken</string>
<string name="remove_from_public_bookmarks">Ta bort från Publika Bokmärken</string>
<string name="add_hashtag_label">Lägg till hashtag</string>
<string name="add_hashtag_label_title">Lägg till en hashtag</string>
<string name="add_hashtag_label_explainer">Tagga det här inlägget offentligt med en hashtag (NIP-32-etikett). De som följer dig ser det i den hashtaggens flöde.</string>
<string name="add_hashtag_label_confirm">Lägg till</string>
<string name="hashtag_label_added_by">tillagd av</string>
<string name="pinned_notes">Fästa anteckningar</string>
<string name="pinned_notes_explainer">Dina fastnålade anteckningar</string>
<string name="pin_to_profile">Fäst på profil</string>
@@ -831,6 +845,28 @@
<string name="quick_zap_amounts">Snabba Zap-belopp</string>
<string name="quick_zap_amounts_explainer">Visas när du trycker på Zap-knappen. Tryck på ett belopp för att ta bort det. Om du lämnar det tomt öppnas en dialog för att ange ett belopp varje gång.</string>
<string name="send_onchain_instead">Skicka on-chain istället</string>
<string name="reload_mint_title">Fyll på mint</string>
<string name="reload_mint_topup_label">Påfyllningsbelopp</string>
<string name="reload_mint_section_to">Mint att fylla på</string>
<string name="reload_mint_section_from">Medel från</string>
<string name="reload_mint_pay_lightning">Betala med Lightning</string>
<string name="reload_mint_lightning_desc">Utfärda nytt ecash från din Lightning-plånbok</string>
<string name="reload_mint_not_enough">Inte tillräckligt här</string>
<string name="reload_mint_available">%1$s sats tillgängliga</string>
<string name="reload_mint_confirm">Fyll på &amp; skicka zap</string>
<string name="reload_mint_send_confirm">Skicka zap</string>
<string name="reload_mint_awaiting_payment">Väntar på betalning…</string>
<string name="reload_mint_retry">Försök igen</string>
<string name="reload_mint_summary">Fyll på %1$s sats till %2$s och zappa %3$s %4$s sats · avgift ≈ %5$s sats</string>
<string name="reload_mint_summary_funded">Zappa %1$s %2$s sats från %3$s</string>
<string name="reload_mint_recipient_fallback">mottagaren</string>
<string name="reload_mint_needs_more">behöver %1$s sat till</string>
<string name="reload_mint_funded">finansierad</string>
<string name="reload_mint_copy_invoice">Kopiera faktura</string>
<string name="topup_mint_title">Fyll på mint</string>
<string name="topup_mint_action">Fyll på den här mint</string>
<string name="topup_mint_amount_label">Belopp att lägga till</string>
<string name="topup_mint_confirm">Fyll på</string>
<string name="zap_privacy_section">Zap-sekretess</string>
<string name="zap_type_section_explainer">Styr hur din identitet visas när du skickar ett Zap.</string>
<string name="wallet_connect_connect_app">Anslut plånbok</string>
@@ -1710,6 +1746,94 @@
<string name="wallet_no_wallets">Inga plånböcker anslutna</string>
<string name="wallet_no_wallets_description">Anslut en eller flera NWC-plånböcker för att skicka och ta emot betalningar.</string>
<string name="wallet_add_connection">Lägg till NWC-anslutning</string>
<string name="wallet_add_choose_type">Välj plånbokstyp</string>
<string name="wallet_add_nwc_title">Anslut en Lightning-plånbok (NWC)</string>
<string name="wallet_add_nwc_description">Betala via en extern Lightning-plånbok med Nostr Wallet Connect.</string>
<string name="wallet_add_cashu_title">Cashu-plånbok (NIP-60)</string>
<string name="wallet_add_cashu_description">En ecash-plånbok i appen som backas av en Cashu-mint. Tokens lagras krypterade på relayer.</string>
<string name="cashu_wallet_title">Cashu-plånbok</string>
<string name="cashu_no_wallet">Ingen Cashu-plånbok än</string>
<string name="cashu_no_wallet_description">Skapa en Cashu-plånbok för att hålla ecash-tokens och ta emot nutzaps.</string>
<string name="cashu_discovering">Letar efter din plånbok…</string>
<string name="cashu_discovering_description">NIP-60-plånböcker synkroniseras mellan klienter. Om du skapade en i en annan app med den här Nostr-nyckeln bör den dyka upp inom några sekunder.</string>
<string name="cashu_balance">Saldo</string>
<string name="cashu_mint_url">Mint-URL</string>
<string name="cashu_remove_mint">Ta bort mint</string>
<string name="cashu_history">Historik</string>
<string name="cashu_edit_wallet">Redigera plånbok</string>
<string name="cashu_p2pk_section">Nutzap-nyckel (avancerat)</string>
<string name="cashu_p2pk_explainer">En separat privat nyckel som endast används för att ta emot NIP-61-nutzaps. Inte din Nostr-identitetsnyckel.</string>
<string name="cashu_p2pk_autogen">Generera en ny nyckel</string>
<string name="cashu_p2pk_autogen_warning_edit">Ogiltigförklarar alla väntande nutzaps som är låsta till den nuvarande nyckeln.</string>
<string name="cashu_p2pk_keep_current">Behåll nuvarande nyckel</string>
<string name="cashu_p2pk_manual_label">Privat P2PK-nyckel (hex)</string>
<string name="wallet_edit_cashu_title">Redigera Cashu-plånbok</string>
<string name="wallet_save_changes">Spara ändringar</string>
<string name="cashu_mint_no_recs">Inga rekommendationer än</string>
<string name="cashu_mint_recommended_by">Rekommenderad av</string>
<plurals name="cashu_mint_plus_others">
<item quantity="one">+%1$d till</item>
<item quantity="other">+%1$d till</item>
</plurals>
<plurals name="cashu_mint_recommended_by_others">
<item quantity="one">Rekommenderad av %1$d annan</item>
<item quantity="other">Rekommenderad av %1$d andra</item>
</plurals>
<string name="cashu_mint_popular_header">Populära mints</string>
<string name="cashu_mint_matches_header">Matchande mints</string>
<string name="cashu_recommend_mint">Rekommendera den här mint</string>
<string name="cashu_action_receive">Ta emot</string>
<string name="cashu_action_send_ln">Skicka LN</string>
<string name="cashu_action_send_token">Skicka token</string>
<string name="cashu_action_redeem">Lös in</string>
<string name="cashu_amount_sats">Belopp (sats)</string>
<string name="cashu_pick_mint">Välj en mint</string>
<string name="cashu_memo_optional">Notering (valfritt)</string>
<string name="cashu_invoice_bolt11">Lightning-faktura (bolt11)</string>
<string name="cashu_token_label">Cashu-token</string>
<string name="cashu_copy_invoice">Kopiera faktura</string>
<string name="cashu_request_invoice">Begär faktura</string>
<string name="cashu_discard_invoice">Förkasta faktura</string>
<string name="cashu_history_incoming">Mottaget</string>
<string name="cashu_history_outgoing">Skickat</string>
<string name="cashu_settings_title">Inställningar för Cashu-plånbok</string>
<string name="cashu_settings_edit_wallet">Redigera plånboksuppgifter</string>
<string name="cashu_settings_edit_wallet_subtitle">Mints, nutzap-nyckel</string>
<string name="cashu_settings_my_recommendations">Mina mint-rekommendationer</string>
<string name="cashu_settings_no_recommendations">Du har inte rekommenderat några mints än. Tryck på tummen upp bredvid en mint för att rekommendera den offentligt.</string>
<string name="cashu_settings_delete_recommendation">Radera rekommendation</string>
<string name="cashu_settings_delete_confirm_title">Dra tillbaka rekommendation?</string>
<string name="cashu_settings_delete_confirm_body">Publicera en raderingsbegäran för din kind:38000-rekommendation av %1$s. Relayer som följer NIP-09 tar bort den.</string>
<string name="cashu_settings_add_recommendation">Rekommendera en mint</string>
<string name="cashu_settings_restore_title">Återställ från seed</string>
<string name="cashu_settings_restore_subtitle">Härled varje tidigare hemlighet på nytt och be varje mint att returnera alla blinda signaturer som fortfarande finns sparade. Användbart efter en förlorad enhet eller en oseriös relays radering av dina token-händelser.</string>
<string name="cashu_settings_restore_running">Skannar mints…</string>
<string name="cashu_settings_restore_nothing_found">Inga återvinningsbara proofs hittades.</string>
<string name="cashu_settings_restore_result">Återställde %1$s sats över %2$s proofs.</string>
<string name="cashu_pay_invoice">Betala faktura</string>
<string name="cashu_get_quote">Hämta offert</string>
<string name="cashu_getting_quote">Ber mint om en offert…</string>
<string name="cashu_quote_confirm">Betala %1$s sat + upp till %2$s sat i avgifter?</string>
<string name="cashu_verify">Verifiera</string>
<string name="cashu_mint_reachable">✓ Mint är nåbar</string>
<string name="cashu_mint_unreachable">Kunde inte nå mint: %1$s</string>
<string name="nutzap_failed_title">Nutzap misslyckades</string>
<string name="nutzap_failed_no_recipient">Ingen mottagar-pubkey på noten</string>
<string name="nutzap_failed_no_event">Kan inte bygga händelsereferens</string>
<string name="cashu_create_token">Skapa token</string>
<string name="cashu_redeem_button">Lös in</string>
<string name="cashu_done">Klar</string>
<string name="cashu_requesting_invoice">Ber mint om en faktura…</string>
<string name="cashu_waiting_for_payment">Väntar på att fakturan ska betalas…</string>
<string name="cashu_completing_mint">Utfärdar proofs…</string>
<string name="cashu_paying_invoice">Betalar via mint…</string>
<string name="cashu_building_token">Byter proofs…</string>
<string name="cashu_redeeming">Byter proofs till din plånbok…</string>
<string name="cashu_receive_invoice_explainer">Betala den här fakturan för att utfärda %1$s sats ecash.</string>
<string name="cashu_received_amount">Tog emot %1$s sats.</string>
<string name="cashu_paid_amount">Betalade %1$s sats (avgifter: %2$s sats).</string>
<string name="cashu_token_ready">Token för %1$s sats redo. Dela den med mottagaren.</string>
<string name="cashu_redeemed_amount">Löste in %1$s sats till din plånbok.</string>
<string name="wallet_paste_uri">Klistra in nostr+walletconnect://-URI</string>
<string name="wallet_save">Spara</string>
<string name="wallet_rename">Byt namn</string>
@@ -1718,7 +1842,6 @@
<string name="wallet_invalid_uri">Ogiltig URI för NWC-anslutning</string>
<string name="wallet_move_up">Flytta upp</string>
<string name="wallet_move_down">Flytta ner</string>
<string name="wallet_reorder">Ändra ordning på plånböcker</string>
<string name="wallet_onchain_transactions">On-chain-transaktioner</string>
<string name="wallet_onchain_no_address">Ingen on-chain-adress tillgänglig för detta konto.</string>
<string name="wallet_onchain_no_backend">Ingen on-chain-backend är konfigurerad.</string>
@@ -2311,6 +2434,10 @@
<string name="kind_audio_track">Ljudspår</string>
<string name="kind_music_track">Musikspår</string>
<string name="kind_music_playlist">Musikspellista</string>
<string name="kind_podcast_episode">Poddavsnitt</string>
<string name="kind_podcast_metadata">Poddprogram</string>
<string name="kind_authored_podcasts">Skapade poddar</string>
<string name="kind_favorite_podcasts">Favoritpoddar</string>
<string name="kind_badge_awards">Utmärkelser</string>
<string name="kind_badge_definitions">Utmärkelsedefinitioner</string>
<string name="kind_accepted_badge_set">Accepterad emblemuppsättning</string>
@@ -2553,6 +2680,12 @@
<item quantity="one">%1$d år</item>
<item quantity="other">%1$d år</item>
</plurals>
<plurals name="cashu_pending_quotes_title">
<item quantity="one">%1$d väntande faktura</item>
<item quantity="other">%1$d väntande fakturor</item>
</plurals>
<string name="cashu_pending_quotes_subtitle">Tryck för att återuppta och kontrollera status.</string>
<string name="cashu_pending_quotes_resume">Återuppta</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">Ansluter</string>
<string name="event_sync_status_downloading">Laddar ner</string>
@@ -764,6 +764,12 @@
<string name="add_to_public_bookmarks">添加到公开书签</string>
<string name="remove_from_private_bookmarks">从私人书签中移除</string>
<string name="remove_from_public_bookmarks">从公开书签中移除</string>
<string name="add_hashtag_label">添加话题标签</string>
<string name="add_hashtag_label_title">添加话题标签</string>
<string name="add_hashtag_label_explainer">给这篇帖子加公开话题标签 (NIP-32 标签) 。关注你的人会在话题的源里看到。</string>
<string name="add_hashtag_label_field">话题标签</string>
<string name="add_hashtag_label_confirm">添加</string>
<string name="hashtag_label_added_by">添加者</string>
<string name="pinned_notes">置顶的笔记</string>
<string name="pinned_notes_explainer">您置顶的笔记</string>
<string name="pin_to_profile">置顶到个人资料</string>
@@ -832,6 +838,29 @@
<string name="quick_zap_amounts">快速打闪金额</string>
<string name="quick_zap_amounts_explainer">按下打闪按钮时显示。点击接即可删除它。 如果留空,每次都会打开对话框来插入金融。</string>
<string name="send_onchain_instead">在链上发送</string>
<string name="reload_mint_title">铸币厂充值</string>
<string name="reload_mint_sats_amount">%1$s sats</string>
<string name="reload_mint_topup_label">充值金额</string>
<string name="reload_mint_section_to">要充值的铸币厂</string>
<string name="reload_mint_section_from">资金来源</string>
<string name="reload_mint_pay_lightning">用闪电付款</string>
<string name="reload_mint_lightning_desc">从闪电钱包铸造新的电子现金</string>
<string name="reload_mint_not_enough">这里还不够</string>
<string name="reload_mint_available">%1$s sats 可用</string>
<string name="reload_mint_confirm">充值 &amp; 发送打闪</string>
<string name="reload_mint_send_confirm">发送打闪</string>
<string name="reload_mint_awaiting_payment">等待支付中…</string>
<string name="reload_mint_retry">重试</string>
<string name="reload_mint_summary">充值 %1$s sats 到 %2$s 并打闪 %3$s %4$s sats · fee ≈ %5$s sats</string>
<string name="reload_mint_summary_funded">从 %3$s 打闪 %1$s %2$s sats</string>
<string name="reload_mint_recipient_fallback">接收者</string>
<string name="reload_mint_needs_more">需要另外 %1$s sat</string>
<string name="reload_mint_funded">已支付</string>
<string name="reload_mint_copy_invoice">复制发票</string>
<string name="topup_mint_title">铸币厂充值</string>
<string name="topup_mint_action">向该铸币厂充值</string>
<string name="topup_mint_amount_label">要添加的金额</string>
<string name="topup_mint_confirm">充值</string>
<string name="zap_privacy_section">打闪隐私</string>
<string name="zap_type_section_explainer">控制发送打闪时如何显示您的身份。</string>
<string name="wallet_connect_connect_app">连接钱包</string>
@@ -1810,7 +1839,6 @@
<string name="wallet_invalid_uri">无效的 NWC 连接 URI</string>
<string name="wallet_move_up">上移</string>
<string name="wallet_move_down">下移</string>
<string name="wallet_reorder">调整钱包顺序</string>
<string name="wallet_onchain_transactions">链上交易</string>
<string name="wallet_onchain_no_address">此账户没有链上地址可用。</string>
<string name="wallet_onchain_no_backend">没有配置链后端。</string>
+11 -1
View File
@@ -806,6 +806,13 @@
<string name="remove_from_private_bookmarks">Remove from Private Bookmarks</string>
<string name="remove_from_public_bookmarks">Remove from Public Bookmarks</string>
<string name="add_hashtag_label">Add hashtag</string>
<string name="add_hashtag_label_title">Add a hashtag</string>
<string name="add_hashtag_label_explainer">Publicly tag this post with a hashtag (NIP-32 label). People who follow you will see it in that hashtag\'s feed.</string>
<string name="add_hashtag_label_field">Hashtag</string>
<string name="add_hashtag_label_confirm">Add</string>
<string name="hashtag_label_added_by">added by</string>
<string name="pinned_notes">Pinned Notes</string>
<string name="pinned_notes_explainer">Your pinned notes</string>
<string name="pin_to_profile">Pin to Profile</string>
@@ -897,6 +904,10 @@
<string name="reload_mint_needs_more">needs %1$s sat more</string>
<string name="reload_mint_funded">funded</string>
<string name="reload_mint_copy_invoice">Copy invoice</string>
<string name="topup_mint_title">Top up mint</string>
<string name="topup_mint_action">Top up this mint</string>
<string name="topup_mint_amount_label">Amount to add</string>
<string name="topup_mint_confirm">Top up</string>
<string name="zap_privacy_section">Zap Privacy</string>
<string name="zap_type_section_explainer">Controls how your identity is shown when you send a zap.</string>
<string name="wallet_connect_connect_app">Connect Wallet</string>
@@ -2051,7 +2062,6 @@
<string name="wallet_invalid_uri">Invalid NWC connection URI</string>
<string name="wallet_move_up">Move Up</string>
<string name="wallet_move_down">Move Down</string>
<string name="wallet_reorder">Reorder wallet</string>
<string name="wallet_onchain_transactions">Onchain Transactions</string>
<string name="wallet_onchain_no_address">No on-chain address available for this account.</string>
<string name="wallet_onchain_no_backend">No chain backend is configured.</string>
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.service.firstFullChar
import com.vitorpamplona.amethyst.commons.util.firstFullChar
import org.junit.Assert
import org.junit.Test
@@ -145,6 +145,7 @@ open class Note(
removeZap(note)
removeZapPayment(note)
removeReport(note)
removeLabel(note)
}
var poll: PollResponsesCache? = null
@@ -166,6 +167,16 @@ open class Note(
var reports = mapOf<User, List<Note>>()
private set
/**
* NIP-32 hashtag labels (kind 1985) targeting this note.
* Key: hashtag value, lowercased (the `l` tag value under the `#t` namespace).
* Value: the list of LabelEvent notes that applied that hashtag to this note,
* so `source.author` identifies who labeled it. Used by the hashtag feed to
* surface posts a follow has tagged and to attribute the label in the UI.
*/
var labels = mapOf<String, List<Note>>()
private set
var zaps = mapOf<Note, Note?>()
private set
@@ -366,12 +377,14 @@ open class Note(
val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty() || nutzaps.isNotEmpty()
val boostsChanged = boosts.isNotEmpty()
val reportsChanged = reports.isNotEmpty()
val labelsChanged = labels.isNotEmpty()
val toBeRemoved =
replies +
reactions.values.flatten() +
boosts +
reports.values.flatten() +
labels.values.flatten() +
zaps.keys +
zaps.values.filterNotNull() +
zapPayments.keys +
@@ -382,6 +395,7 @@ open class Note(
reactions = mapOf()
boosts = listOf()
reports = mapOf()
labels = mapOf()
zaps = mapOf()
onchainZaps = mapOf()
onchainZapResolved = false
@@ -394,6 +408,7 @@ open class Note(
if (reactionsChanged) flowSet?.reactions?.invalidateData()
if (boostsChanged) flowSet?.boosts?.invalidateData()
if (reportsChanged) flowSet?.reports?.invalidateData()
if (labelsChanged) flowSet?.labels?.invalidateData()
if (zapsChanged) flowSet?.zaps?.invalidateData()
return toBeRemoved
@@ -668,6 +683,32 @@ open class Note(
}
}
/** Attach a NIP-32 LabelEvent note as having tagged this note with [hashtag] (lowercased). */
fun addLabel(
hashtag: String,
note: Note,
) {
val listOfLabelers = labels[hashtag]
if (listOfLabelers == null) {
labels = labels + Pair(hashtag, listOf(note))
flowSet?.labels?.invalidateData()
} else if (!listOfLabelers.contains(note)) {
labels = labels + Pair(hashtag, listOfLabelers + note)
flowSet?.labels?.invalidateData()
}
}
/** Detach a LabelEvent note (e.g. deleted) from every hashtag bucket it was in. */
fun removeLabel(note: Note) {
if (labels.none { it.value.contains(note) }) return
labels =
labels
.mapValues { it.value - note }
.filterValues { it.isNotEmpty() }
flowSet?.labels?.invalidateData()
}
fun addRelaySync(relay: NormalizedRelayUrl) =
syncLock.withLock {
if (relay !in relays) {
@@ -1239,6 +1280,7 @@ class NoteFlowSet(
val zaps = NoteBundledRefresherFlow(u)
val ots = NoteBundledRefresherFlow(u)
val edits = NoteBundledRefresherFlow(u)
val labels = NoteBundledRefresherFlow(u)
@OptIn(ExperimentalCoroutinesApi::class)
fun author() =
@@ -1257,7 +1299,8 @@ class NoteFlowSet(
replies.hasObservers() ||
zaps.hasObservers() ||
ots.hasObservers() ||
edits.hasObservers()
edits.hasObservers() ||
labels.hasObservers()
}
@Stable
@@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.nip01Core.UserRelaysCache
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.UserNip05Cache
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusCache
import com.vitorpamplona.amethyst.commons.model.nip56Reports.UserReportCache
import com.vitorpamplona.amethyst.commons.model.trustedAssertions.UserCardsCache
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.UserCardsCache
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -18,7 +18,7 @@
* 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.model.nip30CustomEmojis
package com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
@@ -18,15 +18,17 @@
* 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.model.nip47WalletConnect
package com.vitorpamplona.amethyst.commons.model.nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.serialization.Serializable
import java.util.UUID
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
@Serializable
data class NwcWalletEntry(
val id: String = UUID.randomUUID().toString(),
val id: String = Uuid.random().toString(),
val name: String,
val uri: Nip47WalletConnect.Nip47URI,
) {
@@ -18,7 +18,7 @@
* 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.model.nip51Lists.favoriteAlgoFeedsLists
package com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
@@ -18,7 +18,7 @@
* 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.model.nip51Lists.hashtagLists
package com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
@@ -18,7 +18,7 @@
* 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.model.nip51Lists.interestSets
package com.vitorpamplona.amethyst.commons.model.nip51Lists.interestSets
import androidx.compose.runtime.Stable
@@ -18,7 +18,7 @@
* 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.model.nip51Lists.labeledBookmarkLists
package com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
@@ -18,7 +18,7 @@
* 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.model.nip51Lists.muteList
package com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
@@ -18,7 +18,7 @@
* 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.model.nip51Lists.peopleList
package com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cashu
package com.vitorpamplona.amethyst.commons.model.nip60Cashu
import androidx.compose.runtime.Immutable
import kotlinx.serialization.Serializable
@@ -18,7 +18,7 @@
* 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.model.nip72Communities
package com.vitorpamplona.amethyst.commons.model.nip72Communities
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
@@ -18,7 +18,7 @@
* 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.model.trustedAssertions
package com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
@@ -18,7 +18,7 @@
* 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.trustedAssertions
package com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
import kotlinx.coroutines.flow.StateFlow
@@ -18,7 +18,7 @@
* 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.trustedAssertions
package com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.User
@@ -18,7 +18,7 @@
* 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.model.nip30CustomEmojis
package com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import org.junit.Assert.assertEquals
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
@@ -34,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.eTag
import com.vitorpamplona.quartz.nip01Core.tags.events.toETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
@@ -82,6 +84,16 @@ class LabelEvent(
/** Labels filtered by a specific namespace. */
fun labelsByNamespace(namespace: String) = labels().filter { it.namespace == namespace }
/**
* Hashtags this event associates with its targets, via the NIP-32 tag-association
* namespace `#t` (`["L", "#t"]` + `["l", "<hashtag>", "#t"]`). Values are returned
* lowercased so callers can compare without worrying about case.
*/
fun hashtagAssociations() =
labelsByNamespace(HASHTAG_NAMESPACE)
.map { it.label.lowercase() }
.distinct()
/** Referenced event IDs (label targets). */
fun labeledEvents() = tags.mapNotNull(ETag::parseId)
@@ -104,6 +116,30 @@ class LabelEvent(
const val KIND = 1985
const val ALT = "Label event"
/**
* NIP-32 tag-association namespace for hashtags. A label of the form
* `["l", "<hashtag>", "#t"]` (with `["L", "#t"]`) associates the target
* with the hashtag `<hashtag>` under the standard `t` tag.
*/
const val HASHTAG_NAMESPACE = "#t"
/**
* Build a label event that tags an existing event with a hashtag, using the
* NIP-32 tag-association namespace `#t`. The hashtag is stored lowercased so
* follow-graph hashtag feeds match regardless of input case.
*/
fun buildHashtagLabel(
labeledEvent: EventHintBundle<out Event>,
hashtag: String,
createdAt: Long = TimeUtils.now(),
) = eventTemplate<LabelEvent>(KIND, "", createdAt) {
alt(ALT)
eTag(labeledEvent.toETag())
val tag = LabelTag(hashtag.removePrefix("#").lowercase(), HASHTAG_NAMESPACE)
labelNamespace(tag.namespace)
label(tag)
}
/**
* Build a label event for labeling events.
*/
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip32Labeling
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip32Labeling.tags.LabelTag
import com.vitorpamplona.quartz.utils.EventFactory
@@ -160,6 +161,61 @@ class LabelEventTest {
assertEquals("en", languageLabels[0].label)
}
@Test
fun testBuildHashtagLabel() {
val labeledEvent =
EventFactory.create<Event>(
id = testEventId,
pubKey = testPubKey,
createdAt = 1234567890L,
kind = 1,
tags = arrayOf(),
content = "hello",
sig = "c".repeat(128),
)
val template =
LabelEvent.buildHashtagLabel(
labeledEvent = EventHintBundle(labeledEvent),
hashtag = "#PhotoGraphy",
)
assertEquals(1985, template.kind)
val hasEventRef = template.tags.any { it[0] == "e" && it[1] == testEventId }
assertTrue(hasEventRef, "Should have e tag targeting labeled event")
val hasNamespace = template.tags.any { it[0] == "L" && it[1] == "#t" }
assertTrue(hasNamespace, "Should have the #t tag-association namespace")
// The leading '#' is stripped and the value is lowercased.
val hasLabel = template.tags.any { it[0] == "l" && it[1] == "photography" && it[2] == "#t" }
assertTrue(hasLabel, "Should have a lowercased l label tag under #t")
}
@Test
fun testHashtagAssociations() {
val event =
EventFactory.create<LabelEvent>(
id = testEventId,
pubKey = testPubKey,
createdAt = 1234567890L,
kind = 1985,
tags =
arrayOf(
arrayOf("L", "#t"),
arrayOf("l", "Bitcoin", "#t"),
arrayOf("l", "nostr", "#t"),
arrayOf("l", "MIT", "license"),
arrayOf("e", testEventId),
),
content = "",
sig = "c".repeat(128),
)
assertEquals(listOf("bitcoin", "nostr"), event.hashtagAssociations())
}
@Test
fun testLabeledTargets() {
val event =