Merge remote-tracking branch 'origin/main' into claude/buzz-repo-analysis-7k54ga

This commit is contained in:
Claude
2026-07-22 22:58:51 +00:00
15 changed files with 308 additions and 7 deletions
@@ -2880,10 +2880,15 @@ class Account(
* Read-only import: kind 13302 is replaceable, so folding an older copy is a
* no-op and this is safe to call on every hub open. Merging our own edits with
* a foreign writer's is a separate concern (newest-wins replaceable).
*
* [extraRelays] are additional relays to query — the bootstrap relays saved on the
* bottom-bar tabs of pinned communities. A community's private list frequently lives
* only on the community's own relays (never the user's outbox), so a community pinned
* to the bottom bar would otherwise never surface when opened cold.
*/
suspend fun importConcordCommunities() {
suspend fun importConcordCommunities(extraRelays: Set<NormalizedRelayUrl> = emptySet()) {
val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
val relays = (stock + mineRelays.flow.value + outboxRelays.flow.value).toSet()
val relays = (stock + mineRelays.flow.value + outboxRelays.flow.value + extraRelays).toSet()
if (relays.isEmpty()) return
val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(signer.pubKey))
// Stock relays like relay.ditto.pub can be slow (~1020s to first response), so give
@@ -70,11 +70,22 @@ sealed interface BottomBarEntry {
val relayUrl: String,
) : BottomBarEntry
/** A pinned Concord community, keyed by its community id; opens the community's channel list. */
/**
* A pinned Concord community, keyed by its community id; opens the community's channel list.
*
* [relays] are the community's bootstrap relays, captured from the joined-list entry at pin
* time. A Concord community's private kind-13302 list often lives only on these relays (Armada/
* Vector publish it there, never to the user's outbox), so without them a pinned community whose
* list we haven't cached can never be found — the tab and its server screen would stay blank.
* Carrying the relays on the tab lets the bootstrap re-fetch the list from the right place even
* when nothing about the community is known yet. Optional (defaults empty) so older persisted
* bottom-bar configs still decode.
*/
@Serializable
@SerialName("concord")
data class Concord(
val communityId: String,
val relays: List<String> = emptyList(),
) : BottomBarEntry
/** A pinned Bitchat geohash location channel, keyed by its geohash cell; opens the location chat. */
@@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
@@ -127,6 +128,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.auth.EmptyIAuthStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
@@ -671,10 +673,20 @@ class AccountViewModel(
account.refoundConcordCommunity(communityId, setOf(member))
}
/** Pull the account's Concord community list from the stock + own relays (Concord hub bootstrap). */
/**
* Pull the account's Concord community list (kind 13302) from the stock + own relays and, in
* addition, from the bootstrap relays of every Concord community pinned to the bottom bar. The
* private list frequently lives only on a community's own relays, so a pinned community opened
* cold — never through the hub — is only reachable via the relays saved on its tab.
*/
fun importConcordCommunities() =
viewModelScope.launch(Dispatchers.IO) {
account.importConcordCommunities()
val pinnedRelays =
settings.uiSettingsFlow.bottomBarItems.value
.filterIsInstance<BottomBarEntry.Concord>()
.flatMap { it.relays }
.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) }
account.importConcordCommunities(pinnedRelays)
}
/** Publish an ephemeral typing heartbeat to a Concord channel (throttled by the caller). */
@@ -27,6 +27,7 @@ import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
@@ -76,6 +77,15 @@ fun ConcordChannelSubscription(
* [KeyDataSourceSubscription] so the planes stay requested app-wide, exactly like DMs, and keeps the
* same [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager.revision] watch so a
* fresh fold subscribes its newly-revealed channel planes.
*
* Preloading a community's planes requires its keys, which come from the private kind-13302 list
* so a community pinned to the bottom bar whose list never reached the cache preloads nothing and
* shows a blank tab + server screen ("doesn't load at all"). That list often lives only on the
* community's own relays (Armada/Vector publish it there, never to the user's outbox), so before we
* can preload we may first have to fetch it: [bootstrapPinnedCommunities] imports the list for any
* pinned community we don't yet know, from the relays saved on its tab. Once it folds into the cache,
* [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState.liveCommunities] surfaces
* the entry, the tab/screen fill in, and the plane preload above picks it up.
*/
@Composable
fun ConcordChannelPreload(accountViewModel: AccountViewModel) {
@@ -88,5 +98,37 @@ fun ConcordChannelPreload(accountViewModel: AccountViewModel) {
dataSource.invalidateFilters()
}
bootstrapPinnedCommunities(accountViewModel)
KeyDataSourceSubscription(state, dataSource)
}
/**
* Fetch the private kind-13302 list of any Concord community pinned to the bottom bar whose list we
* don't already have, from the relays saved on its tab (see
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel.importConcordCommunities]). Runs
* app-wide as part of [ConcordChannelPreload], so a pinned community loads without the user ever
* opening the Concord hub (which has its own import). Keyed on the exact set of missing communities,
* so the (slow, stock-relay) fetch runs when a new gap appears a freshly pinned community we can't
* yet resolve and not on every recomposition; it stops once every pinned community is known.
*/
@Composable
private fun bootstrapPinnedCommunities(accountViewModel: AccountViewModel) {
val account = accountViewModel.account
val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems
.collectAsStateWithLifecycle()
val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle()
val missingPinned =
remember(items, communities) {
val known = communities.mapTo(HashSet()) { it.id }
items
.filterIsInstance<BottomBarEntry.Concord>()
.map { it.communityId }
.filterTo(sortedSetOf()) { it !in known }
}
LaunchedEffect(missingPinned) {
if (missingPinned.isNotEmpty()) accountViewModel.importConcordCommunities()
}
}
@@ -585,7 +585,7 @@ private fun PickerChildren(
NavBarItem.CONCORD -> {
val communities by accountViewModel.account.concordChannelList.liveCommunities
.collectAsStateWithLifecycle()
val entries = remember(communities) { communities.map { BottomBarEntry.Concord(it.id) } }
val entries = remember(communities) { communities.map { BottomBarEntry.Concord(it.id, it.relays) } }
GroupChildList(entries, pinnedKeys, accountViewModel, onTogglePin)
}
@@ -1677,7 +1677,9 @@
<string name="media_server_primary_badge">Hlavní</string>
<string name="media_server_added">Přidáno</string>
<string name="media_server_reorder">Změnit pořadí serveru</string>
<string name="media_server_status_online">Online</string>
<string name="media_server_status_slow">Pomalé</string>
<string name="media_server_status_offline">Offline</string>
<string name="media_server_status_checking">Kontrola…</string>
<string name="payment_targets">Platební cíle</string>
<string name="payment_targets_explainer">Zveřejněte své platební adresy, aby vám ostatní mohli přímo posílat prostředky.</string>
@@ -1613,7 +1613,9 @@
<string name="media_server_primary_badge">Primär</string>
<string name="media_server_added">Hinzugefügt</string>
<string name="media_server_reorder">Server neu anordnen</string>
<string name="media_server_status_online">Online</string>
<string name="media_server_status_slow">Langsam</string>
<string name="media_server_status_offline">Offline</string>
<string name="media_server_status_checking">Wird geprüft…</string>
<string name="payment_targets">Zahlungsziele</string>
<string name="payment_targets_explainer">Veröffentliche deine Zahlungsadressen, damit andere dir direkt Geld senden können.</string>
@@ -886,11 +886,62 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<item quantity="many">Przestaje obserwować %1$d kont</item>
<item quantity="other">Przestaje obserwować %1$d konta</item>
</plurals>
<plurals name="napplet_consent_diff_relay_added">
<item quantity="one">dodaje %1$d transmiter</item>
<item quantity="few">dodaje %1$d transmiterów</item>
<item quantity="many">dodaje %1$d transmiterów</item>
<item quantity="other">dodaje %1$d transmitery</item>
</plurals>
<plurals name="napplet_consent_diff_relay_removed">
<item quantity="one">USUWA %1$d transmiter</item>
<item quantity="few">USUWA %1$d transmiterów</item>
<item quantity="many">USUWA %1$d transmiterów</item>
<item quantity="other">USUWA %1$d transmitery</item>
</plurals>
<plurals name="napplet_consent_diff_mute_added">
<item quantity="one">wycisza jeszcze %1$d osobę</item>
<item quantity="few">wycisza jeszcze %1$d osób</item>
<item quantity="many">wycisza jeszcze %1$d osób</item>
<item quantity="other">wycisza jeszcze %1$d osoby</item>
</plurals>
<plurals name="napplet_consent_diff_mute_removed">
<item quantity="one">ODBLOKOWUJE %1$d osobę</item>
<item quantity="few">ODBLOKOWUJE %1$d osób</item>
<item quantity="many">ODBLOKOWUJE %1$d osób</item>
<item quantity="other">ODBLOKOWUJE %1$d osoby</item>
</plurals>
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
the display name, shown next to their avatar. -->
<string name="napplet_consent_diff_follow_one">⚠️ To obserwuje %1$s.</string>
<string name="napplet_consent_diff_unfollow_one">⚠️ To zaprzestaje obserwacji %1$s.</string>
<string name="napplet_consent_diff_mute_one">⚠️ To ucisza %1$s.</string>
<string name="napplet_consent_diff_unmute_one">⚠️ To odblokowuje %1$s.</string>
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
<string name="napplet_consent_diff_follows">⚠️ To spowoduje przepisanie Twojej listy obserwowanych: %1$s.</string>
<string name="napplet_consent_diff_relays">⚠️ To przepisuje twoją listę transmiterów: %1$s. Twoje posty i przeczytane wpisy zmieniają się wraz z tym.</string>
<string name="napplet_consent_diff_mutes">⚠️ Spowoduje to aktualizację listy wyciszonych: %1$s. Wyciszone słowa i hashtagi nie są tutaj wyświetlane — naciśnij „Pokaż wydarzenie”, aby wyświetlić pełną listę.</string>
<string name="napplet_consent_diff_joiner">%1$s i %2$s</string>
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
<string name="napplet_consent_diff_none">Powoduje to ponowne opublikowanie istniejącej listy bez zmian.</string>
<!-- No cached copy to compare against, so the whole list is what gets written. -->
<plurals name="napplet_consent_diff_no_baseline">
<item quantity="one">Spowoduje to utworzenie listy zawierającej %1$d pozycję. Amethyst nie posiada kopii w pamięci podręcznej, z którą mógłby dokonać porównania.</item>
<item quantity="few">Spowoduje to zapisanie listy zawierającej %1$d pozycji. Amethyst nie posiada w pamięci podręcznej kopii, z którą mógłby to porównać.</item>
<item quantity="many">Spowoduje to zapisanie listy zawierającej %1$d pozycji. Amethyst nie posiada w pamięci podręcznej kopii, z którą mógłby to porównać.</item>
<item quantity="other">Spowoduje to zapisanie listy zawierającej %1$d pozycje. Amethyst nie posiada w pamięci podręcznej kopii, z którą mógłby to porównać.</item>
</plurals>
<plurals name="napplet_consent_effect_deletes">
<item quantity="one">To żądanie dotyczy usunięcia %1$d wydarzenia.</item>
<item quantity="few">To żądanie dotyczy usunięcia %1$d wydarzeń.</item>
<item quantity="many">To żądanie dotyczy usunięcia %1$d wydarzeń.</item>
<item quantity="other">To żądanie dotyczy usunięcia %1$d wydarzeń.</item>
</plurals>
<plurals name="napplet_consent_effect_tags">
<item quantity="one">Zawiera %1$d tag. Naciśnij „Pokaż wydarzenie”, aby sprawdzić, co dokładnie zostanie podpisane.</item>
<item quantity="few">Zawiera %1$d tagów. Naciśnij „Pokaż wydarzenie”, aby sprawdzić, co dokładnie zostanie podpisane.</item>
<item quantity="many">Zawiera %1$d tagów. Naciśnij „Pokaż wydarzenie”, aby sprawdzić, co dokładnie zostanie podpisane.</item>
<item quantity="other">Zawiera %1$d tagi. Naciśnij „Pokaż wydarzenie”, aby sprawdzić, co dokładnie zostanie podpisane.</item>
</plurals>
<!-- Signer permissions: first-connect dialog -->
<string name="napplet_connect_title">Połącz z Nostr</string>
<string name="napplet_connect_subtitle">chce połączyć się z Twoim kontem Nostr</string>
@@ -1611,7 +1611,9 @@
<string name="media_server_primary_badge">Primär</string>
<string name="media_server_added">Tillagd</string>
<string name="media_server_reorder">Ändra ordning på server</string>
<string name="media_server_status_online">Online</string>
<string name="media_server_status_slow">Långsam</string>
<string name="media_server_status_offline">Offline</string>
<string name="media_server_status_checking">Kontrollerar…</string>
<string name="payment_targets">Betalningsmottagare</string>
<string name="payment_targets_explainer">Publicera dina betalningsadresser så att andra kan skicka pengar direkt till dig.</string>
@@ -40,7 +40,7 @@ class BottomBarEntrySerializationTest {
BottomBarEntry.Favorite("url:https://example.com"),
BottomBarEntry.PublicChat("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb"),
BottomBarEntry.RelayGroup("abcd1234", "wss://groups.example.com"),
BottomBarEntry.Concord("f".repeat(64)),
BottomBarEntry.Concord("f".repeat(64), listOf("wss://relay.ditto.pub", "wss://community.example.com")),
)
@Test
@@ -12,6 +12,7 @@
<string name="login_generate_button">Vygenerovat nový</string>
<string name="login_key_hint">Zadejte svůj soukromý klíč (nsec) nebo veřejný klíč (npub)</string>
<string name="login_key_label">nsec, bunker:// nebo npub</string>
<string name="login_key_placeholder">nsec1… / bunker://… / npub1…</string>
<string name="login_show_key">Zobrazit klíč</string>
<string name="login_hide_key">Skrýt klíč</string>
<!-- New Key Warning -->
@@ -24,6 +25,7 @@
<string name="action_copy">Kopírovat</string>
<string name="action_paste">Vložit</string>
<string name="action_cancel">Zrušit</string>
<string name="action_ok">OK</string>
<string name="action_save">Uložit</string>
<string name="action_delete">Smazat</string>
<string name="action_share">Sdílet</string>
@@ -56,16 +58,26 @@
<string name="chats_history_all_caught_up">Vše načteno</string>
<string name="chats_history_reached_start">Dosáhli jste začátku zpráv %1$s</string>
<string name="chats_history_subtitle">%1$s · %2$s · načteno od %3$s</string>
<string name="chats_history_subtitle_no_date">%1$s · %2$s</string>
<string name="chats_history_waiting">čeká se na %1$s</string>
<string name="chats_history_incomplete">Některé relaye neodpověděly</string>
<string name="chats_history_incomplete_sub">%1$s nedostupných · klepnutím zobrazíte které</string>
<string name="chats_history_relays_title">%1$s · historie podle relaye</string>
<string name="chats_history_relay_since">od %1$s</string>
<string name="action_dismiss">Zavřít</string>
<plurals name="chats_history_relays">
<item quantity="one">%1$d relay</item>
<item quantity="few">%1$d relaye</item>
<item quantity="many">%1$d relaye</item>
<item quantity="other">%1$d relayů</item>
</plurals>
<!-- Notes & Replies -->
<string name="replying_to">odpovídá na </string>
<!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card -->
<string name="nsite_title">Statický web: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_kind">nApplet</string>
<string name="nsite_website_kind">nSite</string>
<string name="napplet_card_permissions">K čemu má přístup</string>
<string name="nsite_root_site">Kořenový web</string>
<string name="nsite_source">Zdroj:</string>
@@ -127,6 +139,8 @@
<string name="road_event_traffic_jam">Dopravní zácpa</string>
<string name="road_event_unknown">Silniční událost</string>
<string name="podcast_value_zap_split_hint">Zapy na toto se rozdělují mezi:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_for_value">Value-for-Value</string>
<string name="relay_monitor_rtt_open">Otevření</string>
<string name="relay_monitor_rtt_read">Čtení</string>
<string name="relay_monitor_rtt_write">Zápis</string>
@@ -134,6 +148,7 @@
<string name="relay_monitor_relay_type">Typ</string>
<string name="relay_monitor_requirements">Požadavky</string>
<string name="relay_monitor_supported_nips">Podporované NIP</string>
<string name="relay_monitor_ms">%1$d ms</string>
<string name="relay_discovery_accepted_kinds">Přijímané druhy</string>
<string name="relay_discovery_geohash">Poloha</string>
<string name="calendar_rsvp_going">Účastním se</string>
@@ -12,6 +12,7 @@
<string name="login_generate_button">Neu erstellen</string>
<string name="login_key_hint">Gib deinen privaten Schlüssel (nsec) oder öffentlichen Schlüssel (npub) ein</string>
<string name="login_key_label">nsec, bunker:// oder npub</string>
<string name="login_key_placeholder">nsec1… / bunker://… / npub1…</string>
<string name="login_show_key">Schlüssel anzeigen</string>
<string name="login_hide_key">Schlüssel verbergen</string>
<!-- New Key Warning -->
@@ -24,6 +25,7 @@
<string name="action_copy">Kopieren</string>
<string name="action_paste">Einfügen</string>
<string name="action_cancel">Abbrechen</string>
<string name="action_ok">OK</string>
<string name="action_save">Speichern</string>
<string name="action_delete">Löschen</string>
<string name="action_share">Teilen</string>
@@ -56,6 +58,7 @@
<string name="chats_history_all_caught_up">Alles gelesen</string>
<string name="chats_history_reached_start">Anfang deiner %1$s Nachrichten erreicht</string>
<string name="chats_history_subtitle">%1$s · %2$s · geladen seit %3$s</string>
<string name="chats_history_subtitle_no_date">%1$s · %2$s</string>
<string name="chats_history_waiting">warte auf %1$s</string>
<string name="chats_history_incomplete">Einige Relays haben nicht geantwortet</string>
<string name="chats_history_incomplete_sub">%1$s nicht erreichbar · tippen, um zu sehen welche</string>
@@ -70,6 +73,9 @@
<string name="replying_to">Antworten auf </string>
<!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card -->
<string name="nsite_title">Statische Website: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_kind">nApplet</string>
<string name="nsite_website_kind">nSite</string>
<string name="napplet_card_permissions">Worauf sie zugreifen kann</string>
<string name="nsite_root_site">Stammseite</string>
<string name="nsite_source">Quelle:</string>
@@ -125,6 +131,8 @@
<string name="road_event_traffic_jam">Stau</string>
<string name="road_event_unknown">Straßenereignis</string>
<string name="podcast_value_zap_split_hint">Zaps hierauf werden aufgeteilt zwischen:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_for_value">Value-for-Value</string>
<string name="relay_monitor_rtt_open">Öffnen</string>
<string name="relay_monitor_rtt_read">Lesen</string>
<string name="relay_monitor_rtt_write">Schreiben</string>
@@ -132,6 +140,7 @@
<string name="relay_monitor_relay_type">Typ</string>
<string name="relay_monitor_requirements">Anforderungen</string>
<string name="relay_monitor_supported_nips">Unterstützte NIPs</string>
<string name="relay_monitor_ms">%1$d ms</string>
<string name="relay_discovery_accepted_kinds">Akzeptierte Arten</string>
<string name="relay_discovery_geohash">Standort</string>
<string name="calendar_rsvp_going">Komme</string>
@@ -12,6 +12,7 @@
<string name="login_generate_button">Gerar nova</string>
<string name="login_key_hint">Digite sua chave privada (nsec) ou chave pública (npub)</string>
<string name="login_key_label">nsec, bunker:// ou npub</string>
<string name="login_key_placeholder">nsec1… / bunker://… / npub1…</string>
<string name="login_show_key">Mostrar chave</string>
<string name="login_hide_key">Ocultar chave</string>
<!-- New Key Warning -->
@@ -24,6 +25,7 @@
<string name="action_copy">Copiar</string>
<string name="action_paste">Colar</string>
<string name="action_cancel">Cancelar</string>
<string name="action_ok">OK</string>
<string name="action_save">Salvar</string>
<string name="action_delete">Excluir</string>
<string name="action_share">Compartilhar</string>
@@ -56,16 +58,24 @@
<string name="chats_history_all_caught_up">Tudo em dia</string>
<string name="chats_history_reached_start">Início das suas mensagens %1$s alcançado</string>
<string name="chats_history_subtitle">%1$s · %2$s · carregado desde %3$s</string>
<string name="chats_history_subtitle_no_date">%1$s · %2$s</string>
<string name="chats_history_waiting">aguardando %1$s</string>
<string name="chats_history_incomplete">Alguns relays não responderam</string>
<string name="chats_history_incomplete_sub">%1$s inacessível · toque para ver quais</string>
<string name="chats_history_relays_title">%1$s · histórico por relay</string>
<string name="chats_history_relay_since">desde %1$s</string>
<string name="action_dismiss">Dispensar</string>
<plurals name="chats_history_relays">
<item quantity="one">%1$d Relé</item>
<item quantity="other">%1$d Relés</item>
</plurals>
<!-- Notes & Replies -->
<string name="replying_to">respondendo para </string>
<!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card -->
<string name="nsite_title">Site Estático: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_kind">nApplet</string>
<string name="nsite_website_kind">nSite</string>
<string name="napplet_card_permissions">O que ele pode acessar</string>
<string name="nsite_root_site">Site raiz</string>
<string name="nsite_source">Origem:</string>
@@ -121,6 +131,7 @@
<string name="road_event_traffic_jam">Congestionamento</string>
<string name="road_event_unknown">Evento na via</string>
<string name="podcast_value_zap_split_hint">Os zaps para isto são divididos entre:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_for_value">Valor por Valor</string>
<string name="relay_monitor_rtt_open">Abrir</string>
<string name="relay_monitor_rtt_read">Leitura</string>
@@ -129,6 +140,7 @@
<string name="relay_monitor_relay_type">Tipo</string>
<string name="relay_monitor_requirements">Requisitos</string>
<string name="relay_monitor_supported_nips">NIPs Suportados</string>
<string name="relay_monitor_ms">%1$d ms</string>
<string name="relay_discovery_accepted_kinds">Tipos Aceitos</string>
<string name="relay_discovery_geohash">Localização</string>
<string name="calendar_rsvp_going">Vou</string>
@@ -12,6 +12,7 @@
<string name="login_generate_button">Skapa ny</string>
<string name="login_key_hint">Ange din privata nyckel (nsec) eller publika nyckel (npub)</string>
<string name="login_key_label">nsec, bunker:// eller npub</string>
<string name="login_key_placeholder">nsec1… / bunker://… / npub1…</string>
<string name="login_show_key">Visa nyckel</string>
<string name="login_hide_key">Dölj nyckel</string>
<!-- New Key Warning -->
@@ -24,6 +25,7 @@
<string name="action_copy">Kopiera</string>
<string name="action_paste">Klistra in</string>
<string name="action_cancel">Avbryt</string>
<string name="action_ok">Ok</string>
<string name="action_save">Spara</string>
<string name="action_delete">Radera</string>
<string name="action_share">Dela</string>
@@ -56,6 +58,7 @@
<string name="chats_history_all_caught_up">Allt ikapp</string>
<string name="chats_history_reached_start">Nådde början av dina %1$s-meddelanden</string>
<string name="chats_history_subtitle">%1$s · %2$s · laddat sedan %3$s</string>
<string name="chats_history_subtitle_no_date">%1$s · %2$s</string>
<string name="chats_history_waiting">väntar på %1$s</string>
<string name="chats_history_incomplete">Vissa reläer svarade inte</string>
<string name="chats_history_incomplete_sub">%1$s ej nåbara · tryck för att se vilka</string>
@@ -70,6 +73,9 @@
<string name="replying_to">Svarar till </string>
<!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card -->
<string name="nsite_title">Statisk webbplats: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_kind">nApplet</string>
<string name="nsite_website_kind">nSite</string>
<string name="napplet_card_permissions">Vad det har åtkomst till</string>
<string name="nsite_root_site">Rotplats</string>
<string name="nsite_source">Källa:</string>
@@ -103,6 +109,7 @@
<item quantity="other">%1$s +%2$d till</item>
</plurals>
<string name="ps1_save_title">PS1-minneskortsparning</string>
<string name="ps1_save_block">block %1$d</string>
<string name="ps1_save_empty_slot">Tom plats</string>
<string name="podcast_play_soundbite">Spela höjdpunkt</string>
<string name="road_event_accident">Olycka</string>
@@ -124,6 +131,7 @@
<string name="road_event_traffic_jam">Trafikstockning</string>
<string name="road_event_unknown">Väghändelse</string>
<string name="podcast_value_zap_split_hint">Zaps till detta fördelas mellan:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_for_value">Värde -för-värde</string>
<string name="relay_monitor_rtt_open">Öppna</string>
<string name="relay_monitor_rtt_read">Läs</string>
@@ -132,6 +140,7 @@
<string name="relay_monitor_relay_type">Typ</string>
<string name="relay_monitor_requirements">Krav</string>
<string name="relay_monitor_supported_nips">NIP som stöds</string>
<string name="relay_monitor_ms">%1$d ms</string>
<string name="relay_discovery_accepted_kinds">Accepterade typer</string>
<string name="relay_discovery_geohash">Plats</string>
<string name="calendar_rsvp_going">Kommer</string>
@@ -0,0 +1,129 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.concord
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The account joined a community on another client but has NO offline backup here yet; the
* kind-13302 list only shows up later, delivered by a relay into the addressable cache note.
* `liveCommunities` must then decrypt it and surface the entry so the bottom-bar tab and the
* Concord Channels screen fill in.
*/
class ConcordListLateArrivalTest {
private val signer = NostrSignerInternal(KeyPair("0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray()))
private val alpha = "a".repeat(64)
private fun entry(
id: String,
name: String,
) = ConcordCommunityListEntry(
id = id,
owner = signer.pubKey,
ownerSalt = "1".repeat(64),
root = "2".repeat(64),
rootEpoch = 3,
relays = listOf("ws://127.0.0.1:7777"),
name = name,
addedAt = 1000,
)
/** No offline backup at all — this account never saved a list locally. */
private class NoBackupRepository : ConcordListRepository {
override fun concordList(): ConcordCommunityListEvent? = null
override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) {}
}
/** Hands back one stable addressable note per address so the state and the "relay" share it. */
private class StubCache : ICacheProvider {
private val notes = HashMap<String, AddressableNote>()
override fun getAnyChannel(note: Note): Channel? = null
override fun getUserIfExists(pubkey: HexKey): User? = null
override fun countUsers(predicate: (String, User) -> Boolean): Int = 0
override fun getNoteIfExists(hexKey: HexKey): Note? = null
override fun checkGetOrCreateNote(hexKey: HexKey): Note? = null
override fun getOrCreateAddressableNote(key: Address): AddressableNote = notes.getOrPut(key.toValue()) { AddressableNote(key) }
override fun getEventStream(): ICacheEventStream = error("not used")
override fun hasBeenDeleted(event: Any): Boolean = false
override fun getOrCreateUser(pubkey: HexKey): User? = null
override fun justConsumeMyOwnEvent(event: Event): Boolean = false
}
@Test
fun lateArrivingListDecryptsAndSurfaces() =
runTest {
val state =
ConcordChannelListState(
signer = signer,
cache = StubCache(),
scope = CoroutineScope(Dispatchers.Unconfined),
settings = NoBackupRepository(),
)
// Nothing is known yet: no backup, empty cache note.
assertEquals(emptyList(), state.liveCommunities.value)
// A relay delivers the kind-13302 into the SAME addressable note the state observes.
val event = ConcordCommunityListEvent.create(signer, listOf(entry(alpha, "Alpha")))
val author = User(signer.pubKey) { addr -> Note(addr.toValue()) }
state.concordListNote.loadEvent(event, author, emptyList())
// liveCommunities decrypts on Dispatchers.IO, so give it a beat to settle.
withTimeout(5000) {
while (state.liveCommunities.value.isEmpty()) yield()
}
assertEquals(1, state.liveCommunities.value.size)
assertEquals(alpha, state.liveCommunities.value[0].id)
}
}