From 757f348a46d864c63d59e6ff049ace0cd46917d5 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:04:41 +0000 Subject: [PATCH 01/11] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-cs/strings.xml | 579 ++++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 476 ++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 493 +++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 500 +++++++++++++++ docs/changelog/translators.json | 10 +- 5 files changed, 2054 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index f826000683..f9a50c85e7 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -599,6 +599,13 @@ Zpracovává od Dostupné na + Kategorie + Související + Implementuje + Podporované NIP + prostřednictvím %1$s + NIP-%1$s + Aplikace Web Android iOS @@ -629,6 +636,7 @@ Sdílejte své tréninky Nechte Amethyst číst dokončené tréninky z Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) a navrhnout příspěvek. Připojit + %1$s km Z Health Connect Běh Chůze @@ -642,8 +650,12 @@ Dieta Půst Okruh + EMOM + AMRAP Aplikace Aplikace + nApplety + nSites Zatím nenalezeny žádné nSites. Prohlížeč Obnovit @@ -654,6 +666,9 @@ Otevřít Vymazat Oblíbené + Objevujte webové aplikace + Weby od lidí, které sledujete + Aplikace od lidí, které sledujete Možnosti Odebrat z historie Oblíbené aplikace @@ -679,11 +694,15 @@ Zapomenout tento nApplet Zablokováno Odebrat + Zeptat se pokaždé Zatím nenalezeny žádné nApplets. + nApplet %1$s… + Shell Identita Akce klávesnice + Relaye Úložiště Platby Síť @@ -727,14 +746,106 @@ Tento nApplet chce zaplatit Lightning fakturu za %1$d sats. + Připojit k Nostr + se chce připojit k vašemu účtu Nostr + Jak se mají zpracovávat požadavky této aplikace? + Připojit + Zablokovat a ignorovat %1$s + Plně jí důvěřuji + Automaticky podepisovat všechny požadavky (kromě plateb) + Buďme rozumní + Automaticky schvalovat nejběžnější požadavky + Jsem trochu paranoidní + Nepodepisuj nic, aniž by ses mě zeptal! + chce %1$s + Zobrazit událost + Skrýt událost + Více možností + Méně možností + Povolit jednou + Povolit pro tuto relaci + Povolit na 24 hodin + Povolit na 30 dní + Znovu se neptat na %1$s + Znovu se neptat na žádné požadavky Nostr + Odmítnout + Vždy odmítnout %1$s + Naposledy použito + Vyprší + podepsat událost druhu %1$d + podepsat pro %1$s (druh: %2$d) + zašifrovat zprávu + číst vaše soukromé zprávy + Připojené aplikace + Odvolat všechna oprávnění + Výjimky operací + Zatím se nepřipojily žádné aplikace. + Odvolat všechna oprávnění + Povolit + Zeptat se + Odmítnout + aplikace oprávnění podepisovač napplet nsite webová aplikace důvěra připojené + Úroveň důvěry pro podepisování + Oprávnění + Zapomenout tuto aplikaci + Výjimky pro podepisovací operace + Zatím se nepřipojily žádné aplikace.\n\nAž se webová aplikace připojí k vašemu klíči Nostr, objeví se zde. + Autentizace relay + auth autentizace relay podepsat ověřit nip-42 identita + Kdy se ověřovat + Kam se přihlásit + Vždy se ověřovat + Podepisovat autentizační výzvy pro každý relay, který o to požádá + Nikdy se neověřovat + Ignorovat autentizační výzvy ze všech relayů + Vlastní + Vyberte přesně, ke kterým relayům se přihlásit. Na cokoli, co jste níže nepovolili, budete dotázáni. + Mé relaye a místnosti + Přihlásit se ke svým vlastním relayům a k veřejným chatům, komunitám a živým vysíláním, ke kterým jste se připojili nebo je oblíbili. + Číst příspěvky od lidí, které sleduji + Přihlásit se k relayům sledovaného pro stažení jeho příspěvků. + Zprávy lidem, které sleduji + Přihlásit se k relayům sledovaného pro odesílání DM, odpovědí a upozornění. + Zprávy komukoli + Přihlásit se k relayům cizích lidí pro odesílání DM, odpovědí a upozornění. Ve výchozím nastavení vypnuto; místo toho budete dotázáni pokaždé. + Potvrdit tomuto relay, že jste to vy? + Tento relay si nejprve chce ověřit, že jste to opravdu vy. Jeho provozovatel uvidí, který účet jste. + Odeslat vaši zprávu pro %1$s? + Upozornit %1$s? + Načíst příspěvky z %1$s? + Přispět do %1$s? + Otevřít %1$s? + Pokud tak neučiníte, vaše zpráva pro %1$s nebude doručena. + Pokud tak neučiníte, %1$s o tom nebude upozorněn. + Pokud tak neučiníte, neuvidíte zde příspěvky od %1$s. + Pokud tak neučiníte, vaše zpráva na %1$s nebude odeslána. + Pokud tak neučiníte, neuvidíte zde %1$s. + %1$s a další + Odeslat vaši soukromou zprávu pro: + Upozornit: + Stáhnout příspěvky z: + Přispět do této místnosti + Otevřít tuto místnost + Použít tento relay + Připojit se ke svému vlastnímu relay + Povolit jednou + Vždy povolit tento relay + Vždy doručovat mé zprávy + Zablokovat tento relay + Výjimky pro jednotlivé relaye + Zapomenout + Naposledy použito před %1$s + Zatím zde nic není — na každý relay se vztahuje vaše globální zásada. + Povolit + Odmítnout Zdroj: %1$s v%1$s Stáhnout @@ -901,6 +1012,122 @@ Podcasty Zobrazit epizody Zatím nenalezeny žádné epizody + Upoutávka + Série %1$d + Explicitní + Dokončeno + Prémiový + Podpořte pořad + od %1$s + S%1$d · E%2$d + Ep %1$d + Série %1$d + Video + Přepis + Kapitoly + Value-for-Value + %1$d%% + Zapy na toto se rozdělují mezi: + Moderátoři a hosté + Přehrát ukázku + Nejlepší podporovatelé + + %1$d kapitola + %1$d kapitoly + %1$d kapitoly + %1$d kapitol + + Moderátor + Spolumoderátor + Editor + Ověřený autor + Chyba Value-for-Value + Tento podcast nemá žádné platitelné příjemce hodnoty. + Připojte peněženku Nostr Wallet Connect pro odesílání příjemcům keysend (uzel). + Streamovat sats + %1$d sats/min + Automaticky posílá hodnotu, když posloucháte. + V této relaci odesláno %1$d sats + Připojte peněženku Nostr Wallet Connect nebo debetní peněženku pro streamování sats během poslechu. + Nová epizoda + Upravit epizodu + Publikování… + Přidat obal + Čtvercový obrázek zobrazený u epizody + Název + Název epizody + Shrnutí + Délka (sekundy) + Více podrobností + Série + Epizoda č. + URL videa + URL přepisu + URL kapitol + Témata + čárkami, oddělené, štítky + URL zvuku + https://…/epizoda.mp3 + Zvukový soubor připraven k nahrání + Přidat zvukový soubor + MP3, M4A nebo jiný zvuk + Smazat epizodu + Smazat tuto epizodu? Tuto akci nelze vrátit zpět. + Váš podcast + Přidat obal + Čtvercový obal vašeho pořadu + Název pořadu + Můj podcast + Popis + Autor + Kontaktní e-mail + Web + Kategorie + Technologie, Zprávy + Odkazy na financování + https://… + Jazyk + cs + Autorská práva + Typ pořadu + Epizodický + Seriálový + Explicitní obsah + Pořad kompletní (žádné další epizody) + Uzamčeno (prémiové) + Nová upoutávka + Přidat klip upoutávky + Krátký zvukový nebo video náhled + Název upoutávky + URL média + Váš podcast + Bez názvu + Zatím žádné epizody. Klepnutím na Nová epizoda publikujte svou první. + Vytvořte svůj podcast + Klepnutím upravíte podrobnosti pořadu + Nastavte název, obal a podrobnosti svého pořadu + + %1$d upoutávka + %1$d upoutávky + %1$d upoutávky + %1$d upoutávek + + Přidejte příjemce pro rozdělení příchozích sats podle váhy. Posluchači posílají boost nebo streamují hodnotu do těchto cílů. + Přidat příjemce + Přidat adresu ručně + Přidat uživatele Nostr + Hledat podle jména nebo @handle + Tento uživatel nemá Lightning adresu + Odebrat příjemce + Jméno (volitelné) + Lightning adresa + Uzel (keysend) + Lightning adresa + jmeno@example.com + Veřejný klíč uzlu + 02abc… (33bajtový hex) + Váha + Poplatek %1$d epizoda %1$d epizody @@ -933,6 +1160,10 @@ Soukromé záložky Veřejné záložky + Repozitáře + Vaše git repozitáře v záložkách + Podcasty + Vaše podcasty a epizody v záložkách Přidat do soukromých záložek Přidat do veřejných záložek Odebrat ze soukromých záložek @@ -1482,6 +1713,26 @@ Připojení peněženky Jazyk Motiv + Barva zvýraznění + Hlavní barva používaná napříč tlačítky a odkazy + Fialová + Modrá + Zelená + Oranžová + Červená + Růžová + Písmo + Typ písma používaný v celé aplikaci + Výchozí systémové + Bezpatkové + Patkové + Neproporcionální + Velikost písma + Změna velikosti textu v celé aplikaci + Malá + Normální + Velká + Obrovská Automaticky načítat obrázky/gif Automaticky přehrávat videa Automatické přehrávání videí @@ -1558,6 +1809,118 @@ Veřejné Skupina Nová veřejná nebo soukromá skupina + Skupiny relay + V řadě + Podle relay + Zobrazí každou skupinu jako vlastní konverzaci, promíchanou s vašimi chaty. + Sbalí skupiny každého relay do jednoho řádku, umístěného u jeho nejnovější zprávy. + Zobrazení skupin NIP-29 + Skupiny relay + Skupiny + Zprávy + Vytvořit skupinu + Tento relay neuvádí podporu skupin NIP-29. Skupina zde vytvořená nebude fungovat — její název, členové a zprávy nebudou relayem spravovány. Vyberte relay, který podporuje skupiny založené na relay. + Název skupiny + Téma (volitelné) + Soukromá (čtení jen pro členy) + Pouze na pozvání + Vytvořit + Pozvat lidi + Sdílejte tento kód, aby se lidé mohli připojit k této skupině. + Vytváření pozvánky… + Kód pozvánky zkopírován + Kód pozvánky (pro tuto skupinu na pozvání): + Připojit se ke skupině + Kód pozvánky + Tato skupina je pouze na pozvání. Zadejte kód, který jste dostali. + Připojit se + Požádáno + Správce + Moderátor + Člen + Členové + Udělat správcem + Udělat moderátorem + Odebrat roli + Odebrat ze skupiny + Odebrat %1$s z této skupiny? Ztratí přístup, dokud nebude znovu přidán nebo pozván. + Upravit skupinu + Název skupiny + Téma (volitelné) + Soukromá (čtení jen pro členy) + Pouze na pozvání + Uložit + Členové + Upravit skupinu + Vlákna + Otevřít skupinu + Na tomto relay zatím nejsou žádné skupiny. + Příprava pozvánky… + Soukromá + Pouze na pozvání + Zadejte platnou URL relay (wss://…). + Název + Popis + Obrázek skupiny + Přidat fotku + Změnit fotku + Objevování + Pomozte lidem najít tuto skupinu. Témata a poloha ji zobrazí pod odpovídajícími filtry objevování (pokud je hostitelský relay podporuje). + Témata + bitcoin, nostr, umění + Poloha (geohash) + u0nd + Oprávnění + Soukromá + Zprávy mohou číst pouze členové. + Pouze na pozvání + Lidé musí být pozváni nebo schváleni, aby se mohli připojit. + Přispívání jen pro členy + Zprávy mohou psát pouze členové. + Neuvedená + Skrýt tuto skupinu z veřejného adresáře relay. + Hledání skupin napříč vašimi relayi… + Pro tento filtr zatím nebyly nalezeny žádné skupiny. + Oblíbit tento relay + Zatím žádná vlákna. Založte jedno tlačítkem +. + Nové vlákno + Bez názvu + Název + Napište něco… + Přispět + Najít skupiny + Procházejte skupiny hostované na relay. Vložte adresu relay nebo vyberte některou níže. + Adresa relay + Procházet + Relaye, na kterých jste + Oblíbené relaye + Zatím žádné zprávy + Připojte se k této skupině pro odesílání zpráv. + Tato skupina je pouze na pozvání — k přispívání potřebujete pozvánku. + + %1$d člen + %1$d členové + %1$d člena + %1$d členů + + + %1$d zpráva + %1$d zprávy + %1$d zprávy + %1$d zpráv + + + %1$d+ zpráva + %1$d+ zprávy + %1$d+ zprávy + %1$d+ zpráv + + + %1$d osoba, kterou sledujete + %1$d osoby, které sledujete + %1$d osoby, které sledujete + %1$d osob, které sledujete + Soukromé Pro Předmět @@ -1797,12 +2160,42 @@ Vytvořte si peněženku Cashu pro uchovávání tokenů ecash a příjem nutzapů. Hledám vaši peněženku… 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. + Nastavte si peněženku + Hledání vaší peněženky… + Prohledáváme každý relay a hledáme peněženku Cashu publikovanou pod vaším klíčem Nostr. + Prohledávání relayů… %1$d / %2$d + Ověřování peněženek, které jsme našli… + Žádná peněženka nenalezena + Na žádném relay jsme nenašli existující peněženku Cashu pod vaším klíčem Nostr. Začněte vytvořením nové. + Vytvořit novou peněženku + Našli jsme vaši peněženku + Tato peněženka Cashu je publikována v síti Nostr pod vaším klíčem. Používejte ji na tomto zařízení — znovu ji rozešleme na vaše relaye, aby se příště snadno našla. + Použít tuto peněženku + Našli jsme několik peněženek + Máte v síti více než jednu peněženku Cashu — nejspíše vytvořené různými aplikacemi. Nejnovější se stane vaší hlavní peněženkou; převeďte do ní veškeré prostředky ze starších. + Nejnovější — vaše hlavní peněženka + Starší peněženka + Nastavit jako hlavní peněženku + Obnovit prostředky do hlavní peněženky + %1$s sats k obnovení + Obnoveno %1$s sats + Žádné obnovitelné prostředky + Tuto peněženku nelze přečíst + Nejprve nastavte hlavní peněženku, poté obnovte prostředky ze starších. + Nastavování vaší peněženky… + Hledat znovu + Mincovny: %1$s + Peněženka vytvořena + Nyní vyberte několik mincoven, které budou hostit vaše sats. + Vyberte mincovny Zůstatek Minty URL mintu Odebrat mint Přidat mint Historie + Vaše peněženka se ukládá automaticky, jak přidáváte nebo odebíráte mincovny. Klíč nutzap se pro vás vytvoří při prvním přidání mincovny. + Ukládání… Klíč pro nutzap (pokročilé) Samostatný soukromý klíč používaný pouze k příjmu nutzapů NIP-61. Není to klíč vaší identity Nostr. Vygenerovat nový klíč @@ -2158,6 +2551,7 @@ Doporučení aplikací Kanál přijatých zapů Kanál sledujících + Bitcoin (on-chain) peněženka Nastavení reakcí Nastavte, která tlačítka reakcí se zobrazují, jejich pořadí a zda se mají zobrazovat počty. Povoleno @@ -2306,6 +2700,10 @@ Rozštěpováno od Internet: Klon: + Větev + Commit + Základ sloučení + Pull request byl aktualizován na nový commit. Otevřeno Sloučeno Uzavřeno @@ -2313,12 +2711,86 @@ Přehled Úkoly Patche a PR + Otevřené + Uzavřené a vyřešené + Vše + Přidat repozitář do záložek + Větve + Značky + Soubory + Aktualizováno + Nedávná aktivita + Spravuje + Hostováno externě + Tento repozitář nelze klonovat přes http(s), takže zde není zobrazení kódu k dispozici. + Otevřít v prohlížeči + Bez názvu O projektu Odkazy Správci Témata Osobní fork + Readme + Kód + Načítání repozitáře… + Repozitář se nepodařilo načíst z jeho klonovací URL. + Toto oznámení repozitáře neobsahuje žádnou http(s) klonovací URL k procházení. + Tento repozitář nemá soubor README. + Tento soubor se nepodařilo načíst. + Binární soubor (%1$s) — náhled není k dispozici. + Tato složka je prázdná. + kořen + Zkusit znovu + výchozí + Větve + Značky + Hledat soubory… + Žádné odpovídající soubory. + Commity + Není k dispozici žádná historie commitů. + Kopírovat obsah souboru + Text + + %1$d položka + %1$d položky + %1$d položky + %1$d položek + + + %1$d soubor změněn + %1$d soubory změněny + %1$d souboru změněno + %1$d souborů změněno + + Binární soubor se nezobrazuje. + Nový + Nový problém + Název + Popis + Vytvořit + Zrušit + Štítky + bug, vylepšení… + Uzavřít problém + Znovu otevřít + Uzavřít + Znovu otevřít + Označit jako sloučené + Zobrazit změny + Načítání změn… + Nebyly nalezeny žádné změny souborů. + Zkusit načíst změny znovu + Upraveno + Upravit repozitář + Název + Popis + Klonovací URL (jedna na řádek) + Webové URL (jedna na řádek) + Témata (oddělená čárkami) + Uložit + Git repozitáře Statický web: %1$s + nApplet: %1$s Oprávnění: Aplikace & Weby Kořenový web @@ -2607,6 +3079,8 @@ Git záplata Git repozitář Git odpověď + Pull request + Aktualizace PR Cíle zapů Sledované hashtagy Zvýraznění @@ -2907,6 +3381,7 @@ %1$s financováno z %2$s sats cíle Končí %1$s On-chain dar + Detekce ptáků Birdex · %1$d druh Birdex · %1$d druhy @@ -2920,6 +3395,9 @@ %1$s +%2$d dalších + Uložení na paměťovou kartu PS1 + blok %1$d + Prázdný slot Policie Rychlostní radar @@ -3048,6 +3526,105 @@ Nastavení editoru Automaticky vytvářet koncepty Automaticky uloží koncept události, když píšeš nebo opouštíš editor s neodeslaným textem, a odešle ho na tvé soukromé odchozí relaye. + Podpis + Přidá se na konec zprávy při otevírání nového příspěvku, odpovědi, citace nebo článku. Ponechte prázdné pro vypnutí. + Váš podpis + Proof of Work + Obtížnost + Vytěží do vašich příspěvků NIP-13 proof of work před publikováním, aby je relaye a čtenáři mohli zvážit oproti spamu. Vyšší hodnoty trvají exponenciálně déle. Příspěvky se po vytěžení publikují na pozadí. + Vypnuto + Vlastní obtížnost + Jemné doladění cíle v počtu úvodních nulových bitů. + Co těžit + Časově kritické události (autentizace relay, žádosti o zap, zprávy peněženky a podepisovače, koncepty, seznamy) se nikdy netěží. + Krátké poznámky a odpovědi + Hlavní veřejná plocha pro spam + Komentáře + Odpovědi na články, soubory a jiný obsah + Nahlášení + Relaye váží nahlášení podle jejich nákladů + Dlouhé texty a zvýraznění + Články a zvýraznění; zřídkavé, náklady jsou zanedbatelné + Hlasové zprávy + Veřejné hlasové příspěvky a odpovědi + Přeposlání + Vysoký objem pro aktivní uživatele + Reakce + Nejobjemnější druh; těžení každého lajku stojí baterii + Veřejný a živý chat + Prodlevy při těžení narušují plynulost konverzace + Obálky soukromých zpráv + Umožňuje DM relayům filtrovat spam ve schránce; těží se pouze vnější obálka, nikdy vaše zpráva + Jiný veřejný obsah + Ankety, živé statusy, inzeráty a vše ostatní veřejné + Těžení proof of work + + Těžení proof of work… (%1$d příspěvek ve frontě) + Těžení proof of work… (%1$d příspěvky ve frontě) + Těžení proof of work… (%1$d příspěvku ve frontě) + Těžení proof of work… (%1$d příspěvků ve frontě) + + + %1$s • těžení na %2$d bitu + %1$s • těžení na %2$d bitech + %1$s • těžení na %2$d bitu + %1$s • těžení na %2$d bitech + + + %1$s • čeká na těžbu na %2$d bitu + %1$s • čeká na těžbu na %2$d bitech + %1$s • čeká na těžbu na %2$d bitu + %1$s • čeká na těžbu na %2$d bitech + + + Výchozí (%1$d bit) + Výchozí (%1$d bity) + Výchozí (%1$d bitu) + Výchozí (%1$d bitů) + + Výchozí (vypnuto) + Vypnuto pro tento příspěvek + + %1$d bit + %1$d bity + %1$d bitu + %1$d bitů + + Těžení proof of work + Zobrazuje příspěvky, které stále těží svůj NIP-13 proof of work, aby mohly dokončit těžbu i po opuštění aplikace. + Zrušit + ≈ %1$s na příspěvek na tomto zařízení + <1 s + + %1$d sekunda + %1$d sekundy + %1$d sekundy + %1$d sekund + + + %1$d minuta + %1$d minuty + %1$d minuty + %1$d minut + + + %1$d hodina + %1$d hodiny + %1$d hodiny + %1$d hodin + + + %1$d den + %1$d dny + %1$d dne + %1$d dní + + ≈ %1$s zbývá + každou chvíli + Váš %1$s dokončil těžbu, ale nepodařilo se jej publikovat: %2$s + Váš %1$s dokončil těžbu, ale nepodařilo se jej publikovat. Bude to zkuseno znovu při příštím spuštění aplikace. + Chatová zpráva + Nahlášení Použít toto Zavřít Opravit @@ -3121,6 +3698,7 @@ Bude vytvořena nová MLS skupina. Členy můžete přidat poté. Skupina Marmot Skupina %1$s… + %1$s… Známé Známé (%1$d) Nové žádosti @@ -3185,6 +3763,7 @@ Odebrat administrátorská práva \"%1$s\"? Zůstane členem skupiny. Administrátorská práva odebrána Nepodařilo se odebrat administrátorská práva: %1$s + Relaye zatím žádné eventy poslední event%1$s Nejste členem této skupiny diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 1f23f55b23..090be920d0 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -586,6 +586,10 @@ von Verfügbar auf Kategorien + Verwandt + Implementiert + Unterstützte NIPs + über %1$s Web Android iOS @@ -628,6 +632,7 @@ Meditation Diät Fasten + Zirkel Apps Apps Noch keine nSites gefunden. @@ -640,6 +645,9 @@ Öffnen Löschen Favoriten + Web-Apps entdecken + Sites von Personen, denen du folgst + Apps von Personen, denen du folgst Optionen Aus Verlauf entfernen Lieblings-Apps @@ -665,6 +673,7 @@ Dieses nApplet vergessen Blockiert Widerrufen + Mich jedes Mal fragen Noch keine nApplets gefunden. @@ -711,14 +720,106 @@ Dieses nApplet möchte eine Lightning-Rechnung über %1$d sats bezahlen. + Mit Nostr verbinden + möchte sich mit deinem Nostr-Konto verbinden + Wie sollen die Anfragen dieser App behandelt werden? + Verbinden + %1$s blockieren und ignorieren + Ich vertraue ihr voll + Alle Anfragen automatisch signieren (außer Zahlungen) + Bleiben wir vernünftig + Die häufigsten Anfragen automatisch genehmigen + Ich bin etwas paranoid + Nichts ohne Nachfrage signieren! + möchte %1$s + Ereignis anzeigen + Ereignis ausblenden + Mehr Optionen + Weniger Optionen + Einmal erlauben + Für diese Sitzung erlauben + Für 24 Stunden erlauben + Für 30 Tage erlauben + Nicht mehr fragen für %1$s + Bei keiner Nostr-Anfrage mehr fragen + Verweigern + %1$s immer verweigern + Zuletzt verwendet + Läuft ab + Ereignis vom Typ %1$d signieren + für %1$s signieren (Typ: %2$d) + eine Nachricht verschlüsseln + deine privaten Nachrichten lesen + Verbundene Apps + Alle Berechtigungen widerrufen + Vorgangs-Ausnahmen + Bisher haben sich keine Apps verbunden. + Alle Berechtigungen widerrufen + Erlauben + Fragen + Verweigern + apps berechtigungen signierer napplet nsite webapp vertrauen verbunden + Signierungs-Vertrauensstufe + Berechtigungen + Diese App vergessen + Signierungs-Ausnahmen + Bisher haben sich keine Apps verbunden.\n\nWenn sich eine Web-App mit deinem Nostr-Schlüssel verbindet, erscheint sie hier. + Relay-Authentifizierung + auth authentifizierung relay signieren verifizieren nip-42 identität + Wann authentifiziert wird + Wo du dich anmelden willst + Immer authentifizieren + Auth-Challenges für jedes Relay signieren, das sie anfordert + Nie authentifizieren + Auth-Challenges von allen Relays ignorieren + Benutzerdefiniert + Wähle genau, bei welchen Relays du dich anmeldest. Bei allem, was du unten nicht erlaubt hast, wirst du gefragt. + Meine Relays und Räume + Bei deinen eigenen Relays und bei öffentlichen Chats, Communitys und Livestreams anmelden, denen du beigetreten bist oder die du favorisiert hast. + Beiträge von Personen lesen, denen ich folge + Bei den Relays einer gefolgten Person anmelden, um deren Beiträge herunterzuladen. + Personen, denen ich folge, Nachrichten senden + Bei den Relays einer gefolgten Person anmelden, um DMs, Antworten und Benachrichtigungen zu senden. + Jedem Nachrichten senden + Bei den Relays von Fremden anmelden, um DMs, Antworten und Benachrichtigungen zu senden. Standardmäßig aus; stattdessen wirst du jedes Mal gefragt. + Diesem Relay bestätigen, dass du es bist? + Dieses Relay möchte zuerst bestätigen, dass du es wirklich bist. Sein Betreiber sieht, welches Konto du bist. + Deine Nachricht an %1$s senden? + %1$s benachrichtigen? + Beiträge von %1$s laden? + In %1$s posten? + %1$s öffnen? + Wenn nicht, wird deine Nachricht an %1$s nicht zugestellt. + Wenn nicht, wird %1$s nicht darüber benachrichtigt. + Wenn nicht, siehst du hier keine Beiträge von %1$s. + Wenn nicht, wird deine Nachricht an %1$s nicht gepostet. + Wenn nicht, siehst du %1$s hier nicht. + %1$s und andere + Deine private Nachricht senden an: + Benachrichtigen: + Beiträge herunterladen von: + In diesem Raum posten + Diesen Raum öffnen + Dieses Relay verwenden + Mit deinem eigenen Relay verbinden + Einmal erlauben + Dieses Relay immer erlauben + Meine Nachrichten immer zustellen + Dieses Relay blockieren + Relay-spezifische Ausnahmen + Vergessen + Zuletzt verwendet vor %1$s + Hier ist noch nichts — deine globale Richtlinie gilt für jedes Relay. + Erlauben + Verweigern Quelle: %1$s v%1$s Herunterladen @@ -883,6 +984,103 @@ Podcasts Folgen ansehen Noch keine Folgen gefunden + Staffel %1$d + Anstößig + Abgeschlossen + Die Show unterstützen + von %1$s + Staffel %1$d + Transkript + Kapitel + Zaps hierauf werden aufgeteilt zwischen: + Hosts & Gäste + Highlight abspielen + Top-Unterstützer + + %1$d Kapitel + %1$d Kapitel + + Co-Host + Redakteur + Verifizierter Autor + Value-for-Value-Fehler + Dieser Podcast hat keine zahlbaren Value-Empfänger. + Verbinde eine Nostr-Wallet-Connect-Wallet, um an Keysend-(Node-)Empfänger zu senden. + Sats streamen + %1$d Sats/Min + Sendet automatisch Value, während du zuhörst. + %1$d Sats in dieser Sitzung gestreamt + Verbinde eine Nostr-Wallet-Connect- oder Debit-Wallet, um Sats beim Zuhören zu streamen. + Neue Episode + Episode bearbeiten + Wird veröffentlicht… + Cover hinzufügen + Quadratisches Bild, das für die Episode angezeigt wird + Titel + Episodentitel + Zusammenfassung + Dauer (Sekunden) + Weitere Details + Staffel + Video-URL + Transkript-URL + Kapitel-URL + Themen + durch, Komma, getrennte, Tags + Audio-URL + Audiodatei bereit zum Hochladen + Audiodatei hinzufügen + MP3, M4A oder anderes Audio + Episode löschen + Diese Episode löschen? Das kann nicht rückgängig gemacht werden. + Dein Podcast + Cover hinzufügen + Quadratisches Cover für deine Show + Show-Titel + Mein Podcast + Beschreibung + Autor + Kontakt-E-Mail + Kategorien + Technik, Nachrichten + Finanzierungslinks + Sprache + de + Show-Typ + Episodisch + Serie + Anstößige Inhalte + Show abgeschlossen (keine weiteren Episoden) + Gesperrt (Premium) + Neuer Trailer + Trailer-Clip hinzufügen + Kurze Audio- oder Videovorschau + Trailer-Titel + Medien-URL + Dein Podcast + Ohne Titel + Noch keine Episoden. Tippe auf Neue Episode, um deine erste zu veröffentlichen. + Erstelle deinen Podcast + Tippen, um Show-Details zu bearbeiten + Lege Titel, Cover und Details deiner Show fest + + %1$d Trailer + %1$d Trailer + + Füge Empfänger hinzu, um eingehende Sats nach Gewichtung aufzuteilen. Hörer boosten oder streamen Value an diese Ziele. + Empfänger hinzufügen + Adresse manuell hinzufügen + Nostr-Nutzer hinzufügen + Nach Name oder @Handle suchen + Dieser Nutzer hat keine Lightning-Adresse + Empfänger entfernen + Lightning-Adresse + Node (Keysend) + Lightning-Adresse + Node-Pubkey + 02abc… (33-Byte-Hex) + Gewichtung + Gebühr %1$d Folge %1$d Folgen @@ -911,6 +1109,9 @@ Private Lesezeichen Öffentliche Lesezeichen + Repositorys + Deine gespeicherten Git-Repositorys + Deine gespeicherten Podcasts und Episoden Zu den privaten Lesezeichen hinzufügen Zu den öffentlichen Lesezeichen hinzufügen Aus den privaten Lesezeichen entfernen @@ -1456,6 +1657,21 @@ Wallet Verbindung Sprache Design + Akzentfarbe + Hauptfarbe für Schaltflächen und Links + Lila + Blau + Grün + Rot + Schriftart + Schrift, die in der gesamten App verwendet wird + Systemstandard + Serifenlos + Schriftgröße + Skaliert die Textgröße in der gesamten App + Klein + Groß + Riesig Bilder/GIFs automatisch laden Videos automatisch abspielen Videos automatisch abspielen @@ -1528,6 +1744,104 @@ Öffentlich Gruppe Neue öffentliche oder private Gruppe + Relay-Gruppen + Nach Relay + Zeigt jede Gruppe als eigene Unterhaltung, gemischt mit deinen Chats. + Fasst die Gruppen jedes Relays in einer einzigen Zeile zusammen, platziert bei seiner neuesten Nachricht. + NIP-29-Gruppenanzeige + Relay-Gruppen + Gruppen + Nachrichten + Eine Gruppe erstellen + Dieses Relay wirbt nicht mit Unterstützung für NIP-29-Relay-Gruppen. Eine hier erstellte Gruppe funktioniert nicht — ihr Name, ihre Mitglieder und Nachrichten werden nicht vom Relay verwaltet. Wähle ein Relay, das relaybasierte Gruppen unterstützt. + Gruppenname + Thema (optional) + Privat (nur Mitglieder lesen) + Nur mit Einladung + Erstellen + Personen einladen + Teile diesen Code, damit Personen dieser Gruppe beitreten können. + Einladung wird erstellt… + Einladungscode kopiert + Einladungscode (für diese Gruppe nur mit Einladung): + Gruppe beitreten + Einladungscode + Diese Gruppe ist nur mit Einladung. Gib den Code ein, den du erhalten hast. + Beitreten + Angefragt + Mitglied + Mitglieder + Zum Admin machen + Zum Moderator machen + Rolle entfernen + Aus Gruppe entfernen + %1$s aus dieser Gruppe entfernen? Sie verlieren den Zugriff, bis sie erneut hinzugefügt oder eingeladen werden. + Gruppe bearbeiten + Gruppenname + Thema (optional) + Privat (nur Mitglieder lesen) + Nur mit Einladung + Speichern + Mitglieder + Gruppe bearbeiten + Gruppe öffnen + Noch keine Gruppen auf diesem Relay. + Einladung wird vorbereitet… + Privat + Nur mit Einladung + Gib eine gültige Relay-URL ein (wss://…). + Beschreibung + Gruppenbild + Foto hinzufügen + Foto ändern + Entdeckung + Hilf Personen, diese Gruppe zu finden. Themen und ein Ort lassen sie unter den passenden Entdeckungsfiltern erscheinen (falls das Host-Relay sie unterstützt). + Themen + bitcoin, nostr, kunst + Ort (Geohash) + Berechtigungen + Privat + Nur Mitglieder können Nachrichten lesen. + Nur mit Einladung + Personen müssen eingeladen oder genehmigt werden, um beizutreten. + Nur Mitglieder posten + Nur Mitglieder können Nachrichten posten. + Nicht gelistet + Diese Gruppe aus dem öffentlichen Verzeichnis des Relays ausblenden. + Es wird nach Gruppen über deine Relays gesucht… + Noch keine Gruppen für diesen Filter gefunden. + Dieses Relay favorisieren + Noch keine Threads. Starte einen mit der +-Schaltfläche. + Neuer Thread + Ohne Titel + Titel + Schreibe etwas… + Posten + Gruppen finden + Durchsuche die auf einem Relay gehosteten Gruppen. Füge eine Relay-Adresse ein oder wähle unten eine aus. + Relay-Adresse + Durchsuchen + Relays, auf denen du bist + Beliebte Relays + Noch keine Nachrichten + Tritt dieser Gruppe bei, um Nachrichten zu senden. + Diese Gruppe ist nur mit Einladung — du brauchst eine Einladung, um zu posten. + + %1$d Mitglied + %1$d Mitglieder + + + %1$d Nachricht + %1$d Nachrichten + + + %1$d+ Nachricht + %1$d+ Nachrichten + + + %1$d Person, der du folgst + %1$d Personen, denen du folgst + Privat An Betreff @@ -1767,12 +2081,41 @@ Erstelle eine Cashu-Wallet, um Ecash-Tokens zu halten und Nutzaps zu empfangen. Suche nach deiner Wallet… 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. + Richte deine Wallet ein + Deine Wallet wird gesucht… + Wir durchsuchen jedes Relay nach einer Cashu-Wallet, die unter deinem Nostr-Schlüssel veröffentlicht wurde. + Relays werden durchsucht… %1$d / %2$d + Die gefundenen Wallets werden überprüft… + Keine Wallet gefunden + Wir konnten auf keinem Relay eine bestehende Cashu-Wallet unter deinem Nostr-Schlüssel finden. Erstelle eine neue, um zu beginnen. + Neue Wallet erstellen + Wir haben deine Wallet gefunden + Diese Cashu-Wallet ist im Nostr-Netzwerk unter deinem Schlüssel veröffentlicht. Nutze sie auf diesem Gerät — wir senden sie erneut an deine Relays, damit sie beim nächsten Mal leicht zu finden ist. + Diese Wallet verwenden + Wir haben mehrere Wallets gefunden + Du hast mehr als eine Cashu-Wallet im Netzwerk — höchstwahrscheinlich von verschiedenen Apps erstellt. Die neueste wird zu deiner Haupt-Wallet; übertrage alle Guthaben aus den älteren in sie. + Neueste — deine Haupt-Wallet + Ältere Wallet + Als Haupt-Wallet festlegen + Guthaben in Haupt-Wallet übertragen + %1$s Sats wiederherstellbar + %1$s Sats wiederhergestellt + Keine wiederherstellbaren Guthaben + Diese Wallet konnte nicht gelesen werden + Lege zuerst deine Haupt-Wallet fest, übertrage dann die Guthaben aus den älteren. + Deine Wallet wird eingerichtet… + Erneut suchen + Wallet erstellt + Wähle nun ein paar Mints, die deine Sats verwahren. + Mints auswählen Guthaben Mints Mint-URL Mint entfernen Mint hinzufügen Verlauf + Deine Wallet wird automatisch gespeichert, während du Mints hinzufügst oder entfernst. Ein Nutzap-Schlüssel wird für dich erstellt, sobald du das erste Mint hinzufügst. + Wird gespeichert… Nutzap-Schlüssel (erweitert) Ein separater privater Schlüssel, der nur zum Empfangen von NIP-61-Nutzaps dient. Nicht dein Nostr-Identitätsschlüssel. Neuen Schlüssel erzeugen @@ -2106,6 +2449,7 @@ App-Empfehlungen Erhaltene-Zaps-Feed Follower-Feed + Bitcoin-(On-Chain-)Wallet Reaktions-Einstellungen Konfigurieren Sie, welche Reaktionsschaltflächen angezeigt werden, ihre Reihenfolge und ob Zähler angezeigt werden sollen. Aktiviert @@ -2254,6 +2598,8 @@ Geforkt von Internet: Klonen: + Merge-Basis + Der Pull-Request wurde auf einen neuen Commit aktualisiert. Offen Zusammengeführt Geschlossen @@ -2261,8 +2607,18 @@ Übersicht Tickets Patches und PRs + Offen + Geschlossen & erledigt + Alle + Repository als Lesezeichen speichern Dateien + Aktualisiert + Letzte Aktivität + Betreut von + Extern gehostet + Dieses Repository kann nicht über http(s) geklont werden, daher ist die Code-Ansicht hier nicht verfügbar. Im Browser öffnen + Ohne Titel Über Links Maintainer @@ -2270,18 +2626,55 @@ Persönlicher Fork Readme Code + Repository wird geladen… + Das Repository konnte nicht von seiner Klon-URL geladen werden. + Diese Repository-Ankündigung enthält keine http(s)-Klon-URL zum Durchsuchen. + Dieses Repository hat keine README-Datei. + Diese Datei konnte nicht geladen werden. + Binärdatei (%1$s) — keine Vorschau verfügbar. + Dieser Ordner ist leer. Root + Erneut versuchen default Branches + Dateien durchsuchen… + Keine passenden Dateien. Commits Kein Commit-Verlauf verfügbar. + Dateiinhalt kopieren Text + + %1$d Element + %1$d Elemente + Datei geändert Dateien geändert + Binärdatei wird nicht angezeigt. + Neu + Neues Issue + Titel + Beschreibung + Erstellen + Abbrechen + Bug, Verbesserung… + Issue schließen + Wieder öffnen + Schließen + Wieder öffnen + Als gemergt markieren + Änderungen ansehen + Änderungen werden geladen… + Keine Dateiänderungen gefunden. + Änderungen erneut laden + Überarbeitet + Repository bearbeiten Name Beschreibung + Klon-URLs (eine pro Zeile) + Web-URLs (eine pro Zeile) + Themen (durch Komma getrennt) Speichern Git Repositories Statische Website: %1$s @@ -2856,6 +3249,7 @@ %1$s finanziert von %2$s Sats Ziel Endet %1$s On-Chain-Spende + Vogelerkennung Birdex · %1$d Art Birdex · %1$d Arten @@ -2865,6 +3259,9 @@ %1$s +%2$d weitere + PS1-Memory-Card-Spielstand + Block %1$d + Leerer Slot Polizei Blitzer @@ -2993,6 +3390,85 @@ Editor-Einstellungen Entwürfe automatisch erstellen Speichert automatisch ein Entwurfsereignis, wenn du tippst oder den Editor mit ungesendetem Text verlässt, und sendet es an deine privaten ausgehenden Relays. + Signatur + Wird am Ende der Nachricht angefügt, wenn du einen neuen Beitrag, eine Antwort, ein Zitat oder einen Artikel öffnest. Leer lassen, um zu deaktivieren. + Deine Signatur + Arbeitsnachweis + Schwierigkeit + Schürft vor der Veröffentlichung einen NIP-13-Arbeitsnachweis in deine Beiträge, damit Relays und Leser sie gegen Spam gewichten können. Höhere Werte dauern exponentiell länger zum Schürfen. Beiträge werden nach dem Schürfen im Hintergrund veröffentlicht. + Aus + Benutzerdefinierte Schwierigkeit + Feinabstimmung des Ziels in führenden Nullbits. + Was geschürft wird + Zeitkritische Ereignisse (Relay-Authentifizierung, Zap-Anfragen, Wallet- und Signierer-Nachrichten, Entwürfe, Listen) werden nie gemint. + Kurze Notizen & Antworten + Die primäre öffentliche Spam-Fläche + Kommentare + Antworten auf Artikel, Dateien und andere Inhalte + Meldungen + Relays gewichten Meldungen nach ihren Kosten + Langform & Highlights + Artikel und Highlights; selten, die Kosten sind vernachlässigbar + Sprachnachrichten + Öffentliche Sprachbeiträge und -antworten + Hohes Volumen bei aktiven Nutzern + Reaktionen + Häufigster Typ; jedes Like zu schürfen kostet Akku + Öffentlicher & Live-Chat + Schürf-Verzögerungen stören den Gesprächsfluss + Verpackungen privater Nachrichten + Ermöglicht DM-Relays das Filtern von Posteingangs-Spam; nur die äußere Verpackung wird gemint, nie deine Nachricht + Andere öffentliche Inhalte + Umfragen, Live-Status, Kleinanzeigen und alles andere Öffentliche + Arbeitsnachweis wird geschürft + + Arbeitsnachweis wird geschürft… (%1$d Beitrag in der Warteschlange) + Arbeitsnachweis wird geschürft… (%1$d Beiträge in der Warteschlange) + + + %1$s • schürft bei %2$d Bit + %1$s • schürft bei %2$d Bits + + + %1$s • wartet auf Schürfen bei %2$d Bit + %1$s • wartet auf Schürfen bei %2$d Bits + + + Standard (%1$d Bit) + Standard (%1$d Bits) + + Standard (aus) + Für diesen Beitrag aus + + %1$d Bit + %1$d Bits + + Arbeitsnachweis-Schürfen + Zeigt Beiträge an, die noch ihren NIP-13-Arbeitsnachweis schürfen, damit sie fertig werden können, nachdem du die App verlässt. + Abbrechen + ≈ %1$s pro Beitrag auf diesem Gerät + + %1$d Sekunde + %1$d Sekunden + + + %1$d Minute + %1$d Minuten + + + %1$d Stunde + %1$d Stunden + + + %1$d Tag + %1$d Tage + + ≈ %1$s verbleibend + jeden Moment + Dein %1$s wurde fertig geschürft, konnte aber nicht veröffentlicht werden: %2$s + Dein %1$s wurde fertig geschürft, konnte aber nicht veröffentlicht werden. Es wird beim nächsten Start der App erneut versucht. + Chat-Nachricht + Meldung Verwenden Schließen Korrigieren diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index b12e17d4b0..2a7f29143b 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -583,6 +583,10 @@ Manipula por Disponível em + Categorias + Relacionados + Implementa + NIPs suportados Web Android iOS @@ -637,6 +641,9 @@ Abrir Limpar Favoritos + Descobrir web apps + Sites de pessoas que você segue + Apps de pessoas que você segue Opções Remover do histórico Apps favoritos @@ -662,6 +669,7 @@ Esquecer este nApplet Bloqueado Revogar + Perguntar sempre Nenhum nApplet encontrado ainda. @@ -707,14 +715,106 @@ Este nApplet quer pagar uma fatura Lightning de %1$d sats. + Conectar ao Nostr + quer se conectar à sua conta Nostr + Como as solicitações deste app devem ser tratadas? + Conectar + Bloquear e ignorar %1$s + Confio totalmente + Assinar automaticamente todas as solicitações (exceto pagamentos) + Vamos ser razoáveis + Aprovar automaticamente as solicitações mais comuns + Sou um pouco paranoico + Não assine nada sem me perguntar! + quer %1$s + Mostrar evento + Ocultar evento + Mais opções + Menos opções + Permitir uma vez + Permitir nesta sessão + Permitir por 24 horas + Permitir por 30 dias + Não perguntar novamente para %1$s + Não perguntar novamente para nenhuma solicitação Nostr + Negar + Sempre negar %1$s + Usado por último + Expira + assinar evento kind %1$d + assinar para %1$s (kind: %2$d) + criptografar uma mensagem + ler suas mensagens privadas + Apps conectados + Revogar todas as permissões + Substituições de operação + Nenhum app se conectou ainda. + Revogar todas as permissões + Permitir + Perguntar + Negar + apps permissões assinador napplet nsite webapp confiança conectados + Nível de confiança para assinatura + Capacidades + Esquecer este app + Substituições de operação de assinatura + Nenhum app se conectou ainda.\n\nQuando um web app se conectar à sua chave Nostr, ele aparecerá aqui. + Autenticação de relay + auth autenticação relay assinar verificar nip-42 identidade + Quando autenticar + Em qual fazer login + Sempre autenticar + Assinar desafios de autenticação de todos os relays que solicitarem + Nunca autenticar + Ignorar desafios de autenticação de todos os relays + Personalizado + Escolha exatamente em quais relays fazer login. Você será perguntado sobre qualquer um que não tenha permitido abaixo. + Meus relays e salas + Fazer login nos seus próprios relays e em chats públicos, comunidades e transmissões ao vivo que você entrou ou favoritou. + Ler publicações de pessoas que sigo + Fazer login nos relays de quem você segue para baixar as publicações deles. + Enviar mensagem a pessoas que sigo + Fazer login nos relays de quem você segue para enviar DMs, respostas e notificações. + Enviar mensagem a qualquer pessoa + Fazer login nos relays de desconhecidos para enviar DMs, respostas e notificações. Desativado por padrão; em vez disso, você será perguntado a cada vez. + Confirmar que é você para este relay? + Este relay quer confirmar que é realmente você primeiro. Seu operador verá qual conta você é. + Enviar sua mensagem para %1$s? + Notificar %1$s? + Carregar publicações de %1$s? + Publicar em %1$s? + Abrir %1$s? + Se não fizer isso, sua mensagem para %1$s não será entregue. + Se não fizer isso, %1$s não será notificado sobre isto. + Se não fizer isso, você não verá publicações de %1$s aqui. + Se não fizer isso, sua mensagem para %1$s não será publicada. + Se não fizer isso, você não verá %1$s aqui. + %1$s e outros + Enviar sua mensagem privada para: + Notificar: + Baixar publicações de: + Publicar nesta sala + Abrir esta sala + Usar este relay + Conectar ao seu próprio relay + Permitir uma vez + Sempre permitir este relay + Sempre entregar minhas mensagens + Bloquear este relay + Substituições por relay + Esquecer + Usado por último há %1$s + Nada aqui ainda — sua política global se aplica a todos os relays. + Permitir + Negar Fonte: %1$s v%1$s Baixar @@ -879,6 +979,105 @@ Podcasts Ver episódios Nenhum episódio encontrado ainda + Temporada %1$d + Explícito + Concluído + Apoiar o programa + por %1$s + T%1$d · E%2$d + Temporada %1$d + Vídeo + Transcrição + Capítulos + Os zaps para isto são divididos entre: + Apresentadores & Convidados + Reproduzir destaque + Principais apoiadores + + %1$d capítulo + %1$d capítulos + + Apresentador + Coapresentador + Autor verificado + Erro de Value-for-Value + Este podcast não tem destinatários de valor pagáveis. + Conecte uma carteira Nostr Wallet Connect para enviar a destinatários keysend (nó). + Transmitir sats + Envia valor automaticamente enquanto você ouve. + %1$d sats transmitidos nesta sessão + Conecte uma carteira Nostr Wallet Connect ou de débito para transmitir sats enquanto ouve. + Novo episódio + Editar episódio + Publicando… + Adicionar capa + Imagem quadrada exibida para o episódio + Título + Título do episódio + Resumo + Duração (segundos) + Mais detalhes + Temporada + Episódio nº + URL do vídeo + URL da transcrição + URL dos capítulos + Tópicos + tags, separadas, por vírgula + URL do áudio + https://…/episodio.mp3 + Arquivo de áudio pronto para upload + Adicionar arquivo de áudio + MP3, M4A ou outro áudio + Excluir episódio + Excluir este episódio? Isso não pode ser desfeito. + Seu podcast + Adicionar capa + Arte quadrada para o seu programa + Título do programa + Meu Podcast + Descrição + Autor + E-mail de contato + Site + Categorias + Tecnologia, Notícias + Links de financiamento + Idioma + pt + Direitos autorais + Tipo de programa + Episódico + Conteúdo explícito + Programa concluído (sem mais episódios) + Bloqueado (premium) + Novo trailer + Adicionar clipe de trailer + Prévia curta de áudio ou vídeo + Título do trailer + URL da mídia + Seu podcast + Sem título + Nenhum episódio ainda. Toque em Novo episódio para publicar o primeiro. + Crie seu podcast + Toque para editar os detalhes do programa + Configure o título, a arte e os detalhes do seu programa + Adicione destinatários para dividir os sats recebidos por peso. Os ouvintes impulsionam ou transmitem valor para esses destinos. + Adicionar destinatário + Adicionar endereço manualmente + Adicionar um usuário Nostr + Pesquise por nome ou @handle + Este usuário não tem endereço Lightning + Remover destinatário + Nome (opcional) + Endereço Lightning + Nó (keysend) + Endereço Lightning + nome@exemplo.com + Chave pública do nó + 02abc… (33 bytes em hex) + Peso + Taxa %1$d episódio %1$d episódios @@ -907,6 +1106,9 @@ Itens Salvos Privados Itens Salvos Públicos + Repositórios + Seus repositórios git marcados + Seus podcasts e episódios marcados Adicionar aos Itens Salvos Privados Adicionar aos Itens Salvos Públicos Remover dos Itens Salvos Privados @@ -1452,6 +1654,25 @@ Conectar Carteira Linguagem Tema + Cor de destaque + Cor principal usada em botões e links + Roxo + Azul + Verde + Laranja + Vermelho + Rosa + Fonte + Tipo de letra usado em todo o app + Padrão do sistema + Sem serifa + Serifada + Monoespaçada + Tamanho da fonte + Ajuste o tamanho do texto em todo o app + Pequeno + Grande + Enorme Pré-visualizar imagens Reproduzir vídeos automaticamente Reprodução automática de vídeos @@ -1524,6 +1745,107 @@ Público Grupo Novo Grupo Público ou Privado + Grupos de relay + Em linha + Por relay + Mostra cada grupo como sua própria conversa, misturado com seus chats. + Agrupa os grupos de cada relay em uma única linha, posicionada na sua mensagem mais recente. + Exibição de grupos NIP-29 + Grupos de relay + Grupos + Mensagens + Criar um grupo + Este relay não anuncia suporte a grupos NIP-29. Um grupo criado aqui não funcionará — seu nome, membros e mensagens não serão gerenciados pelo relay. Escolha um relay que suporte grupos baseados em relay. + Nome do grupo + Tópico (opcional) + Privado (leitura somente para membros) + Somente por convite + Criar + Convidar pessoas + Compartilhe este código para que as pessoas possam entrar neste grupo. + Criando um convite… + Código de convite copiado + Código de convite (para este grupo somente por convite): + Entrar no grupo + Código de convite + Este grupo é somente por convite. Insira o código que você recebeu. + Entrar + Solicitado + Moderador + Membro + Membros + Tornar admin + Tornar moderador + Remover função + Remover do grupo + Remover %1$s deste grupo? A pessoa perderá o acesso até ser readicionada ou reconvidada. + Editar grupo + Nome do grupo + Tópico (opcional) + Privado (leitura somente para membros) + Somente por convite + Salvar + Membros + Editar grupo + Abrir grupo + Nenhum grupo neste relay ainda. + Preparando convite… + Privado + Somente por convite + Insira uma URL de relay válida (wss://…). + Nome + Descrição + Imagem do grupo + Adicionar foto + Alterar foto + Descoberta + Ajude as pessoas a encontrar este grupo. Tópicos e uma localização fazem-no aparecer nos filtros de descoberta correspondentes (se o relay host os suportar). + Tópicos + bitcoin, nostr, arte + Localização (geohash) + Permissões + Privado + Apenas membros podem ler as mensagens. + Somente por convite + As pessoas precisam ser convidadas ou aprovadas para entrar. + Publicação somente para membros + Apenas membros podem publicar mensagens. + Não listado + Ocultar este grupo do diretório público do relay. + Procurando grupos nos seus relays… + Nenhum grupo encontrado para este filtro ainda. + Favoritar este relay + Nenhuma thread ainda. Comece uma com o botão +. + Nova thread + Sem título + Título + Escreva algo… + Publicar + Encontrar grupos + Explore os grupos hospedados em um relay. Cole um endereço de relay ou escolha um abaixo. + Endereço do relay + Explorar + Relays em que você está + Relays populares + Nenhuma mensagem ainda + Entre neste grupo para enviar mensagens. + Este grupo é somente por convite — você precisa de um convite para publicar. + + %1$d membro + %1$d membros + + + %1$d mensagem + %1$d mensagens + + + %1$d+ mensagem + %1$d+ mensagens + + + %1$d pessoa que você segue + %1$d pessoas que você segue + Privado Para Assunto @@ -1763,12 +2085,41 @@ Crie uma carteira Cashu para guardar tokens ecash e receber nutzaps. Procurando sua carteira… Carteiras NIP-60 sincronizam entre clientes. Se você criou uma em outro app com esta chave Nostr, ela deve aparecer em alguns segundos. + Configure sua carteira + Procurando sua carteira… + Procurando em todos os relays por uma carteira Cashu publicada sob sua chave Nostr. + Procurando em relays… %1$d / %2$d + Verificando as carteiras que encontramos… + Nenhuma carteira encontrada + Não encontramos uma carteira Cashu existente sob sua chave Nostr em nenhum relay. Crie uma nova para começar. + Criar uma nova carteira + Encontramos sua carteira + Esta carteira Cashu está publicada na rede Nostr sob sua chave. Use-a neste dispositivo — vamos retransmiti-la para seus relays para que seja fácil encontrá-la da próxima vez. + Usar esta carteira + Encontramos várias carteiras + Você tem mais de uma carteira Cashu na rede — provavelmente criadas por apps diferentes. A mais recente se torna sua carteira principal; recupere para ela os fundos das mais antigas. + Mais recente — sua carteira principal + Carteira mais antiga + Definir como carteira principal + Recuperar fundos para a carteira principal + %1$s sats recuperáveis + %1$s sats recuperados + Nenhum fundo recuperável + Não foi possível ler esta carteira + Defina primeiro sua carteira principal, depois recupere os fundos das mais antigas. + Configurando sua carteira… + Buscar novamente + Carteira criada + Agora escolha alguns mints para hospedar seus sats. + Escolher mints Saldo Mints URL do mint Remover mint Adicionar mint Histórico + Sua carteira é salva automaticamente conforme você adiciona ou remove mints. Uma chave de nutzap é criada para você na primeira vez que adiciona um mint. + Salvando… Chave de nutzap (avançado) Uma chave privada separada, usada apenas para receber nutzaps NIP-61. Não é a chave da sua identidade Nostr. Gerar uma nova chave @@ -2102,6 +2453,7 @@ Recomendações de Apps Feed de Zaps Recebidos Feed de Seguidores + Carteira Bitcoin (on-chain) Configurações de Reações Configure quais botões de reação são exibidos, sua ordem e se os contadores devem ser mostrados. Ativado @@ -2250,6 +2602,8 @@ Forkado de Site: Clonar: + Base de merge + O pull request foi atualizado para um novo commit. Aberto Mesclado Fechado @@ -2257,11 +2611,69 @@ Visão geral Tickets Patches e PRs + Abertos + Fechados & Resolvidos + Todos + Marcar repositório + Arquivos + Atualizado + Atividade recente + Mantido por + Hospedado externamente + Este repositório não pode ser clonado via http(s), então a visualização de código não está disponível aqui. + Abrir no navegador + Sem título Sobre Links Mantenedores Tópicos Fork pessoal + Código + Carregando repositório… + Não foi possível carregar o repositório a partir da sua URL de clone. + Este anúncio de repositório não tem URL de clone http(s) para navegar. + Este repositório não tem arquivo README. + Não foi possível carregar este arquivo. + Arquivo binário (%1$s) — prévia não disponível. + Esta pasta está vazia. + raiz + Tentar novamente + padrão + Pesquisar arquivos… + Nenhum arquivo correspondente. + Nenhum histórico de commits disponível. + Copiar conteúdo do arquivo + Texto + + %1$d arquivo alterado + %1$d arquivos alterados + + Arquivo binário não exibido. + Nova + Nova issue + Título + Descrição + Criar + Cancelar + bug, melhoria… + Fechar issue + Reabrir + Fechar + Reabrir + Marcar como merged + Ver alterações + Carregando alterações… + Nenhuma alteração de arquivo encontrada. + Tentar carregar alterações novamente + Revisado + Editar repositório + Nome + Descrição + URLs de clone (uma por linha) + URLs web (uma por linha) + Tópicos (separados por vírgula) + Salvar + Repositórios Git Site Estático: %1$s Permissões: Site Raiz @@ -2550,6 +2962,7 @@ Patch do Git Repositório Git Resposta do Git + Atualização de PR Metas de Zap Hashtags seguidas Destaques @@ -2832,6 +3245,7 @@ %1$s financiado de %2$s sats da meta Termina %1$s Doação on-chain + Detecção de pássaros Birdex · %1$d espécie Birdex · %1$d espécies @@ -2841,6 +3255,9 @@ %1$s +%2$d a mais + Salvamento em cartão de memória PS1 + bloco %1$d + Espaço vazio Polícia Radar de velocidade @@ -2969,6 +3386,82 @@ Configurações de composição Criar rascunhos automaticamente Salva um evento de rascunho automaticamente quando você digita ou sai do compositor com texto não enviado e envia para seus relays de saída privados. + Assinatura + Adicionado ao final da mensagem ao abrir uma nova publicação, resposta, citação ou artigo. Deixe em branco para desativar. + Sua assinatura + Prova de trabalho + Dificuldade + Minera uma prova de trabalho NIP-13 nas suas publicações antes de publicar, para que relays e leitores possam ponderá-las contra spam. Valores mais altos levam exponencialmente mais tempo para minerar. As publicações são publicadas em segundo plano depois de mineradas. + Desligado + Dificuldade personalizada + Ajuste com precisão o alvo em bits zero à esquerda. + O que minerar + Eventos com prazo crítico (autenticação de relay, solicitações de zap, mensagens de carteira e assinador, rascunhos, listas) nunca são minerados. + Notas curtas & respostas + A principal superfície de spam público + Comentários + Respostas a artigos, arquivos e outros conteúdos + Denúncias + Os relays ponderam as denúncias pelo seu custo + Conteúdo longo & destaques + Artigos e destaques; pouco frequentes, o custo é insignificante + Mensagens de voz + Publicações e respostas de voz públicas + Republicações + Alto volume para usuários ativos + Reações + Kind de maior volume; minerar cada curtida gasta bateria + Chat público & ao vivo + Atrasos de mineração prejudicam o fluxo da conversa + Envelopes de mensagens privadas + Permite que os relays de DM filtrem spam da caixa de entrada; apenas o envelope externo é minerado, nunca sua mensagem + Outro conteúdo público + Enquetes, status ao vivo, classificados e tudo mais que for público + Minerando prova de trabalho + + Minerando prova de trabalho… (%1$d publicação na fila) + Minerando prova de trabalho… (%1$d publicações na fila) + + + %1$s • minerando em %2$d bit + %1$s • minerando em %2$d bits + + + %1$s • aguardando para minerar em %2$d bit + %1$s • aguardando para minerar em %2$d bits + + + Padrão (%1$d bit) + Padrão (%1$d bits) + + Padrão (desligado) + Desligado para esta publicação + Mineração de prova de trabalho + Mostra publicações que ainda estão minerando sua prova de trabalho NIP-13 para que possam terminar depois que você sair do app. + Cancelar + ≈ %1$s por publicação neste dispositivo + + %1$d segundo + %1$d segundos + + + %1$d minuto + %1$d minutos + + + %1$d hora + %1$d horas + + + %1$d dia + %1$d dias + + ≈ %1$s restante + a qualquer momento + Seu %1$s terminou de minerar, mas não pôde ser publicado: %2$s + Seu %1$s terminou de minerar, mas não pôde ser publicado. Uma nova tentativa será feita na próxima vez que o app iniciar. + Mensagem de chat + Denúncia Usar isto Dispensar Corrigir diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index ca69e77142..83a1991f8d 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -583,6 +583,10 @@ Hanterar av Tillgänglig på + Kategorier + Relaterat + Implementerar + NIP:er som stöds Web Android iOS @@ -638,6 +642,9 @@ Öppna Rensa Favoriter + Upptäck webbappar + Webbplatser från personer du följer + Appar från personer du följer Alternativ Ta bort från historik Favoritappar @@ -663,6 +670,7 @@ Glöm det här nApplet Blockerad Återkalla + Fråga mig varje gång Inga nApplets hittades ännu. @@ -710,14 +718,106 @@ Det här nApplet vill betala en Lightning-faktura på %1$d sats. + Anslut till Nostr + vill ansluta till ditt Nostr-konto + Hur ska den här appens förfrågningar hanteras? + Anslut + Blockera och ignorera %1$s + Jag litar helt på den + Signera automatiskt alla förfrågningar (utom betalningar) + Låt oss vara rimliga + Godkänn automatiskt de vanligaste förfrågningarna + Jag är lite paranoid + Signera inget utan att fråga mig! + vill %1$s + Visa händelse + Dölj händelse + Fler alternativ + Färre alternativ + Tillåt en gång + Tillåt för den här sessionen + Tillåt i 24 timmar + Tillåt i 30 dagar + Fråga inte igen om att %1$s + Fråga inte igen för några Nostr-förfrågningar + Neka + Neka alltid %1$s + Senast använd + Upphör + signera en händelse av typ %1$d + signera för %1$s (typ: %2$d) + kryptera ett meddelande + läsa dina privata meddelanden + Anslutna appar + Återkalla alla behörigheter + Åsidosättningar för operationer + Inga appar har anslutit ännu. + Återkalla alla behörigheter + Tillåt + Fråga + Neka + appar behörigheter signerare napplet nsite webapp förtroende anslutna + Förtroendenivå för signering + Behörigheter + Glöm den här appen + Åsidosättningar för signeringsoperationer + Inga appar har anslutit ännu.\n\nNär en webbapp ansluter till din Nostr-nyckel visas den här. + Reläautentisering + auth autentisering relä signera verifiera nip-42 identitet + När autentisering ska ske + Vad du ska logga in på + Autentisera alltid + Signera autentiseringsutmaningar för varje relä som begär det + Autentisera aldrig + Ignorera autentiseringsutmaningar från alla reläer + Anpassad + Välj exakt vilka reläer du vill logga in på. Du tillfrågas om allt du inte har tillåtit nedan. + Mina reläer och platser + Logga in på dina egna reläer och på offentliga chattar, communities och livestreams som du har gått med i eller favoritmarkerat. + Läs inlägg från personer jag följer + Logga in på en följd persons reläer för att ladda ner deras inlägg. + Meddela personer jag följer + Logga in på en följd persons reläer för att skicka DM, svar och notiser. + Meddela vem som helst + Logga in på främlingars reläer för att skicka DM, svar och notiser. Av som standard; du tillfrågas i stället varje gång. + Bekräfta att det är du för det här reläet? + Det här reläet vill först bekräfta att det verkligen är du. Dess operatör ser vilket konto du är. + Skicka ditt meddelande till %1$s? + Meddela %1$s? + Läs in inlägg från %1$s? + Publicera till %1$s? + Öppna %1$s? + Om du inte gör det levereras inte ditt meddelande till %1$s. + Om du inte gör det meddelas inte %1$s om detta. + Om du inte gör det ser du inte inlägg från %1$s här. + Om du inte gör det publiceras inte ditt meddelande till %1$s. + Om du inte gör det ser du inte %1$s här. + %1$s med flera + Skicka ditt privata meddelande till: + Meddela: + Ladda ner inlägg från: + Publicera till det här rummet + Öppna det här rummet + Använd det här reläet + Anslut till ditt eget relä + Tillåt en gång + Tillåt alltid det här reläet + Leverera alltid mina meddelanden + Blockera det här reläet + Åsidosättningar per relä + Glöm + Senast använd för %1$s sedan + Inget här ännu — din globala policy gäller för varje relä. + Tillåt + Neka Källa: %1$s v%1$s Ladda ner @@ -882,6 +982,105 @@ Poddar Visa avsnitt Inga avsnitt hittades än + Säsong %1$d + Slutförd + Stöd podden + av %1$s + S%1$d · A%2$d + Avs %1$d + Säsong %1$d + Transkription + Kapitel + Zaps till detta fördelas mellan: + Värdar & gäster + Spela höjdpunkt + Främsta supportrar + + %1$d kapitel + %1$d kapitel + + Värd + Medvärd + Redaktör + Verifierad författare + Value-for-Value-fel + Den här podden har inga betalbara värdemottagare. + Anslut en Nostr Wallet Connect-plånbok för att skicka till keysend-mottagare (noder). + Streama sats + Skickar värde automatiskt medan du lyssnar. + Streamade %1$d sats denna session + Anslut en Nostr Wallet Connect- eller debiteringsplånbok för att streama sats medan du lyssnar. + Nytt avsnitt + Redigera avsnitt + Publicerar… + Lägg till omslagsbild + Kvadratisk bild som visas för avsnittet + Titel + Avsnittstitel + Sammanfattning + Längd (sekunder) + Fler detaljer + Säsong + Avsnitt # + Video-URL + Transkriptions-URL + Kapitel-URL + Ämnen + komma, separerade, taggar + Ljud-URL + https://…/avsnitt.mp3 + Ljudfil redo att laddas upp + Lägg till ljudfil + MP3, M4A eller annat ljud + Ta bort avsnitt + Ta bort det här avsnittet? Detta kan inte ångras. + Din podd + Lägg till omslagsbild + Kvadratisk bild för din podd + Poddtitel + Min podd + Beskrivning + Författare + Kontakt-e-post + Webbplats + Kategorier + Teknik, Nyheter + Finansieringslänkar + Språk + sv + Upphovsrätt + Poddtyp + Fristående avsnitt + Seriell + Explicit innehåll + Podden är komplett (inga fler avsnitt) + Låst (premium) + Ny trailer + Lägg till trailerklipp + Kort ljud- eller videoförhandsvisning + Trailertitel + Media-URL + Din podd + Namnlös + Inga avsnitt ännu. Tryck på Nytt avsnitt för att publicera ditt första. + Skapa din podd + Tryck för att redigera podddetaljer + Ange din podds titel, bild och detaljer + Lägg till mottagare för att fördela inkommande sats efter vikt. Lyssnare boostar eller streamar värde till dessa destinationer. + Lägg till mottagare + Lägg till adress manuellt + Lägg till en Nostr-användare + Sök efter namn eller @handtag + Den här användaren har ingen Lightning-adress + Ta bort mottagare + Namn (valfritt) + Lightning-adress + Nod (keysend) + Lightning-adress + namn@example.com + Nodens publika nyckel + Vikt + Avgift %1$d avsnitt %1$d avsnitt @@ -910,6 +1109,9 @@ Privata Bokmärken Publika Bokmärken + Dina bokmärkta git-repositories + Poddar + Dina bokmärkta poddar och avsnitt Lägg till i Privata Bokmärken Lägg till i Publika Bokmärken Ta bort från Privata Bokmärken @@ -1455,6 +1657,21 @@ Plånboksanslut Språk Tema + Accentfärg + Huvudfärg som används för knappar och länkar + Lila + Blå + Grön + Röd + Rosa + Typsnitt + Teckensnitt som används genom hela appen + Systemstandard + Teckenstorlek + Skala textstorleken i hela appen + Liten + Stor + Enorm Ladda automatiskt bilder/gif Spela automatiskt videor Automatisk uppspelning av videor @@ -1527,6 +1744,106 @@ Publik Grupp Ny offentlig eller privat grupp + Relägrupper + Efter relä + Visa varje grupp som en egen konversation, blandad med dina chattar. + Slå ihop varje reläs grupper till en enda rad, placerad vid dess nyaste meddelande. + Visning av NIP-29-grupper + Relägrupper + Grupper + Meddelanden + Skapa en grupp + Det här reläet annonserar inte stöd för NIP-29-relägrupper. En grupp som skapas här fungerar inte — dess namn, medlemmar och meddelanden hanteras inte av reläet. Välj ett relä som stöder reläbaserade grupper. + Gruppnamn + Ämne (valfritt) + Privat (läsning endast för medlemmar) + Endast inbjudan + Skapa + Bjud in personer + Dela den här koden så att personer kan gå med i gruppen. + Skapar en inbjudan… + Inbjudningskod kopierad + Inbjudningskod (för den här gruppen med endast inbjudan): + Gå med i grupp + Inbjudningskod + Den här gruppen är endast för inbjudna. Ange koden du fick. + Gå med + Begärd + Medlem + Medlemmar + Gör till admin + Gör till moderator + Ta bort roll + Ta bort från grupp + Ta bort %1$s från den här gruppen? De förlorar åtkomst tills de läggs till eller bjuds in igen. + Redigera grupp + Gruppnamn + Ämne (valfritt) + Privat (läsning endast för medlemmar) + Endast inbjudan + Spara + Medlemmar + Redigera grupp + Trådar + Öppna grupp + Inga grupper på det här reläet ännu. + Förbereder inbjudan… + Privat + Endast inbjudan + Ange en giltig relä-URL (wss://…). + Namn + Beskrivning + Gruppbild + Lägg till foto + Ändra foto + Upptäckt + Hjälp personer att hitta den här gruppen. Ämnen och en plats gör att den syns under matchande upptäcktsfilter (om värdreläet stöder dem). + Ämnen + bitcoin, nostr, konst + Plats (geohash) + Behörigheter + Privat + Endast medlemmar kan läsa meddelanden. + Endast inbjudan + Personer måste bjudas in eller godkännas för att gå med. + Publicering endast för medlemmar + Endast medlemmar kan publicera meddelanden. + Ej listad + Dölj den här gruppen från reläets offentliga katalog. + Söker efter grupper på dina reläer… + Inga grupper hittades för det här filtret ännu. + Favoritmarkera det här reläet + Inga trådar ännu. Starta en med +-knappen. + Ny tråd + Namnlös + Titel + Skriv något… + Publicera + Hitta grupper + Bläddra bland grupperna som hostas på ett relä. Klistra in en reläadress eller välj en nedan. + Reläadress + Bläddra + Reläer du är på + Populära reläer + Inga meddelanden ännu + Gå med i den här gruppen för att skicka meddelanden. + Den här gruppen är endast för inbjudna — du behöver en inbjudan för att publicera. + + %1$d medlem + %1$d medlemmar + + + %1$d meddelande + %1$d meddelanden + + + %1$d+ meddelande + %1$d+ meddelanden + + + %1$d person du följer + %1$d personer du följer + Privat Till Ämne @@ -1766,12 +2083,41 @@ Skapa en Cashu-plånbok för att hålla ecash-tokens och ta emot nutzaps. Letar efter din plånbok… 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. + Konfigurera din plånbok + Söker efter din plånbok… + Söker på alla reläer efter en Cashu-plånbok publicerad under din Nostr-nyckel. + Söker på reläer… %1$d / %2$d + Verifierar plånböckerna vi hittade… + Ingen plånbok hittades + Vi kunde inte hitta någon befintlig Cashu-plånbok under din Nostr-nyckel på något relä. Skapa en ny för att komma igång. + Skapa en ny plånbok + Vi hittade din plånbok + Den här Cashu-plånboken är publicerad i Nostr-nätverket under din nyckel. Använd den på den här enheten — vi sänder om den till dina reläer så att den är lätt att hitta nästa gång. + Använd den här plånboken + Vi hittade flera plånböcker + Du har mer än en Cashu-plånbok i nätverket — troligen skapade av olika appar. Den nyaste blir din huvudplånbok; återhämta eventuella medel från de äldre till den. + Nyast — din huvudplånbok + Äldre plånbok + Ange som huvudplånbok + Återhämta medel till huvudplånboken + %1$s sats kan återvinnas + Återhämtade %1$s sats + Inga återvinningsbara medel + Det gick inte att läsa den här plånboken + Ange din huvudplånbok först, återhämta sedan medel från de äldre. + Konfigurerar din plånbok… + Sök igen + Plånbok skapad + Välj nu några mints som ska hosta dina sats. + Välj mints Saldo Mints Mint-URL Ta bort mint Lägg till mint Historik + Din plånbok sparas automatiskt när du lägger till eller tar bort mints. En nutzap-nyckel skapas åt dig första gången du lägger till en mint. + Sparar… Nutzap-nyckel (avancerat) En separat privat nyckel som endast används för att ta emot NIP-61-nutzaps. Inte din Nostr-identitetsnyckel. Generera en ny nyckel @@ -2105,6 +2451,7 @@ App-rekommendationer Mottagna zaps-flöde Följarflöde + Bitcoin-plånbok (on-chain) Reaktionsinställningar Konfigurera vilka reaktionsknappar som visas, deras ordning och om räknare ska visas. Aktiverad @@ -2253,6 +2600,9 @@ Forkad från Webbplats: Klona: + Gren + Mergebas + Uppdaterade pull request till en ny commit. Öppen Sammanfogad Stängd @@ -2260,11 +2610,77 @@ Översikt Ärenden Patchar och PR + Öppna + Stängda & lösta + Alla + Bokmärk repository + Grenar + Taggar + Filer + Uppdaterad + Senaste aktivitet + Underhålls av + Hostat externt + Det här repositoryt kan inte klonas över http(s), så kodvyn är inte tillgänglig här. + Öppna i webbläsare + Namnlös Om Länkar Underhållare Ämnen Personlig fork + Kod + Läser in repository… + Det gick inte att läsa in repositoryt från dess klon-URL. + Den här repository-annonseringen har ingen http(s)-klon-URL att bläddra i. + Det här repositoryt har ingen README-fil. + Det gick inte att läsa in den här filen. + Binär fil (%1$s) — förhandsvisning ej tillgänglig. + Den här mappen är tom. + rot + Försök igen + standard + Grenar + Taggar + Sök filer… + Inga matchande filer. + Ingen commit-historik tillgänglig. + Kopiera filinnehåll + + %1$d objekt + %1$d objekt + + + %1$d fil ändrad + %1$d filer ändrade + + Binär fil visas inte. + Ny + Nytt ärende + Titel + Beskrivning + Skapa + Avbryt + Etiketter + bugg, förbättring… + Stäng ärende + Öppna igen + Stäng + Öppna igen + Markera som mergad + Visa ändringar + Läser in ändringar… + Inga filändringar hittades. + Försök läsa in ändringar igen + Reviderad + Redigera repository + Namn + Beskrivning + Klon-URL:er (en per rad) + Webb-URL:er (en per rad) + Ämnen (kommaseparerade) + Spara + Git-repositories Statisk webbplats: %1$s Behörigheter: Appar & webbplatser @@ -2554,6 +2970,7 @@ Git-patch Git-repository Git-svar + PR-uppdatering Zap-mål Följda hashtaggar Höjdpunkter @@ -2836,6 +3253,7 @@ %1$s finansierat av %2$s sats mål Slutar %1$s On-chain-donation + Fågeldetektering Birdex · %1$d art Birdex · %1$d arter @@ -2845,6 +3263,8 @@ %1$s +%2$d till + PS1-minneskortsparning + Tom plats Polis Fartkamera @@ -2973,6 +3393,82 @@ Skrivinställningar Skapa utkast automatiskt Sparar ett utkast automatiskt när du skriver eller lämnar redigeraren med osänd text och skickar det till dina privata utgående reläer. + Signatur + Läggs till i slutet av meddelandet när du öppnar ett nytt inlägg, svar, citat eller artikel. Lämna tomt för att inaktivera. + Din signatur + Bevis på arbete + Svårighetsgrad + Minar ett NIP-13-bevis på arbete i dina inlägg innan de publiceras så att reläer och läsare kan väga dem mot spam. Högre värden tar exponentiellt längre tid att mina. Inlägg publiceras i bakgrunden när de är minade. + Av + Anpassad svårighetsgrad + Finjustera målet i inledande nollbitar. + Vad som ska minas + Tidskritiska händelser (relä-autentisering, zap-förfrågningar, plånboks- och signerarmeddelanden, utkast, listor) minas aldrig. + Korta notiser & svar + Den primära offentliga spamytan + Kommentarer + Svar på artiklar, filer och annat innehåll + Rapporter + Reläer väger rapporter efter deras kostnad + Långform & höjdpunkter + Artiklar och höjdpunkter; sällsynta, kostnaden är försumbar + Röstmeddelanden + Offentliga röstinlägg och svar + Reposter + Hög volym för aktiva användare + Reaktioner + Den mest högvolymiga typen; att mina varje gillning kostar batteri + Offentlig & livechatt + Miningfördröjningar skadar konversationsflödet + Omslag för privata meddelanden + Låter DM-reläer filtrera inkorgsspam; endast det yttre omslaget minas, aldrig ditt meddelande + Annat offentligt innehåll + Omröstningar, livestatus, radannonser och allt annat offentligt + Minar bevis på arbete + + Minar bevis på arbete… (%1$d inlägg i kön) + Minar bevis på arbete… (%1$d inlägg i kön) + + + %1$s • minar vid %2$d bit + %1$s • minar vid %2$d bitar + + + %1$s • väntar på att minas vid %2$d bit + %1$s • väntar på att minas vid %2$d bitar + + + Standard (%1$d bit) + Standard (%1$d bitar) + + Standard (av) + Av för det här inlägget + Mining av bevis på arbete + Visar inlägg som fortfarande minar sitt NIP-13-bevis på arbete så att de kan slutföras efter att du lämnat appen. + Avbryt + ≈ %1$s per inlägg på den här enheten + + %1$d sekund + %1$d sekunder + + + %1$d minut + %1$d minuter + + + %1$d timme + %1$d timmar + + + %1$d dag + %1$d dagar + + ≈ %1$s kvar + när som helst nu + Ditt %1$s slutförde miningen men kunde inte publiceras: %2$s + Ditt %1$s slutförde miningen men kunde inte publiceras. Det görs ett nytt försök nästa gång appen startar. + Chattmeddelande + Rapport Använd detta Avvisa Korrigera @@ -3055,6 +3551,10 @@ Inga inbjudningar När någon lägger till dig i en grupp visas det här tills du svarar. Inga meddelanden ännu + + %1$d medd + %1$d medd + %1$d medlem %1$d medlemmar diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 88e2ab67dc..b56145cbb7 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -178,6 +178,12 @@ "Spanish, United States" ] }, + { + "user": "davotoula", + "languages": [ + "Czech" + ] + }, { "user": "greenart7c3", "languages": [] @@ -242,10 +248,6 @@ "user": "adhrasreoshiathoi", "languages": [] }, - { - "user": "davotoula", - "languages": [] - }, { "user": "crackadoo", "languages": [] From 0053edf2dd96d923d8f22c003eb7be6bc03d259b Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 11 Jul 2026 16:49:41 +0100 Subject: [PATCH 02/11] refactor: adopt androidx.core KTX helpers (Bitmap/Uri/SharedPreferences) --- .claude/CLAUDE.md | 20 +++++++++++++++++++ .../amethyst/ImageUploadTesting.kt | 3 ++- .../ThumbnailDiskCacheInstrumentedTest.kt | 3 ++- .../amethyst/napplet/NappletBrokerService.kt | 3 ++- .../amethyst/napplet/WebAppNetworkRegistry.kt | 4 ++-- .../calendar/CalendarReminderNotifier.kt | 3 ++- .../service/calendar/CalendarReminderPrefs.kt | 5 +++-- .../service/calendar/CalendarReminderStore.kt | 3 ++- .../ui/note/creators/location/MapPinIcon.kt | 3 ++- .../amethyst/ui/note/types/Ps1Save.kt | 5 ++--- .../screen/loggedIn/browser/WebAppScreen.kt | 6 +++--- .../detail/CalendarEventDetailScreen.kt | 5 +++-- .../commons/blurhash/PlatformImage.android.kt | 3 ++- .../commons/keystorage/SecureKeyStorage.kt | 5 +++-- .../nip64Chess/ChessDismissedGamesStorage.kt | 3 ++- .../napplethost/NappletBrowserActivity.kt | 12 ++++++----- .../napplethost/NappletBrowserService.kt | 3 ++- .../napplethost/NappletHostService.kt | 3 ++- 18 files changed, 63 insertions(+), 29 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0d3293a869..7225a48c67 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -282,6 +282,26 @@ Do this before considering the task complete. - The only acceptable inline fully-qualified names are: a genuine name collision (prefer `import ... as Alias` instead), or where the language requires it. Comments, KDoc, and string literals are exempt. +- **Prefer the `androidx.core` KTX extension over the raw platform Java call** + when one exists — this is what Android Lint's `UseKtx` flags. Common swaps: + `Bitmap.createBitmap(w, h, cfg)` → `createBitmap(w, h)`, + `Bitmap.createScaledBitmap(src, w, h, f)` → `src.scale(w, h, f)`, + `Uri.parse(s)` → `s.toUri()`, and `prefs.edit()…apply()` → `prefs.edit { }`. + Only adopt the KTX form when it's behaviour-preserving: keep any explicit + argument that differs from the extension's default (a non-`ARGB_8888` + `Bitmap.Config`, `scale(filter = false)`), and leave calls the KTX has no + equivalent for (e.g. the `createBitmap` pixels/matrix overloads, or a + conditional-`apply()` editor loop) untouched. +- **This "prefer the KTX sugar" rule does NOT extend to collection operators.** + The KTX preference is about platform wrappers (`Bitmap`/`Uri`/`SharedPreferences`), + which compile to the identical call. Collections are the opposite: in hot + event/parse paths Quartz deliberately uses raw JVM arrays (`TagArray = + Array>`) and the inline `fast*` operators (`fastForEach`, + `fastAny`, `fastFirstOrNull`, `fastFirstNotNullOfOrNull`, … in + `nip01Core/core/TagArray.kt`) instead of Kotlin `List` + stdlib + `forEach`/`map`/`filter`/`any` — the `fast*` variants allocate no iterator, + no intermediate list, and no lambda object. Don't "modernize" those into + stdlib collection calls; match the surrounding hot-path style. ### Navigation Shell - **Desktop**: Sidebar + main content area diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index a5f447e654..84ebadec75 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst import android.graphics.Bitmap import android.graphics.Color +import androidx.core.graphics.createBitmap import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.vitorpamplona.amethyst.model.AccountSettings @@ -81,7 +82,7 @@ class ImageUploadTesting { .build() private fun getBitmap(): ByteArray { - val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(200, 300) for (x in 0 until bitmap.width) { for (y in 0 until bitmap.height) { bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt())) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheInstrumentedTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheInstrumentedTest.kt index 04f5e861e6..8e5c047041 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheInstrumentedTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheInstrumentedTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.images import android.graphics.Bitmap +import androidx.core.graphics.createBitmap import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.After @@ -44,7 +45,7 @@ class ThumbnailDiskCacheInstrumentedTest { cacheDir = File(appContext.cacheDir, "thumbnail-test-${UUID.randomUUID()}") cache = ThumbnailDiskCache(cacheDir) sourceFile = File(appContext.cacheDir, "source-${UUID.randomUUID()}.jpg") - val bitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(64, 64) sourceFile.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } bitmap.recycle() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 59f276401d..c08d211060 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -32,6 +32,7 @@ import android.os.Messenger import android.os.RemoteException import android.os.SystemClock import android.util.Log +import androidx.core.net.toUri import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.napplet.NappletBroker @@ -359,7 +360,7 @@ class NappletBrokerService : Service() { val intent = Intent(applicationContext, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - data = Uri.parse("nostr:connectedapp?coordinate=" + Uri.encode(coordinate)) + data = ("nostr:connectedapp?coordinate=" + Uri.encode(coordinate)).toUri() } runCatching { applicationContext.startActivity(intent) } .onFailure { Log.w("NappletBrokerService", "Could not open Connected Apps detail", it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt index 185b780e99..2038417d1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context -import android.net.Uri +import androidx.core.net.toUri import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore @@ -86,7 +86,7 @@ object WebAppNetworkRegistry { } /** The host key for [url] (e.g. `vitorpamplona.com`), or the raw string if it has no host. */ - fun hostKeyOf(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url + fun hostKeyOf(url: String): String = runCatching { url.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url /** Whether the site behind [url] routes through Tor. Defaults to true (Tor) for any site never set. */ fun useTor(url: String): Boolean = modes[hostKeyOf(url)] ?: true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt index c496d512bd..5cc2d5a8fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt @@ -27,6 +27,7 @@ import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.net.toUri import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.stringRes @@ -64,7 +65,7 @@ object CalendarReminderNotifier { val tapIntent = Intent(context, MainActivity::class.java).apply { action = Intent.ACTION_VIEW - data = android.net.Uri.parse(deepLink) + data = deepLink.toUri() addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) } val tapPendingIntent = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt index f3dda3056f..737cffdadb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.calendar import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit /** * Device-wide preferences for the calendar reminder worker. @@ -40,13 +41,13 @@ class CalendarReminderPrefs( fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED) fun setEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_ENABLED, enabled).apply() + prefs.edit { putBoolean(KEY_ENABLED, enabled) } } fun leadMinutes(): Int = prefs.getInt(KEY_LEAD_MINUTES, DEFAULT_LEAD_MINUTES) fun setLeadMinutes(minutes: Int) { - prefs.edit().putInt(KEY_LEAD_MINUTES, minutes).apply() + prefs.edit { putInt(KEY_LEAD_MINUTES, minutes) } } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt index eb78d56322..81c3ad882f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.calendar import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit /** * Persistent "I've already notified for this event" set. Backed by [SharedPreferences] because @@ -54,7 +55,7 @@ class CalendarReminderStore( eventId: String, eventStartSeconds: Long, ) { - prefs.edit().putLong(keyFor(eventId), eventStartSeconds).apply() + prefs.edit { putLong(keyFor(eventId), eventStartSeconds) } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt index 8e9f422ccc..ba370ddfba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt @@ -25,6 +25,7 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path +import androidx.core.graphics.createBitmap import kotlin.math.roundToInt /** @@ -57,7 +58,7 @@ fun roadEventPinBitmap( val cx = width / 2f val cy = pad + radius - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(width, height) val canvas = Canvas(bitmap) // Head circle + pointer drawn as a single path so they share one shadow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Ps1Save.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Ps1Save.kt index 639c26947c..54d4a4ab25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Ps1Save.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Ps1Save.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -46,6 +45,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.graphics.createBitmap import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.stringRes @@ -160,8 +160,7 @@ private fun Ps1SaveIconImage(icon: Ps1SaveIcon) { val frames = remember(icon) { icon.frames.map { pixels -> - Bitmap - .createBitmap(Ps1SaveIcon.SIZE, Ps1SaveIcon.SIZE, Bitmap.Config.ARGB_8888) + createBitmap(Ps1SaveIcon.SIZE, Ps1SaveIcon.SIZE) .apply { setPixels(pixels, 0, Ps1SaveIcon.SIZE, 0, 0, Ps1SaveIcon.SIZE, Ps1SaveIcon.SIZE) } .asImageBitmap() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index 9b903b4b33..8265fa2eff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser -import android.net.Uri import android.os.Build import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi @@ -44,6 +43,7 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R @@ -193,14 +193,14 @@ private fun EmbeddedWebAppTab( } /** The host of [url] for the tab title, falling back to the raw string. */ -internal fun hostLabel(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url +internal fun hostLabel(url: String): String = runCatching { url.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url /** * The `scheme://host[:port]` origin of [url] — the exact form the sandbox reports for NIP-07 consent, so * it matches the `browser:` permission-ledger key. Null when [url] has no usable scheme/host. */ internal fun browserOrigin(url: String): String? { - val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return null + val uri = runCatching { url.toUri() }.getOrNull() ?: return null val scheme = uri.scheme?.takeIf { it.isNotBlank() } ?: return null val host = uri.host?.takeIf { it.isNotBlank() } ?: return null return "$scheme://$host" + if (uri.port > 0) ":${uri.port}" else "" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt index e25c76a2be..ad89bdc42b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -582,11 +583,11 @@ private fun LocationRow(location: String) { val trimmed = location.trim() val intent = if (isUrl) { - Intent(Intent.ACTION_VIEW, Uri.parse(trimmed)) + Intent(Intent.ACTION_VIEW, trimmed.toUri()) } else { // `geo:0,0?q=` is the Android geo intent; the user's // installed maps app handles it. - Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=${Uri.encode(trimmed)}")) + Intent(Intent.ACTION_VIEW, "geo:0,0?q=${Uri.encode(trimmed)}".toUri()) } // runCatching swallows ActivityNotFoundException when no handler is // installed — we don't have anywhere useful to fall back to. diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt index 3c494a6904..b8075bca4e 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.blurhash import android.graphics.Bitmap +import androidx.core.graphics.scale actual class PlatformImage( val bitmap: Bitmap, @@ -43,7 +44,7 @@ actual class PlatformImage( actual fun scale( width: Int, height: Int, - ): PlatformImage = PlatformImage(Bitmap.createScaledBitmap(bitmap, width, height, false)) + ): PlatformImage = PlatformImage(bitmap.scale(width, height, false)) actual companion object { actual fun create( diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index 953525260e..bc603dacb0 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.keystorage import android.content.Context +import androidx.core.content.edit import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import kotlinx.coroutines.Dispatchers @@ -86,7 +87,7 @@ actual class SecureKeyStorage private actual constructor() { ) { withContext(Dispatchers.IO) { try { - encryptedPrefs.edit().putString(KEY_PREFIX + npub, privKeyHex).apply() + encryptedPrefs.edit { putString(KEY_PREFIX + npub, privKeyHex) } } catch (e: Exception) { throw SecureStorageException("Failed to save private key", e) } @@ -108,7 +109,7 @@ actual class SecureKeyStorage private actual constructor() { val key = KEY_PREFIX + npub val existed = encryptedPrefs.contains(key) if (existed) { - encryptedPrefs.edit().remove(key).apply() + encryptedPrefs.edit { remove(key) } } existed } catch (e: Exception) { diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt index cba028d6a1..53e63f7e8e 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.nip64Chess import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit actual class ChessDismissedGamesStorage private actual constructor() { private var prefs: SharedPreferences? = null @@ -48,6 +49,6 @@ actual class ChessDismissedGamesStorage private actual constructor() { userPubkey: String, ids: Set, ) { - prefs?.edit()?.putStringSet(prefsKey(userPubkey), ids)?.apply() + prefs?.edit { putStringSet(prefsKey(userPubkey), ids) } } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 9088b7f8b7..d7deb5b6f3 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -54,6 +54,8 @@ import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback import androidx.core.content.ContextCompat import androidx.core.graphics.Insets +import androidx.core.graphics.scale +import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.webkit.JavaScriptReplyProxy @@ -456,7 +458,7 @@ class NappletBrowserActivity : ComponentActivity() { runCatching { val scaled = if (icon.width > ICON_MAX_PX || icon.height > ICON_MAX_PX) { - Bitmap.createScaledBitmap(icon, ICON_MAX_PX, ICON_MAX_PX, true) + icon.scale(ICON_MAX_PX, ICON_MAX_PX) } else { icon } @@ -597,7 +599,7 @@ class NappletBrowserActivity : ComponentActivity() { // Key the persisted choice on the host actually displayed (which may differ from startUrl after // in-page navigation), so the preference sticks to the right site. val liveUrl = if (this::webView.isInitialized) webView.url ?: startUrl else startUrl - val host = runCatching { Uri.parse(liveUrl).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return + val host = runCatching { liveUrl.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return val msg = Message.obtain(null, NappletIpc.MSG_SET_WEB_TOR).apply { data = @@ -613,7 +615,7 @@ class NappletBrowserActivity : ComponentActivity() { private var title: String = "" - private fun barTitle(): String = title.ifBlank { runCatching { Uri.parse(startUrl).host }.getOrNull() ?: getString(CommonsR.string.napplet_untitled) } + private fun barTitle(): String = title.ifBlank { runCatching { startUrl.toUri().host }.getOrNull() ?: getString(CommonsR.string.napplet_untitled) } /** * The top pull-down sheet: a small grabber at the top edge (out of the corner where a site shows its @@ -644,7 +646,7 @@ class NappletBrowserActivity : ComponentActivity() { */ private fun openPermissions() { val liveUrl = if (this::webView.isInitialized) webView.url ?: startUrl else startUrl - val uri = runCatching { Uri.parse(liveUrl) }.getOrNull() ?: return + val uri = runCatching { liveUrl.toUri() }.getOrNull() ?: return val scheme = uri.scheme?.takeIf { it.isNotBlank() } ?: return val host = uri.host?.takeIf { it.isNotBlank() } ?: return val origin = "$scheme://$host" + if (uri.port > 0) ":${uri.port}" else "" @@ -774,6 +776,6 @@ class NappletBrowserActivity : ComponentActivity() { .putExtra(EXTRA_THEME, theme) .putExtra(EXTRA_IS_FAVORITE, isFavorite) // Distinct task identity per URL for documentLaunchMode=intoExisting. - .setData(Uri.parse(url)) + .setData(url.toUri()) } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 43556385a0..9208f9d275 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -45,6 +45,7 @@ import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.annotation.RequiresApi +import androidx.core.graphics.createBitmap import androidx.privacysandbox.ui.provider.toCoreLibInfo import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebMessageCompat @@ -206,7 +207,7 @@ class NappletBrowserService : Service() { val outW = (boxW * zoom).toInt().coerceAtLeast(1) val outH = (boxH * zoom).toInt().coerceAtLeast(1) val t0 = SystemClock.elapsedRealtimeNanos() - val bitmap = Bitmap.createBitmap(outW, outH, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(outW, outH) val canvas = Canvas(bitmap) canvas.drawColor(tab.bgColor) // Map the source rect (centered on cx,cy in view px) into the zoomed output bitmap. diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index b2fe56b483..d038a40ec2 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -45,6 +45,7 @@ import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.annotation.RequiresApi +import androidx.core.graphics.createBitmap import androidx.privacysandbox.ui.provider.toCoreLibInfo import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.ProxyConfig @@ -238,7 +239,7 @@ class NappletHostService : Service() { val outW = (boxW * zoom).toInt().coerceAtLeast(1) val outH = (boxH * zoom).toInt().coerceAtLeast(1) val t0 = SystemClock.elapsedRealtimeNanos() - val bitmap = Bitmap.createBitmap(outW, outH, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(outW, outH) val canvas = Canvas(bitmap) canvas.drawColor(tab.bgColor) canvas.scale(zoom, zoom) From f2f8bdb7655398dd254f1c5a2aa22ce08fdb67c3 Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:13:04 +0000 Subject: [PATCH 03/11] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-hi-rIN/strings.xml | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index a8a851778e..ce35da6323 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -803,16 +803,19 @@ क्या आपका सन्देश %1$s को भेजें। क्या %1$s को सूचित करें। + क्या %1$s से परिचय पत्र तथा गतिविधियों का आवहन करें। क्या %1$s को पत्र भेजें। क्या %1$s को खोलें। यदि आप पुष्टि नहीं करते तो %1$s को आपका सन्देश नहीं भेजा जाएगा। यदि आप नहीं स्वीकारते तो %1$s को इस विषय पर सूचित नहीं किया जाएगा। + यदि नहीं स्वीकारते तो %1$s से परिचय पत्र अथवा गतिविधियाँ आपको यहाँ नहीं दिखेगा। यदि आप स्वीकारते नहीं तो %1$s को आपका सन्देश प्रकाशित नहीं किया जाएगा। यदि आप स्वीकारते नहीं तो आप यहाँ %1$s को नहीं देखेंगे। %1$s तथा अन्य आपका निजी सन्देश भेजें इनको : इनको सूचित करें : + परिचय जानकारी पत्र तथा गतिविधियों का अवरोहण करें यहाँ से : इस शाला को पत्र भेजें इस शाला को खोलें इस पुनःप्रसारक का उपयोग करें @@ -3445,6 +3448,27 @@ लक्ष्य का सूक्ष्मानुकूलन अग्रतम शून्य द्व्यंकों में। क्या खनन करना चाहिए समयसंकट घटनाओं (पुनःप्रसारक प्रमाणीकरण ज्साप अनुरोध धनकोष तथा हस्ताक्षरकर्ता सन्देश पाण्डुलिपियाँ सूचियाँ) के लिए खनन कभी नहीं। + लघु टीकाएँ तथा प्रत्युत्तर + प्राथमिक सार्वजनिक कचरालेख रूप + टिप्पणियाँ + निबन्ध अभिलेख तथा अन्य विषयवस्तुओं के लिए प्रत्युत्तर + सूचनाएँ + पुनःप्रसारक सूचनाओं का तोलन करते हैं उनके लागत के अनुसार + दीर्घरूप तथा उद्दीप्तव्य + निबन्ध तथा उद्दीप्तव्य। विरल। लागत नगण्य + ध्वनि सन्देश + सार्वजनिक ध्वनि पत्र तथा प्रत्युत्तर + पुनःप्रकाशन + उच्च मात्रा सक्रिय प्रयोक्ताओं के लिए + प्रतिक्रियाएँ + उच्चतम मात्रा प्रकार। प्रत्येक इष्टसूचक के लिए खनन का परिणाम विद्युत्कोष का व्यय + सार्वजनिक तथा सीधा चर्चा + खनन विलम्बता से वार्तालाप प्रवाह आहत + निजी सन्देश कोष + सीधासन्देश पुनःप्रसारकों को आगतपेटिका कचरालेख छालन करने में सक्षम करता है। केवल बाहरी कोष का खनन होता है। आपका सन्देश कभी नहीं + अन्य सार्वजनिक विषयवस्तु + मतदान वर्तमानस्थितियाँ वर्गीकृतविज्ञापन तथा अन्य सभी सार्वजनिक + श्रमप्रमाण खनन इसका प्रयोग करें हटाएँ सम्यक From 82f1780b551abd705e7108f294caaf01fa1ec4cb Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:01:21 +0000 Subject: [PATCH 04/11] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-hi-rIN/strings.xml | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index ce35da6323..af4c9b8139 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -663,7 +663,7 @@ पसंदीदा से हटाएँ अपनी विंडो में खोलें इस क्रमक के लिए क्या अभिगम्य है - पृथक पर्यावरण में चलता है। आपके लेखा तक कोई विशेष अभिगमन अधिकार नहीं। + पृथक्कृत पर्यावरण में चलता है। आपके लेखा तक कोई विशेष अभिगमन अधिकार नहीं। इसके लिए क्या अभिगम्य है Tor पर लोड होता है। खुले वेब पर लोड होता है। @@ -799,7 +799,7 @@ किसी को भी सन्देश भेजें अज्ञात लोगों के पुनःप्रसारकों में प्रवेशांकन करें सीधेसन्देश तथा प्रत्युत्तर तथा सूचनाएँ भेजने के लिए। मूलविकल्पतः निष्क्रिय। प्रत्येक बार आप से पूछा जाएगा। क्या इस पुनःप्रसारक को पुष्टि करें कि यह आप हैं। - यह पुनःप्रसारक पहले पुष्टि करना चाहता हैं कि यह वस्तुतः आप हैं। इसके परिचालक देखेगा आपका लेखा कौनसा है। + यह पुनःप्रसारक पहले पुष्टि करना चाहता है कि यह वस्तुतः आप हैं। इसका परिचालक देखेगा आपका लेखा कौनसा है। क्या आपका सन्देश %1$s को भेजें। क्या %1$s को सूचित करें। @@ -1795,7 +1795,7 @@ इस अक्षरराशि को बाँटें जिससे लोग इस समूह से जुड सकें। आमन्त्रण बनाया जा रहा है… आमन्त्रण अक्षरराशि अनुकृत - आमन्त्रण अक्षरराशि (इस केवल आमन्त्रित समूह के लिए): + आमन्त्रण अक्षरराशि (इस आमन्त्रण सीमित समूह के लिए): समूह से जुडें आमन्त्रण अक्षरराशि यह समूह आमन्त्रण सीमित है। आपको दी गई अक्षरराशि प्रविष्ट करें। @@ -3306,7 +3306,7 @@ %1$s सं॰ %2$d अधिक - पीएस१ स्मृति पट्टि अभिलेखन + पीएस१ स्मृति पत्रक अभिलेखन खण्ड %1$d रिक्त छेद @@ -3440,9 +3440,9 @@ हस्ताक्षर सन्देश के अन्त में जोडा जाएगा नए पत्र प्रत्युत्तर उद्धरण अथवा निबन्ध खोलते समय। रिक्त छोड दें अक्षम करने के लिए। आपका हस्ताक्षर - श्रम प्रमाण + श्रमप्रमाण कठिनाई - एक निप॰१३ श्रमप्रमाण का खनन करता है आपके पत्रों के लिए प्रकाशन पूर्व जिससे पुनःप्रसारक तथा पढनेवाले उसका तोलन कर सके कचरालेख के प्रति। उच्चतर मूल्यों के लिए घातांकीय अधिक समय लगेगा। पत्र प्रकाशित होंगे अदृश्यतः खनन पश्चात। + एक निप॰१३ श्रमप्रमाण का खनन करता है आपके पत्रों के लिए प्रकाशन पूर्व जिससे पुनःप्रसारक तथा पढनेवाले उसका तोलन कर सके कचरालेख के प्रति। उच्चतर मूल्यों के लिए घातांकीयतः अधिक समय लगेगा। पत्र प्रकाशित होंगे अदृश्यतः खनन पश्चात। निष्क्रिय विशिष्ट कठिनाई लक्ष्य का सूक्ष्मानुकूलन अग्रतम शून्य द्व्यंकों में। @@ -3469,6 +3469,55 @@ अन्य सार्वजनिक विषयवस्तु मतदान वर्तमानस्थितियाँ वर्गीकृतविज्ञापन तथा अन्य सभी सार्वजनिक श्रमप्रमाण खनन + + श्रमप्रमाण खनन… (%1$d पत्र पंक्ति में) + श्रमप्रमाण खनन… (%1$d पत्र पंक्ति में) + + + %1$s। खनन %2$d द्व्यंक पर + %1$s। खनन %2$d द्व्यंक पर + + + %1$s। %2$d द्व्यंक पर खनन की प्रतीक्षा + %1$s। %2$d द्व्यंक पर खनन की प्रतीक्षा + + + मूलविकल्प (%1$d द्व्यंक) + मूलविकल्प (%1$d द्व्यंक) + + मूलविकल्प (निष्क्रिय) + इस पत्र के लिए निष्क्रिय + + %1$d द्व्यंक + %1$d द्व्यंक + + श्रमप्रमाण खनन + उन पत्रों को दिखाता है जो अपने निप॰१३ श्रमप्रमाण का खनन कर रहे हैं जिससे कि वे सम्पन्न हो सके आप क्रमक छोडने के पश्चात। + निरस्त + लगभग %1$s प्रति पत्र इस यन्त्र पर + १ से॰ से न्यून + + %1$d सेकण्ड + %1$d सेकण्ड + + + %1$d मिनुट + %1$d मिनुट + + + %1$d घण्टा + %1$d घण्टे + + + %1$d दिन + %1$d दिन + + लगभग %1$s शेष + अब किसी भी क्षण + आपका %1$s खनन समाप्त हुआ पर प्रकाशित नहीं हो सका : %2$s + आपका %1$s खनन समाप्त हुआ पर प्रकाशित नहीं हो सका। अगली बार क्रमक आरम्भ होने पर इसका पुनःप्रयास किया जाएगा। + चर्चा सन्देश + सूचना इसका प्रयोग करें हटाएँ सम्यक From 97c7fbfe80f2a207d054a06f34e447fb5bc241e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 02:59:08 +0000 Subject: [PATCH 05/11] fix: compact git-card action buttons and dedupe issue/PR subject heading The NIP-34 status buttons (Mark merged / Close / Reopen) and the PR View-changes button used default Material3 sizing, which reads oversized inside a note card. They now use the same 32dp compact height, tighter content padding and labelMedium text as the app-recommendation buttons. Issue and PR cards also showed the title twice when the authoring client duplicated the subject tag as a leading markdown heading in the content (gitworkshop/ngit do this). The body renderer now strips that first heading when it matches the subject already rendered as the card title. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01USiovhenaijgVEFFzASgfy --- .../amethyst/ui/note/types/Git.kt | 29 ++++++++++++++--- .../ui/note/types/GitPullRequestChanges.kt | 20 ++++++++---- .../ui/note/types/GitStatusActions.kt | 32 ++++++++++++++----- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index 99fb0abf00..218f677add 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -261,12 +261,31 @@ private fun GitMetaRow( /** Shortens a 40-char git object id to its 7-char prefix for display. */ private fun String.shortCommit(): String = if (length > 7) take(7) else this +/** + * Some NIP-34 clients duplicate the subject tag as a leading markdown heading of the + * content ("# Title\n\n…"). The card already renders the subject as its own title, so + * when the first line is a heading matching [subject], drop it from the body. + */ +private fun stripDuplicatedSubject( + content: String, + subject: String?, +): String { + if (subject.isNullOrBlank()) return content + val trimmed = content.trimStart() + if (!trimmed.startsWith("#")) return content + val firstLineEnd = trimmed.indexOf('\n').let { if (it < 0) trimmed.length else it } + val heading = trimmed.substring(0, firstLineEnd).trimStart('#').trim() + if (!heading.equals(subject.trim(), ignoreCase = true)) return content + return trimmed.substring(firstLineEnd).trimStart() +} + /** * The shared markdown body used by patches, issues and pull requests: honors * the collapsed [makeItShort] preview for the logged-in user's own posts and * otherwise renders the full content with sensitivity warnings and uncited * hashtags. The subject, when present, is rendered separately as a title by the - * caller, so it is not inlined here. + * caller, so it is not inlined here — pass it as [renderedSubject] so a copy + * duplicated into the content as a leading heading is stripped. */ @Composable private fun GitMarkdownBody( @@ -277,8 +296,10 @@ private fun GitMarkdownBody( backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, + renderedSubject: String? = null, ) { - LoadDecryptedContent(note, accountViewModel) { body -> + LoadDecryptedContent(note, accountViewModel) { rawBody -> + val body = remember(rawBody, renderedSubject) { stripDuplicatedSubject(rawBody, renderedSubject) } val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) } @@ -537,7 +558,7 @@ private fun RenderGitIssueEvent( Spacer(modifier = HalfDoubleVertSpacer) - GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) + GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav, renderedSubject = subject) if (!makeItShort) { GitStatusActions(note, accountViewModel) @@ -669,7 +690,7 @@ private fun RenderGitPullRequestEvent( Spacer(modifier = HalfDoubleVertSpacer) - GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) + GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav, renderedSubject = subject) if (!makeItShort) { GitPullRequestChanges(cloneUrls, currentCommit, mergeBase, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt index 6146391da6..71148c1c5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt @@ -99,9 +99,13 @@ fun GitPullRequestChanges( when (val s = state) { ChangesState.Idle -> - FilledTonalButton(onClick = { load() }, modifier = Modifier.padding(top = 8.dp)) { - Icon(MaterialSymbols.Code, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.git_pr_view_changes), modifier = Modifier.padding(start = 6.dp)) + FilledTonalButton( + onClick = { load() }, + modifier = Modifier.padding(top = 8.dp).then(CompactButtonHeight), + contentPadding = CompactButtonPadding, + ) { + Icon(MaterialSymbols.Code, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(stringRes(R.string.git_pr_view_changes), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) } ChangesState.Loading -> @@ -129,9 +133,13 @@ fun GitPullRequestChanges( } ChangesState.Failed -> - FilledTonalButton(onClick = { load() }, modifier = Modifier.padding(top = 8.dp)) { - Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.git_pr_changes_retry), modifier = Modifier.padding(start = 6.dp)) + FilledTonalButton( + onClick = { load() }, + modifier = Modifier.padding(top = 8.dp).then(CompactButtonHeight), + contentPadding = CompactButtonPadding, + ) { + Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(stringRes(R.string.git_pr_changes_retry), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt index ea8cc7075a..5afc90e1e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.ButtonDefaults @@ -55,6 +57,10 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent private enum class StatusTarget { OPEN, CLOSED, APPLIED } +/** Compact sizing shared by the small action buttons on git cards. */ +internal val CompactButtonHeight = Modifier.height(32.dp) +internal val CompactButtonPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp) + /** * NIP-34 status controls for an issue, patch or pull request. Visible only to the * people allowed to moderate the thread — the item's author and the repository @@ -85,23 +91,33 @@ fun GitStatusActions( verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (closedOrApplied) { - FilledTonalButton(onClick = { sendStatus(accountViewModel, note, StatusTarget.OPEN) }) { - Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.git_status_reopen), modifier = Modifier.padding(start = 6.dp)) + FilledTonalButton( + onClick = { sendStatus(accountViewModel, note, StatusTarget.OPEN) }, + modifier = CompactButtonHeight, + contentPadding = CompactButtonPadding, + ) { + Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(stringRes(R.string.git_status_reopen), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) } } else { if (isPatchOrPr) { - FilledTonalButton(onClick = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) }) { - Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.git_status_mark_merged), modifier = Modifier.padding(start = 6.dp)) + FilledTonalButton( + onClick = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) }, + modifier = CompactButtonHeight, + contentPadding = CompactButtonPadding, + ) { + Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(stringRes(R.string.git_status_mark_merged), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) } } OutlinedButton( onClick = { sendStatus(accountViewModel, note, StatusTarget.CLOSED) }, colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + modifier = CompactButtonHeight, + contentPadding = CompactButtonPadding, ) { - Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.git_status_close), modifier = Modifier.padding(start = 6.dp)) + Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(stringRes(R.string.git_status_close), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) } } } From e9899f95cbb309538e2352b80ee701557124b004 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 15:20:55 +0000 Subject: [PATCH 06/11] feat: remove border from git event cards Try git patch/issue/PR/repo cards without the 1dp outline so they read like regular posts; the container keeps only the full-width column. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01USiovhenaijgVEFFzASgfy --- .../vitorpamplona/amethyst/ui/note/types/Git.kt | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index 218f677add..db61b42ddd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -21,12 +21,10 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -85,7 +83,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.repoHasFetchableClo import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size16dp import com.vitorpamplona.amethyst.ui.theme.Size5dp @@ -93,7 +90,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size8dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent @@ -102,9 +98,7 @@ import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent -private val CardShape = QuoteBorder private val ChipShape = RoundedCornerShape(8.dp) -private val CardPadding = PaddingValues(start = Size10dp, top = Size10dp, end = Size10dp, bottom = Size5dp) private val HeaderSpacing = Arrangement.spacedBy(Size8dp) private val LinkRowSpacing = Arrangement.spacedBy(Size8dp) @@ -113,15 +107,7 @@ private fun GitCardContainer( modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { - val border = MaterialTheme.colorScheme.subtleBorder - Column( - modifier = - modifier - .fillMaxWidth() - .clip(CardShape) - .border(1.dp, border, CardShape) - .padding(CardPadding), - ) { + Column(modifier = modifier.fillMaxWidth()) { content() } } From d7e163111aad51229c293e7a006ab6a92bf9f386 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 12 Jul 2026 16:52:29 +0100 Subject: [PATCH 07/11] build: remove Kover coverage plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the Kover coverage aggregation (introduced in dca345212b) from every module, the root aggregation block, the Sonar coverage import, the version catalog, and BUILDING.md. With the plugin present the 16 GB CI runner OOM-killed during the Android test+build job even with Kover opt-in disabled; removing it entirely clears the OOM. No other CI changes — the diff versus main is exactly the inverse of dca345212b. Co-Authored-By: Claude Opus 4.8 (1M context) --- BUILDING.md | 19 ------------- amethyst/build.gradle.kts | 1 - build.gradle.kts | 53 ------------------------------------ cli/build.gradle.kts | 1 - commons/build.gradle.kts | 1 - desktopApp/build.gradle.kts | 1 - geode/build.gradle.kts | 1 - gradle/libs.versions.toml | 2 -- nestsClient/build.gradle.kts | 1 - quartz/build.gradle.kts | 1 - quic/build.gradle.kts | 1 - relayBench/build.gradle.kts | 1 - 12 files changed, 83 deletions(-) diff --git a/BUILDING.md b/BUILDING.md index 06dec12b15..54b6021e97 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -278,25 +278,6 @@ call does not guarantee lint finishes first): The reports persist under each module's `build/reports/`, so re-run lint only when you want fresh lint data in the next scan. -### 5. Optional: include test coverage - -Coverage comes from [Kover](https://github.com/Kotlin/kotlinx-kover). The root -`koverXmlReportCoverage` task runs the unit-test suites of the covered modules -(`quartz`, `commons`, `quic`, `nestsClient`, `geode`, `cli`, `relayBench`, -`desktopApp`, and `amethyst`'s `fdroidDebug` variant) and merges the result -into `build/reports/kover/reportCoverage.xml`, which the scanner imports -automatically when it exists. Same recipe as lint — generate first, scan -second: - -```bash -./gradlew koverXmlReportCoverage -./gradlew sonar -``` - -For a human-readable version of the same data, run -`./gradlew koverHtmlReportCoverage` and open -`build/reports/kover/htmlCoverage/index.html`. - Every `sonar.*` entry in `local.properties` is forwarded to the scanner, so any [analysis parameter](https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/analysis-parameters/) can be set there. `sonar.projectKey` / `sonar.projectName` default to the root diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index d7d65cc0cb..e4df9eb8bf 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -6,7 +6,6 @@ plugins { alias(libs.plugins.jetbrainsComposeCompiler) alias(libs.plugins.serialization) alias(libs.plugins.googleKsp) - alias(libs.plugins.kotlinxKover) } fun getCurrentBranch(workingDir: java.io.File): String = diff --git a/build.gradle.kts b/build.gradle.kts index 5d6326492d..6b87fbd049 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,50 +45,6 @@ plugins { alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false alias(libs.plugins.serialization) alias(libs.plugins.googleKsp) apply false - alias(libs.plugins.kotlinxKover) -} - -// Aggregated code coverage: `./gradlew koverXmlReportCoverage` runs the unit -// test suites of the modules below and merges their coverage into -// build/reports/kover/reportCoverage.xml (JaCoCo-compatible XML, importable by -// SonarQube — see BUILDING.md). `koverHtmlReportCoverage` renders the same -// data for humans. A named variant is used instead of Kover's default total -// report because the total for an Android project unconditionally merges -// EVERY build variant — compiling all release/benchmark variants of -// :amethyst just to measure unit tests. The "coverage" variant below picks -// one representative compilation per project: the `jvm` target for KMP/JVM -// modules and `playDebug` for :amethyst — play is the primary flavor and the -// larger code surface (both `add`s are optional so the one block fits every -// project). :nappletHost stays out — it has no tests. -dependencies { - kover(project(":amethyst")) - kover(project(":quartz")) - kover(project(":commons")) - kover(project(":quic")) - kover(project(":nestsClient")) - kover(project(":geode")) - kover(project(":cli")) - kover(project(":relayBench")) - kover(project(":desktopApp")) -} - -kover { - currentProject { - createVariant("coverage") {} - } -} - -subprojects { - plugins.withId("org.jetbrains.kotlinx.kover") { - configure { - currentProject { - createVariant("coverage") { - add("jvm", optional = true) - add("playDebug", optional = true) - } - } - } - } } // Shared app version for all subprojects — read from gradle/libs.versions.toml. @@ -164,15 +120,6 @@ if (sonarEnabled) { .filter { it.startsWith("sonar.") } .forEach { System.setProperty(it, sonarProperties.getProperty(it)) } - // Import Kover's aggregated coverage (run `./gradlew koverXmlReportCoverage` - // first) unless the operator pointed the scanner somewhere else. - if (System.getProperty("sonar.coverage.jacoco.xmlReportPaths") == null) { - System.setProperty( - "sonar.coverage.jacoco.xmlReportPaths", - layout.buildDirectory.file("reports/kover/reportCoverage.xml").get().asFile.absolutePath, - ) - } - // The scanner's sonarResolver task reads AGP's generated-res-values provider // but doesn't depend on the task that produces it — wire it up in every // module that has both (today only :amethyst enables resValues, but the diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 7d26b235b1..489e604740 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -3,7 +3,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.jetbrainsKotlinJvm) application - alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index b71358533c..8182a3604b 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -25,7 +25,6 @@ plugins { alias(libs.plugins.jetbrainsComposeCompiler) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.serialization) - alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index f49cad7ba2..10ca2b3d48 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -6,7 +6,6 @@ plugins { alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.jetbrainsComposeCompiler) - alias(libs.plugins.kotlinxKover) } // RPM rejects dashes in version strings — replace with tilde (~) which RPM uses diff --git a/geode/build.gradle.kts b/geode/build.gradle.kts index 46e9f1cf23..ee636965c7 100644 --- a/geode/build.gradle.kts +++ b/geode/build.gradle.kts @@ -5,7 +5,6 @@ plugins { alias(libs.plugins.serialization) application `java-test-fixtures` - alias(libs.plugins.kotlinxKover) } application { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc1cf2bad1..3d768731a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,7 +35,6 @@ kmpTorResource = "409.5.0" junit = "4.13.2" kchesslib = "1.0.5" kotlin = "2.4.0" -kover = "0.9.8" kotlinxCollectionsImmutable = "0.5.0" kotlinxCoroutinesCore = "1.11.0" kotlinxSerialization = "1.11.0" @@ -247,7 +246,6 @@ jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlin' } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -kotlinxKover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } diff --git a/nestsClient/build.gradle.kts b/nestsClient/build.gradle.kts index bb5acd43a2..3cb05ae465 100644 --- a/nestsClient/build.gradle.kts +++ b/nestsClient/build.gradle.kts @@ -5,7 +5,6 @@ plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.serialization) - alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 108587341f..10c0817ee8 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -9,7 +9,6 @@ plugins { alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.serialization) alias(libs.plugins.vanniktech.mavenPublish) - alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/quic/build.gradle.kts b/quic/build.gradle.kts index 55c28f88d1..def6dad697 100644 --- a/quic/build.gradle.kts +++ b/quic/build.gradle.kts @@ -23,7 +23,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidKotlinMultiplatformLibrary) - alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/relayBench/build.gradle.kts b/relayBench/build.gradle.kts index 831f524883..8a02cc1e96 100644 --- a/relayBench/build.gradle.kts +++ b/relayBench/build.gradle.kts @@ -3,7 +3,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.jetbrainsKotlinJvm) application - alias(libs.plugins.kotlinxKover) } application { From 8e46714fcaa7ae85ac6bde208d2374ca0f775b02 Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:06:46 +0000 Subject: [PATCH 08/11] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-hi-rIN/strings.xml | 36 ++-- .../src/main/res/values-hu-rHU/strings.xml | 9 +- .../src/main/res/values-sl-rSI/strings.xml | 187 +++++++++++++++++- docs/changelog/translators.json | 12 +- 4 files changed, 207 insertions(+), 37 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index af4c9b8139..90d73c13a3 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -791,7 +791,7 @@ अनुकूलित स्पष्ट चयन करें किन पुनःप्रसारकों में प्रवेशांकन करना चाहिए। नीचे जिसको अनुमति नहीं दी गई उन सब के विषय में आप पूछे जाएँगे। मेरे पुनःप्रसारक तथा आयोजन स्थल - प्रवेशांकन करें आपके अपने पुनःप्रसारक तथा सार्वजनिक चर्चाएँ तथा समुदाय तथा सीधे प्रवाहों को जिनसे आप जुडे हैं अथवा प्रियचिह्नित किए हैं। + प्रवेशांकन करें आपके अपने पुनःप्रसारक तथा सार्वजनिक चर्चाएँ तथा समुदाय तथा वर्तमानप्रवाहों को जिनसे आप जुडे हैं अथवा प्रियचिह्नित किए हैं। मेरे द्वारा अनुचरित लोगों के पत्र पढें अनुचरित के पुनःप्रसारकों में प्रवेशांकन करें उनके पत्र प्राप्त करने के लिए। मेरे द्वारा अनुचरित लोगों को सन्देश भेजें @@ -845,7 +845,7 @@ लघु चलचित्र सार्वजनिक चर्चा अनुचरण पोटलियाँ - सीधा प्रसारण + वर्तमानप्रवाह ध्वनि शाला %1$s में एक निलय @@ -871,14 +871,14 @@ मंच छोडें ध्वनिग्राहक मौन ध्वनिग्राहक सक्रिय - सीधा प्रसारण प्रारम्भ… + वर्तमानप्रवाह प्रारम्भ… प्रसारण असफल : %1$s मौनकरण असफल : %1$s कोई अनुमति नहीं निलय ध्वनि चालू रखता है जब शाला खुला है। निलय संयोजित - निलय - सीधाप्रसार + निलय - वर्तमान लौटने के लिए दबाएँ। अवरोधन निलय जुडें @@ -906,7 +906,7 @@ %1$d श्रोता %1$d श्रोतागण - सीधाप्रसार + वर्तमान चर्चा श्रोतागण हाथ @@ -951,7 +951,7 @@ इस शाला में ध्वनि सेवा अथवा गन्तव्य उपलब्ध नहीं। निमन्त्रक को इसके विवरणों का शोधन करना चाहिए श्रोतागण जुडने से पहले। पीछे स्थान आरम्भण - सीधाप्रसार चालू + वर्तमानप्रवाह चालू समयनिर्धारित निकटकाल समाप्त समाप्त %1$s पूर्व @@ -1605,7 +1605,7 @@ विषयवस्तु आकार संयोजकता अभिगमन नियन्त्रण - न्यूनतम श्रमप्रमाण + न्यूनतम श्रमप्रमाण कठिनाई प्रमाणीकरण आवश्यक भुगतान आवश्यक अधिकतम संदेश लम्बाई @@ -1619,13 +1619,13 @@ Token कॉपी करें अन्य क्रमक में खोलें कोई लैटनिंग पता स्थापित नहीं - तत्क्षणप्रसार - जालरहित + वर्तमान + असंयोजित समाप्त समय निर्धारित निजी - तत्क्षणप्रसार कट गया - तत्क्षणप्रसार समाप्त + वर्तमानप्रवाह कट गया + वर्तमानप्रवाह समाप्त खुला निजी आवृत @@ -1639,7 +1639,7 @@ पठितव्य सूचनावली विधियाँ व्यापारक्षेत्र - तत्क्षणप्रसार + वर्तमानप्रवाह समुदाय चर्चाएँ अनुमति प्राप्त पत्र @@ -2520,7 +2520,7 @@ बाँटें अथवा अभिलेखन करें चलचित्र योजक बाहर बाँटें संचारयन्त्र में अभिलेखन करें - चलचित्र का अवरोहण करें आपके यन्त्र तक (सीधे प्रसारों में अदृश्य) + चलचित्र का अवरोहण करें आपके यन्त्र तक (वर्तमानप्रवाहों में अदृश्य) चित्र में चित्र चलचित्र दिखाएँ तैरते गवाक्ष में (अदृश्य यदि अनालम्बित) यन्त्र पर निक्षेपण @@ -3023,8 +3023,8 @@ साहसिक दृश्य साहसिक पठन नाम युक्त स्मर्त्तव्य सूची - सीधी बातचीत - सीधा प्रसारण + वर्तमान बातचीत + वर्तमानप्रवाह ज्साप नोस्टर धनकोष अनुरोध नोस्टर धनकोष प्रत्युत्तर @@ -3246,7 +3246,7 @@ आरोहण कृत %2$d में से %1$d घटना प्रकाशन चालू… दृश्याभिलेख प्रकाशित - आपका उच्छनिरूपण दृश्याभिलेख नोस्टर पर चलन्त है। + आपका उच्छनिरूपण दृश्याभिलेख नोस्टर पर वर्तमान है। कुछ गडबड हुआ हो गया पुनः प्रयास करें @@ -3431,7 +3431,7 @@ लेख शोधन प्रस्ताव करें यन्त्र स्थित एआई॰ प्रतिरूप का प्रयोग करता है लेख सुधार तथा स्वर परिवर्तन प्रस्तावों के लिए। पदचिह्न युक्त प्रसारण - पदचिह्न युक्त प्रसारक का उपयोग करें घटनाओं को भेजते समय। तत्काल प्रगति दिखाता है तथा प्रत्येक पुनःप्रसारक की स्थिति प्रसारण करते समय। + पदचिह्न युक्त प्रसारक का उपयोग करें घटनाओं को भेजते समय। वर्तमान प्रगति दिखाता है तथा प्रत्येक पुनःप्रसारक की स्थिति प्रसारण करते समय। व्यायामों का सुझाव बाँटनें के लिए सम्पन्न व्यायाम पढता है स्वास्थ्य संयोजन से (सामसंग स्वास्थ्य तथा गूगिल फिट तथा फिटबिट तथा गार्मिन इत्यादि) तथा पत्र के रूप में बाँटने का सुझाव देता है। अनुमति अनुरोध केवल संयोजन करने पर। सम्पादन स्थापना विकल्प @@ -3462,7 +3462,7 @@ उच्च मात्रा सक्रिय प्रयोक्ताओं के लिए प्रतिक्रियाएँ उच्चतम मात्रा प्रकार। प्रत्येक इष्टसूचक के लिए खनन का परिणाम विद्युत्कोष का व्यय - सार्वजनिक तथा सीधा चर्चा + सार्वजनिक तथा वर्तमान चर्चा खनन विलम्बता से वार्तालाप प्रवाह आहत निजी सन्देश कोष सीधासन्देश पुनःप्रसारकों को आगतपेटिका कचरालेख छालन करने में सक्षम करता है। केवल बाहरी कोष का खनन होता है। आपका सन्देश कभी नहीं diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index ef95c83d52..c667c9f116 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -139,7 +139,8 @@ Zap-igazolás Közvetlen lightning-fizetés - nem lesz zapigazolás közzétéve a Nostr-on. Egy láncon belüli zapigazolás közzé lesz téve a Nostr-on, így a címzett megtalálhatja a kifizetést. - Fizetés közvetlenül a láncon belüli pénztárcából a profil megadott bitcoin-címére (%1$s) - nem lesz zapigazolás közzétéve. + Fizetés közvetlenül a láncon belüli pénztárcából a profil megadott bitcoin-címére (%1$s) - nem lesz zapigazolás közzétéve. + A nutzap-esemény magát az ecasht kézbesíti, így az mindig közzé lesz téve. Számla kérése… Számla kérése a Nostr hálózaton keresztül… @@ -793,7 +794,7 @@ Saját átjátszók és helyszínek Bejelentkezés a saját átjátszóiba, valamint azokba a nyilvános csevegésekbe, közösségekbe és élő közvetítésekbe, amelyekhez csatlakozott vagy amelyeket kedvelt. Követett személyek bejegyzéseinek olvasása - Bejelentkezés egy követett személy átjátszóiba a bejegyzéseinek letöltéséhez. + Bejelentkezés egy követett felhasználó átjátszóiba, hogy letölthesse a profiladatait, bejegyzéseit és a bejegyzéseihez kapcsolódó interakciókat. Üzenet küldése a követett személyeknek Bejelentkezés egy követett személy átjátszóiba a közvetlen üzenetek, válaszok és értesítések küldéséhez. Üzenet küldése bárkinek @@ -803,13 +804,13 @@ Elküldi az üzenetet neki: %1$s? Értesíti őt: %1$s? - Betölti a bejegyzéseket tőle: %1$s? + Profil, bejegyzések és interakciók betöltése tőle: %1$s? Közzéteszi itt: %1$s? Megnyitja ezt: %1$s? Ha nem teszi meg, az üzenete nem lesz kézbesítve neki: %1$s. Ha nem teszi meg, %1$s nem kap erről értesítést. - Ha nem teszi meg, nem fogja itt látni a bejegyzéseket tőle: %1$s. + Ha nem teszi meg, akkor nem fogja itt látni %1$s profilját, bejegyzéseit és interakcióit. Ha nem teszi meg, az üzenete nem lesz közzétéve itt: %1$s. Ha nem teszi meg, nem fogja itt látni ezt: %1$s. %1$s és mások diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 028de5ed97..7470dcfef9 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -811,13 +811,50 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Avtentikacija releja Preverjanje pristnosti (Auth), rele (Relay), podpis (Sign), preverjanje (Verify), NIP-42, identiteta Globalna pravila + V kaj se prijaviti Vedno zahtevaj avtentikacijo Podpiši avtentikacijske izzive za vsak relej, ki to zahteva Nikoli ne avtenticiraj Prezri avtentikacijske izzive vseh relejev + Po meri + Izberite natančno, v katere releje se želite prijaviti. Za vse, česar spodaj niste dovolili, boste vprašani naknadno. + Moji releji in prizorišča + Prijavite se v svoje releje ter v javne klepete, skupnosti in prenose v živo, ki jim sledite ali ste jih dodali med priljubljene. + Preberi objave ljudi, ki jim sledim + Prijavite se v releje oseb, ki jim sledite, za prenos podatkov njihovega profila, objav in odzivov na objave. + Pošlji sporočila osebam, ki jim sledim + Prijavite se v releje oseb, ki jim sledite, da jim lahko pošiljate zasebna sporočila (ZS), odgovore in obvestila. + Pošlji sporočilo vsem + Prijavite se v releje neznanih oseb za pošiljanje zasebnih sporočil, odgovorov in obvestil. Privzeto izklopljeno; vsakič boste vprašani za dovoljenje. + Potrdite temu releju, da ste to vi? + Ta rele želi najprej potrditi, da ste to vi. Upravljavec releja bo videl, kateri račun uporabljate. + Pošljem sporočilo %1$s? + Obvestim %1$s? + Naložim profil, objave in odzive iz %1$s? + Pošljem v %1$s? + Odprem %1$s? + V nasprotnem primeru sporočilo za %1$s ne bo dostavljeno. + V nasprotnem primeru %1$s o tem ne bo obveščen. + V nasprotnem primeru tukaj ne boste videli profila, objav ali odzivov od %1$s. + V nasprotnem primeru vaše sporočilo za %1$s ne bo objavljeno. + V nasprotnem primeru tukaj ne boste videli %1$s. + %1$s in ostali + Pošlji zasebno sporočilo: + Obvesti: + Prenesi podatke o profilu, objave in odzive iz: + Pošlji v to sobo + Odpri to sobo + Uporabi ta rele + Poveži se z lastnim relejem + Dovoli tokrat + Vedno dovoli ta rele + Vedno dostavi moja sporočila + Blokiraj ta rele Prilagoditve po posameznem releju + Pozabi + Zadnjič uporabljen pred %1$s Brez preglasitev na posameznih relejih — globalna politika velja povsod Dovoli Zavrni @@ -855,7 +892,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Sporočila ni bilo mogoče poslati Nimate nameščene aplikacije za odpiranje te povezave. Zaprem to sobo? - Vsi udeleženci bodo odklopljeni. Soba bo v viru vsebin prikazana kot ZAPRTA. + Vsi udeleženci bodo odklopljeni. Soba bo v viru vsebin prikazana kot KONČANA. Zapri sobo Napaka pri zvoku: %1$s Govori @@ -1331,7 +1368,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Privzeti seznam sledenih V moji okolici Globalno - Urejeno + Izbrani Moje Seznam utišanih Seznam sledenih @@ -1418,7 +1455,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Označi kot prebrano Novo sporočilo Novi zapi - Odzivi + Odzivne ikone Prejmi obvestila o odzivih na objave %1$s se je odzval na vašo objavo za %1$s @@ -1625,11 +1662,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Odpri z drugo aplikacijo Lightning naslov ni nastavljen V ŽIVO - ODKLOPLEN + NEDOSEGLJIV KONČANO PLANIRANO ZASEBNO - Prenos v živo je odklopljen + Prenos v živo je končan Prenos v živo je končan ODPRTO ZASEBNO @@ -1656,7 +1693,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nastavitve aplikacije Nastavitve uporabnika Prevodi - Reakcije + Odzivi Nastavitve Nastavitve računa Nastavitve aplikacije @@ -1791,6 +1828,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Strni skupine posameznega releja v eno vrstico, prikazano pri njegovem najnovejšem sporočilu. Prikaz skupin NIP-29 Skupine relejev + Skupine Sporočila Ustvari skupino Ta relej ne oglašuje podpore za skupine NIP-29. Skupina, ustvarjena tukaj, ne bo delovala – relej ne bo upravljal njenega imena, članov in sporočil. Izberite rele, ki podpira skupine na ravni releja. @@ -1860,6 +1898,41 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nov niz objav Brez naslova Naslov + Napiši kaj… + Pošlji + Najdi skupine + Brskajte po skupinah, ki jih gosti rele. Prilepite naslov releja ali izberite enega spodaj. + Naslov releja + Brskaj + Releji na katerih ste vi + Popularni releji + Ni sporočil + Pridružite se tej skupini, da boste lahko pošiljali sporočila. + Ta skupina je zaprtega tipa – za objavljanje potrebujete povabilo. + + %1$d član + %1$d člana + %1$d člani + %1$d članov + + + %1$d sporočilo + %1$d sporočila + %1$d sporočila + %1$d sporočil + + + %1$d+ sporočilo + %1$d+ sporočila + %1$d+ sporočila + %1$d+ sporočil + + + %1$d uporabnik, ki mu sledite + %1$d uporabnika, ki jima sledite + %1$d uporabniki, ki jim sledite + %1$d uporabnikov, ki jim sledite + Zasebno Za Zadeva @@ -3026,7 +3099,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Posodobitev zahtevka (PR) Ciljni znesek zapov Sledeni ključniki - Osvetlitve + Poudarki Http Auth Indeksiraj seznam relejev Pustolovski prolog @@ -3067,7 +3140,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Zasebni releji Proxy releji Javno sporočilo - Odzivne ikone + Odzivi Kartica kontakta Avtorizacija releja Odkrivanje relejev @@ -3160,7 +3233,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, profili izhodni seznami Nazadnje viden pred %1$s - Nazadje viden na %1$s (%2$s nazaj) + Nazadje viden %1$s (pred %2$s) Nazadnje viden prav zdaj Nikoli viden @@ -3472,6 +3545,102 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Podpis Doda se na konec sporočila pri ustvarjanju nove objave, odgovora, citata ali članka. Pustite prazno, da onemogočite. Vaš podpis + Dokazilo o delu (PoW) + Težavnost + Pred objavo sporočila, rudari (NIP-13) dokaz o delu (PoW), da jih lahko releji in bralci ločijo od neželene vsebine (spam). Višje vrednosti zahtevajo eksponentno več časa za rudarjenje. Objave se po končanem rudarjenju samodejno objavijo v ozadju. + Ugasni + Težavnost po meri + Nastavite cilj v številu začetnih ničel + Kaj rudarit + Časovno kritični dogodki (avtentikacija relejev, zahteve za zap-e, sporočila denarnice in podpisnika, osnutki, seznami) se nikoli ne rudarijo. + Kratki zapiski in odgovori + Glavno javni vir za vsiljeno vsebino + Komentarji + Odgovori na članke, datoteke in drugo vsebino + Prijave + Releji ocenjujejo poročila glede na njihove stroške + Dolg zapis in poudarki + Članki in poudarki; redko, strošek je zanemarljiv + Glasovna sporočila + Javna glasovna sporočila in odgovori + Deli dalje + Višja glasnost za aktivne uporabnike + Odzivi + Tip (kind) z najvišjim obsegom; rudarjenje vsakega všečka porablja baterijo + Javni in pogovori v živo + Zamik zaradi rudarjenja, moti tekočemu pogovoru + Ovoji zasebnih sporočil + Releji za zasebna sporočila filtrirajo vsiljeno vsebino; rudari se le zunanji ovoj, nikoli vaše sporočilo. + Ostala javna vsebina + Ankete, statusi v živo, mali oglasi in vse ostalo javno + Rudarjenje dokaza o delu (PoW) + + Rudarjenje dokaza o delu… (%1$d objava v čakalni vrsti) + Rudarjenje dokaza o delu… (%1$d objavi v čakalni vrsti) + Rudarjenje dokaza o delu… (%1$d objave v čakalni vrsti) + Rudarjenje dokaza o delu… (%1$d objav v čakalni vrsti) + + + %1$s • rudarjenje pri %2$d bitu + %1$s • rudarjenje pri %2$d bitih + %1$s • rudarjenje pri %2$d bitih + %1$s • rudarjenje pri %2$d bitih + + + %1$s • čakanje na rudarjenje pri %2$d bitu + %1$s • čakanje na rudarjenje pri %2$d bitih + %1$s • čakanje na rudarjenje pri %2$d bitih + %1$s • čakanje na rudarjenje pri %2$d bitih + + + Privzeto (%1$d bit) + Privzeto (%1$d bita) + Privzeto (%1$d biti) + Privzeto (%1$d bitov) + + Privzeto (izklopljeno) + Izklopljeno za ta zapisek + + %1$d bit + %1$d bita + %1$d biti + %1$d bitov + + Rudarjenje z dokazom o delu (PoW) + Prikazuje objave, ki še rudarijo svoj NIP-13 dokaz o delu, da lahko zaključijo postopek, potem ko zapustite aplikacijo. + Prekliči + ≈ %1$s na objavo v tej napravi + <1 s + + %1$d sekunda + %1$d sekundi + %1$d sekunde + %1$d sekund + + + %1$d minuta + %1$d minuti + %1$d minut + %1$d minut + + + %1$d ura + %1$d uri + %1$d ure + %1$d ur + + + %1$d dan + %1$d dneva + %1$d dni + %1$d dni + + ≈ %1$s še ostane + Vsak čas bo + Vaš %1$s je zaključil rudarjenje, vendar ga ni bilo mogoče objaviti: %2$s + Vaš %1$s je končal z rudarjenjem, vendar ga ni bilo mogoče objaviti. Ponovni poskus bo izveden ob naslednjem zagonu aplikacije. + Sporočilo klepeta + Prijavi Uporabi to Prekliči Pravilno diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index b56145cbb7..976a4a9801 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -134,6 +134,12 @@ "Hindi" ] }, + { + "user": "StellarStoic", + "languages": [ + "Slovenian" + ] + }, { "user": "summoner001", "languages": [ @@ -146,12 +152,6 @@ "Chinese Simplified" ] }, - { - "user": "StellarStoic", - "languages": [ - "Slovenian" - ] - }, { "user": "anthony-robin", "languages": [ From f8416f4940f65694581093aecc1477e5ce082b29 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 12 Jul 2026 19:29:07 -0400 Subject: [PATCH 09/11] feat(relay-auth): per-account NIP-42 override store + state on Account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-relay ALLOW/DENY AUTH overrides (and the policy ledger that reads them) lived in one process-wide DataStore keyed only by relay URL, so a DENY set for one account silently applied to every logged-in account. Move them to a per-account file under accounts// and warm-cache the overrides in memory on the Account (RelayAuthPermissionCache) so an AUTH challenge is answered without a disk read. DataStoreRelayAuthPermissionStore now shares one DataStore per file path — DataStore v1 forbids two live instances on one file, and loadAccount can build the store more than once per account. Introduces the per-account state holders wired up by the following commits: Account.relayAuthPermissions, relayAuthLedger and relayNotifications. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 43 +++++++ .../model/accountsCache/AccountCacheState.kt | 6 + .../DataStoreRelayAuthPermissionStore.kt | 19 +++- .../model/InMemoryRelayAuthPermissionStore.kt | 68 ++++++++++++ .../model/RelayAuthPermissionCache.kt | 105 ++++++++++++++++++ 5 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 798aef0e4a..c374cc6d95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -51,6 +51,8 @@ import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory @@ -127,6 +129,10 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.InMemoryRelayAuthPermissionStore +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionCache +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger +import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor @@ -170,6 +176,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -339,6 +346,7 @@ class Account( val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null, val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null, val powQueue: () -> PoWPublishQueue? = { null }, + relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), ) : IAccount { private var userProfileCache: User? = null @@ -353,6 +361,41 @@ class Account( val userMetadata = UserMetadataState(signer, cache, scope, settings) + // Per-account NIP-42 ALLOW/DENY overrides, warm-cached in memory so a relay AUTH challenge is + // answered without a disk read. Backed by a per-account file (see AccountCacheState). + val relayAuthPermissions = RelayAuthPermissionCache(relayAuthPermissionStore, scope) + + // Per-account NIP-42 policy evaluator (blocked → per-relay override → global policy → prompt), + // reading THIS account's own toggles, relay lists and follow graph. Cached here so every AUTH + // path (foreground screen + background notification consumer) shares one instance, and so an + // AUTH challenge is decided per account instead of folding every logged-in account together. + val relayAuthLedger = + RelayAuthPermissionLedger( + store = relayAuthPermissions, + globalPolicy = { settings.defaultRelayAuthPolicy.value }, + customToggles = { + RelayAuthCustomToggles( + myRelaysAndVenues = settings.relayAuthTrustMyRelaysAndVenues.value, + readFollows = settings.relayAuthTrustReadFollows.value, + messageFollows = settings.relayAuthTrustMessageFollows.value, + messageStrangers = settings.relayAuthTrustMessageStrangers.value, + ) + }, + isInMyRelayList = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in trustedRelays.flow.value } ?: false }, + isBlocked = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in blockedRelayList.flow.value } ?: false }, + isFollowed = { pubkey -> pubkey in allFollows.flow.value.authors }, + isTrustedVenue = { venueId -> + venueId in publicChatList.flowSet.value || + venueId in communityList.flowSet.value || + Address.parse(venueId)?.pubKeyHex?.let { it in allFollows.flow.value.authors } == true + }, + ) + + // Per-account relay NOTIFY (payment-prompt) cache. NotifyCoordinator attributes each incoming + // NOTIFY to the account whose AUTH the relay rejected and drops it here, so a prompt for one + // account never surfaces under another (the old cache was a process-wide singleton). + val relayNotifications = NotifyRequestsCache() + override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings) val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 32a6183eec..7a07f95469 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -224,6 +225,10 @@ class AccountCacheState( null } + // Per-account NIP-42 ALLOW/DENY overrides live in this account's own dir, so a DENY for one + // account never leaks into another (the store used to be a single app-wide file). + val relayAuthPermissionStore = DataStoreRelayAuthPermissionStore(accountDir) + return Account( settings = accountSettings, signer = signerWithClientTag, @@ -247,6 +252,7 @@ class AccountCacheState( marmotMessageStore = marmotMessageStore, marmotKeyPackageStore = marmotKeyPackageStore, powQueue = powQueue, + relayAuthPermissionStore = relayAuthPermissionStore, ).also { newAccount -> accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt index abdb5c5e86..fe2e79fffd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.first import java.io.File import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap /** * Single-file DataStore-backed [RelayAuthPermissionStore]. All per-relay ALLOW/DENY overrides @@ -45,11 +46,10 @@ class DataStoreRelayAuthPermissionStore( ) : RelayAuthPermissionStore { constructor(context: Context) : this(context.applicationContext.filesDir) - private val store: DataStore by lazy { - PreferenceDataStoreFactory.create( - produceFile = { File(filesDir, "datastore/relay_auth.preferences_pb") }, - ) - } + // DataStore v1 throws if two instances are ever active on the same file. loadAccount can build + // this store more than once for the same account (re-login, cache races), so the underlying + // DataStore is shared per absolute file path across the process instead of created per instance. + private val store: DataStore get() = dataStoreFor(File(filesDir, "datastore/relay_auth.preferences_pb")) override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? { val raw = store.data.first()[decisionKey(relayUrl)] ?: return null @@ -198,6 +198,15 @@ class DataStoreRelayAuthPermissionStore( private fun lastUsedKey(relayUrl: String) = stringPreferencesKey("$LAST_USED_PREFIX${hash(relayUrl)}") companion object { + // One DataStore per file path, process-wide. computeIfAbsent runs the factory at most once + // per path, so concurrent constructions for the same account share a single active DataStore. + private val stores = ConcurrentHashMap>() + + private fun dataStoreFor(file: File): DataStore = + stores.computeIfAbsent(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + private const val DECISION_PREFIX = "allow:" private const val URL_PREFIX = "url:" private const val RATIONALE_PREFIX = "rat:" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt new file mode 100644 index 0000000000..eb4c514f15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt @@ -0,0 +1,68 @@ +/* + * 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.relayClient.authCommand.model + +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore + +/** + * Volatile, non-persistent [RelayAuthPermissionStore]. Used as the default for [com.vitorpamplona.amethyst.model.Account] + * instances built without a disk-backed store (Compose previews, unit tests, the mock account view + * models) so nothing on the auth path has to null-check the store. + */ +class InMemoryRelayAuthPermissionStore : RelayAuthPermissionStore { + private val decisions = mutableMapOf() + private val rationale = mutableMapOf>>() + + override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? = decisions[relayUrl] + + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) { + decisions[relayUrl] = decision + } + + override suspend fun clearDecision(relayUrl: String) { + decisions.remove(relayUrl) + } + + override suspend fun allDecisions(): Map = decisions.toMap() + + override suspend fun recordUse( + relayUrl: String, + additions: Map>, + ) { + val forRelay = rationale.getOrPut(relayUrl) { mutableMapOf() } + for ((kind, pubkeys) in additions) { + forRelay.getOrPut(kind) { mutableSetOf() }.addAll(pubkeys) + } + } + + override suspend fun loadRationale(relayUrl: String): Map> = rationale[relayUrl]?.mapValues { it.value.toSet() } ?: emptyMap() + + override suspend fun allRationales(): Map>> = rationale.mapValues { entry -> entry.value.mapValues { it.value.toSet() } } + + override suspend fun clearRationale(relayUrl: String) { + rationale.remove(relayUrl) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.kt new file mode 100644 index 0000000000..4f2ab00362 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.kt @@ -0,0 +1,105 @@ +/* + * 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.relayClient.authCommand.model + +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * In-memory, warm-cached view over one account's [RelayAuthPermissionStore] (a per-account file — + * see [DataStoreRelayAuthPermissionStore] built from `accounts//`). Held on the + * [com.vitorpamplona.amethyst.model.Account] like the other state caches. + * + * The ALLOW/DENY overrides are the only thing read on the hot NIP-42 decision path + * ([RelayAuthPermissionLedger.decide]). They are snapshotted into memory once, right after the + * account loads, and served from there — so an incoming AUTH challenge is answered without a disk + * read (a slow disk hit here would stall login-time relay auth). Writes update memory *and* disk. + * + * Rationale + last-used are read only by the settings screen, off the hot path, so they pass + * straight through to disk. Implements [RelayAuthPermissionStore] so it drops into every existing + * call site (the ledger and the settings screen) unchanged. + */ +class RelayAuthPermissionCache( + private val disk: RelayAuthPermissionStore, + scope: CoroutineScope, +) : RelayAuthPermissionStore { + private val loaded = CompletableDeferred() + private val _overrides = MutableStateFlow>(emptyMap()) + + /** Per-relay overrides for this account, observable so the settings screen refreshes on change. */ + val overrides: StateFlow> = _overrides.asStateFlow() + + init { + scope.launch { + _overrides.value = disk.allDecisions() + loaded.complete(Unit) + } + } + + /** Non-suspending override lookup — returns null until the initial warm load finishes. */ + fun decisionOrNull(relayUrl: String): RelayAuthDecision? = _overrides.value[relayUrl] + + override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? { + loaded.await() + return _overrides.value[relayUrl] + } + + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) { + disk.storeDecision(relayUrl, decision) + _overrides.update { it + (relayUrl to decision) } + } + + override suspend fun clearDecision(relayUrl: String) { + disk.clearDecision(relayUrl) + _overrides.update { it - relayUrl } + } + + override suspend fun allDecisions(): Map { + loaded.await() + return _overrides.value + } + + // --- rationale + last-used: settings-screen only, never on the auth decision path --- + + override suspend fun recordUse( + relayUrl: String, + additions: Map>, + ) = disk.recordUse(relayUrl, additions) + + override suspend fun loadRationale(relayUrl: String): Map> = disk.loadRationale(relayUrl) + + override suspend fun allRationales(): Map>> = disk.allRationales() + + override suspend fun clearRationale(relayUrl: String) = disk.clearRationale(relayUrl) + + override suspend fun allLastUsed(): Map = disk.allLastUsed() +} From 0ec63958b87f12a55077de1a53d7800c0718cd75 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 12 Jul 2026 19:29:22 -0400 Subject: [PATCH 10/11] fix(relay-auth): only AUTH a relay with accounts that actually use it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One shared NostrClient serves every logged-in account, so a relay's NIP-42 AUTH challenge is not tied to any one of them. AuthCoordinator used to fold every account's verdict into one decision and then sign with EVERY account (or a random throwaway key), so a paid relay like inbox.nostr.wine billed and de-anonymized accounts that never used it — including via a merged read filter that merely named them, and via account switches / the background notification consumer. Decide and sign PER ACCOUNT now: an account signs only if the relay is in its own relay list, or it is publishing its own event there (RelayAuthFirstParty), AND its own ledger verdict (Account.relayAuthLedger) allows it. A subscription merely naming the account is NOT first-party — that is the merged-filter false positive. The random-ephemeral-key fallback is removed. Own-inbox reads still qualify: the relay serving them is by definition in the account's own list. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../compose/AccountDataSourceSubscription.kt | 47 +------ .../authCommand/model/AuthCoordinator.kt | 121 +++++++++++------- .../model/ListWithUniqueSetCache.kt | 6 + .../authCommand/model/RelayAuthFirstParty.kt | 56 ++++++++ .../relayauth/RelayAuthSettingsScreen.kt | 8 +- .../model/RelayAuthFirstPartyTest.kt | 74 +++++++++++ 6 files changed, 216 insertions(+), 96 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt index c8a18ce7ef..194c106cea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt @@ -24,16 +24,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator -import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull - -/** The owner pubkey of an addressable venue (`kind:pubkey:dTag`), or null for a bare channel id. */ -private fun venueOwnerPubkey(venueId: String): String? = Address.parse(venueId)?.pubKeyHex @Composable fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator) @@ -45,51 +38,17 @@ fun RelayAuthSubscription( ) { val account = accountViewModel.account + // The per-account NIP-42 policy ledger now lives on Account (account.relayAuthLedger), so this + // only has to register the account itself. The coordinator decides + signs per account. val state = remember(accountViewModel) { ScreenAuthAccount(account) } - val ledger = - remember(accountViewModel) { - RelayAuthPermissionLedger( - store = Amethyst.instance.relayAuthPermissionStore, - globalPolicy = { account.settings.defaultRelayAuthPolicy.value }, - customToggles = { - RelayAuthCustomToggles( - myRelaysAndVenues = account.settings.relayAuthTrustMyRelaysAndVenues.value, - readFollows = account.settings.relayAuthTrustReadFollows.value, - messageFollows = account.settings.relayAuthTrustMessageFollows.value, - messageStrangers = account.settings.relayAuthTrustMessageStrangers.value, - ) - }, - isInMyRelayList = { relayUrl -> - val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false - normalized in account.trustedRelays.flow.value - }, - isBlocked = { relayUrl -> - val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false - normalized in account.blockedRelayList.flow.value - }, - // Any follow list (kind 3, follow sets, etc.) counts as trusting the counterparty - // enough to reveal our identity to a relay that serves them. - isFollowed = { pubkey -> pubkey in account.allFollows.flow.value.authors }, - // A venue (public chat / community / live stream) is trusted if we've joined it, or - // its owner — the pubkey in a `kind:pubkey:dTag` address — is someone we follow. - isTrustedVenue = { venueId -> - venueId in account.publicChatList.flowSet.value || - venueId in account.communityList.flowSet.value || - venueOwnerPubkey(venueId)?.let { it in account.allFollows.flow.value.authors } == true - }, - ) - } - - DisposableEffect(state, ledger) { + DisposableEffect(state) { dataSource.subscribe(state) - dataSource.subscribeLedger(ledger) onDispose { dataSource.unsubscribe(state) - dataSource.unsubscribeLedger(ledger) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 533d7c7c67..441d09feb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -22,11 +22,14 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope @@ -36,33 +39,19 @@ class ScreenAuthAccount( @Stable class AuthCoordinator( - client: INostrClient, + val client: INostrClient, scope: CoroutineScope, val promptBus: RelayAuthPromptBus = RelayAuthPromptBus(), ) { private val authWithAccounts = ListWithUniqueSetCache { it.account } - private val tempAccount by lazy { - NostrSignerSync() - } - - @Volatile private var relayLedgers: List = emptyList() - - fun subscribeLedger(ledger: RelayAuthPermissionLedger) { - synchronized(this) { relayLedgers = relayLedgers + ledger } - } - - fun unsubscribeLedger(ledger: RelayAuthPermissionLedger) { - synchronized(this) { relayLedgers = relayLedgers - ledger } - } val receiver = RelayAuthenticator( client, scope, signWithAllLoggedInUsers = { relayUrl, authTemplate -> - // Reconstruct *why* this relay wants auth from what we're doing with it, so each - // account's ledger can apply follow-based trust and (later) explain the prompt. - // Built lazily so the no-ledgers auto-allow path below doesn't pay for it. + // Reconstruct *why* this relay wants auth from what the shared client is doing with + // it. Built lazily so accounts that fail the first-party gate below don't pay for it. val context by lazy(LazyThreadSafetyMode.NONE) { RelayAuthContext( @@ -74,45 +63,83 @@ class AuthCoordinator( ), ) } - val currentLedgers = relayLedgers - // Ask the user (only in the ASK case) and fold every account's verdict into one - // decision plus an optional per-relay override to remember. - val outcome = - AuthDecisionResolver.resolve(currentLedgers.map { it.decide(context) }) { - promptBus.requestDecision(relayUrl, context.purposes) - } - outcome.remember?.let { decision -> - currentLedgers.firstOrNull()?.setDecision(relayUrl.url, decision) - } - val shouldAuth = outcome.shouldAuth - if (shouldAuth) { - // Remember why we granted this relay so the settings screen can explain it. - currentLedgers.firstOrNull()?.recordGrant(context) + // One socket is shared by every logged-in account, so an AUTH challenge is not tied + // to any single one of them. We answer PER ACCOUNT: an account only reveals its + // identity to a relay it has a first-party reason to be on (its own inbox/outbox + // traffic, or a relay it configured) AND its own ledger verdict allows it. This is + // what stops account B — or a throwaway key — being billed / de-anonymized on a + // relay only account A uses (the inbox.nostr.wine over-AUTH bug): unlike the old + // "any account ALLOWs → sign with everyone (else a random key)" path, a bystander + // account never signs, and there is no random-key fallback. + val signed = mutableListOf() + var askChoice: UserAuthChoice? = null - // distinct() returns Set (the key type U of ListWithUniqueSetCache) - val results = - authWithAccounts.distinct().mapNotNull { - if (it.signer.isWriteable()) { - try { - it.signer.sign(authTemplate) - } catch (e: Exception) { - Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) - null + authWithAccounts.distinctValues().forEach forEachAccount@{ screen -> + val account = screen.account + if (!account.signer.isWriteable()) return@forEachAccount + if (!isFirstParty(account, relayUrl)) return@forEachAccount + + val approve = + when (account.relayAuthLedger.decide(context)) { + RelayAuthVerdict.ALLOW -> true + RelayAuthVerdict.DENY -> false + RelayAuthVerdict.ASK -> { + // Prompt at most once per challenge; reuse the answer for any other + // account that also reaches ASK on this same relay. + val choice = askChoice ?: promptBus.requestDecision(relayUrl, context.purposes).also { askChoice = it } + when (choice) { + UserAuthChoice.ALLOW_ONCE -> true + UserAuthChoice.ALWAYS_ALLOW -> { + account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.ALLOW) + true + } + UserAuthChoice.BLOCK -> { + account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.DENY) + false + } + UserAuthChoice.DISMISS -> false } - } else { - null } } - // Always auth, even with random keys - if (results.isNotEmpty()) results else listOf(tempAccount.sign(authTemplate)) - } else { - emptyList() + if (approve) { + // Remember why we granted this relay so the settings screen can explain it. + account.relayAuthLedger.recordGrant(context) + try { + signed.add(account.signer.sign(authTemplate)) + } catch (e: Exception) { + Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) + } + } } + + signed }, ) + /** + * True when [account] has a first-party reason to authenticate with [relayUrl] on the shared + * client: it is publishing its own event there, a subscription there is reading its own + * inbox/outbox (`#p` or `authors` names its pubkey), or the relay is in its own relay list. + * + * Merely *following* the counterparty of someone else's traffic is deliberately NOT first-party: + * that is exactly how a bystander account got dragged into a paid inbox relay's AUTH (the shared + * auth context carries the OTHER account's counterparties, evaluated against this account's + * follow graph). Reads of a followed author's outbox on an auth-gated relay this account doesn't + * use are therefore no longer auto-authed — a deliberate privacy-positive trade-off. + */ + private fun isFirstParty( + account: Account, + relayUrl: NormalizedRelayUrl, + ): Boolean = + RelayAuthFirstParty.hasReason( + me = account.pubKey, + relayUrl = relayUrl, + pendingEvents = client.activeOutboxEvents(relayUrl), + myRelays = account.trustedRelays.flow.value, + ) + fun destroy() { receiver.destroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt index c48bc6a6b7..af549f6f42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt @@ -54,6 +54,12 @@ class ListWithUniqueSetCache( return newSet } + /** One representative [T] per unique key — the first occurrence wins. */ + fun distinctValues(): List { + val seen = HashSet() + return list.get().filter { seen.add(key(it)) } + } + fun forEachSubscriber(action: (T) -> Unit) { list.get().forEach(action) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt new file mode 100644 index 0000000000..ac6bca8ffe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt @@ -0,0 +1,56 @@ +/* + * 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.relayClient.authCommand.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Whether a given account has a *first-party* reason to authenticate (NIP-42) with a relay on the + * shared [com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient]. Pure so the per-account + * signing gate can be tested without a live client, signer, or Compose. + * + * The socket is shared by every logged-in account, so "this relay wants auth" says nothing about + * *which* account should answer. An account should reveal its identity to a relay only when: + * - it is publishing its own event there ([pendingEvents] authored by it — e.g. delivering a DM to + * the recipient's inbox relay), or + * - the relay is one it configured itself ([myRelays] — its NIP-65 / DM / search / … lists, which + * is where its own inbox/outbox reads are routed anyway). + * + * Crucially, an active subscription merely *naming* the account (a `#p` tag or `authors` entry) is + * NOT a first-party reason: the app packs several accounts' pubkeys into one merged filter and fans + * it out to the union of everyone's relays, so account B's pubkey routinely rides a subscription to + * account A's paid relay. Trusting that dragged bystander accounts (e.g. into inbox.nostr.wine's + * AUTH, and its bill). Genuine own-inbox reads still qualify via [myRelays] — the relay serving them + * is by definition in the account's own list. + */ +object RelayAuthFirstParty { + fun hasReason( + me: HexKey, + relayUrl: NormalizedRelayUrl, + pendingEvents: List, + myRelays: Set, + ): Boolean { + if (pendingEvents.any { it.pubKey == me }) return true + return relayUrl in myRelays + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt index 5a244b6b1e..0b57347215 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt @@ -64,17 +64,15 @@ 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 com.vitorpamplona.amethyst.Amethyst 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.relayauth.AuthPurposeKind import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.LoadRelayAuthUser -import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore -import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -105,8 +103,8 @@ fun RelayAuthSettingsScreen( nav: INav, ) { val account = accountViewModel.account - val store: DataStoreRelayAuthPermissionStore = Amethyst.instance.relayAuthPermissionStore - val ledger = remember { RelayAuthPermissionLedger(store, { account.settings.defaultRelayAuthPolicy.value }) } + val store: RelayAuthPermissionStore = account.relayAuthPermissions + val ledger = account.relayAuthLedger val scope = rememberCoroutineScope() val globalPolicy by account.settings.defaultRelayAuthPolicy.collectAsState() diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt new file mode 100644 index 0000000000..817fb6defd --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt @@ -0,0 +1,74 @@ +/* + * 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.relayClient.authCommand.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The per-account NIP-42 signing gate: on the shared client, an account signs a relay's AUTH only + * when the relay is in its own relay list, or it is publishing its own event there. The bug these + * lock down: an unpaid/bystander account being AUTH'd (and billed by inbox.nostr.wine) purely + * because another account uses that relay — including via a merged filter that names it. + */ +class RelayAuthFirstPartyTest { + private val relay = NormalizedRelayUrl("wss://inbox.nostr.wine/") + private val me = "a".repeat(64) + private val other = "b".repeat(64) + + private fun event(pubkey: String) = + Event( + id = "0".repeat(64), + pubKey = pubkey, + createdAt = 0, + kind = 1059, + tags = emptyArray(), + content = "", + sig = "", + ) + + @Test + fun aRelayNotInMyListWithNothingOfMineIsNotFirstParty() { + // The exact inbox.nostr.wine case: a relay another account uses, which a merged read filter + // names me on, but which is in none of my lists and where I publish nothing → must NOT sign. + assertFalse(RelayAuthFirstParty.hasReason(me, relay, emptyList(), emptySet())) + } + + @Test + fun publishingSomeoneElsesEventIsNotFirstParty() { + assertFalse(RelayAuthFirstParty.hasReason(me, relay, listOf(event(other)), emptySet())) + } + + @Test + fun aRelayIConfiguredIsFirstParty() { + // Own inbox/outbox reads qualify this way: the relay serving them is in my own relay list. + assertTrue(RelayAuthFirstParty.hasReason(me, relay, emptyList(), setOf(relay))) + } + + @Test + fun publishingMyOwnEventIsFirstParty() { + // Delivering my own DM/post to the recipient's relay, even one not in my list. + assertTrue(RelayAuthFirstParty.hasReason(me, relay, listOf(event(me)), emptySet())) + } +} From 6a528a742c37a27c9f4a9dc1de74876e49156807 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 12 Jul 2026 19:29:35 -0400 Subject: [PATCH 11/11] fix(relay-auth): show a relay's payment NOTIFY only under the billed account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay NOTIFY (payment prompt) went into a process-wide NotifyRequestsCache and DisplayNotifyMessages showed it under any account whose relay list contained the relay — so a prompt billed to Amethyst surfaced under Vitor, and stale entries re-appeared on every account switch. A NOTIFY doesn't reliably name a pubkey, so instead of parsing the message we correlate it with the AUTH that triggered it: a paid relay answers an unauthorized AUTH with `OK false …` right before the NOTIFY, and we signed that auth event. NotifyCoordinator remembers each auth event's signer, maps the failing OK back to it, and files the NOTIFY into THAT account's own Account.relayNotifications. Unattributable NOTIFYs are dropped. DisplayNotifyMessages now reads only the current account's cache. Also removes the now-unused app-wide RelayAuthPermissionStore singleton and wires NotifyCoordinator with the pubkey -> Account lookup. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/vitorpamplona/amethyst/AppModules.kt | 13 ++- .../compose/DisplayNotifyMessages.kt | 20 +--- .../notifyCommand/model/NotifyCoordinator.kt | 92 +++++++++++++++++-- 3 files changed, 96 insertions(+), 29 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 34ade716a5..8b633a1fb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -79,7 +79,6 @@ import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator -import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState @@ -571,13 +570,13 @@ class AppModules( // Verifies and inserts in the cache from all relays, all subscriptions val cacheClientConnector = CacheClientConnector(client, cache) - // Show messages from the Relay and controls their dismissal - val notifyCoordinator = NotifyCoordinator(client) + // Show messages from the Relay and controls their dismissal. Attributes each NOTIFY to the + // account whose AUTH the relay rejected (accountsCache is declared below; the lambda reads it + // lazily at NOTIFY time, long after init). + val notifyCoordinator = NotifyCoordinator(client) { pubkey -> accountsCache.accounts.value[pubkey] } - // Persists per-relay NIP-42 ALLOW/DENY overrides across app restarts. - val relayAuthPermissionStore by lazy { - DataStoreRelayAuthPermissionStore(appContext) - } + // Per-relay NIP-42 ALLOW/DENY overrides are now per-account (Account.relayAuthPermissions, + // backed by a file under accounts//), so there is no app-wide store here anymore. // Singleton stores for napplet permissions — DataStore v1 enforces one instance per file. val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt index 38f6d6566a..f442bc3ef0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt @@ -21,22 +21,19 @@ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache 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.quartz.nip01Core.relay.normalizer.displayUrl -import kotlinx.coroutines.flow.map @Composable fun DisplayNotifyMessages( accountViewModel: AccountViewModel, nav: INav, -) = DisplayNotifyMessages(Amethyst.instance.notifyCoordinator.requests, accountViewModel, nav) +) = DisplayNotifyMessages(accountViewModel.account.relayNotifications, accountViewModel, nav) @Composable fun DisplayNotifyMessages( @@ -44,17 +41,10 @@ fun DisplayNotifyMessages( accountViewModel: AccountViewModel, nav: INav, ) { - val flow = - remember(accountViewModel) { - requests.transientPaymentRequests.map { - it.filter { notifyMsg -> - notifyMsg.relayUrl in accountViewModel.account.dmRelayList.flow.value || - notifyMsg.relayUrl in accountViewModel.account.nip65RelayList.allFlowNoDefaults.value - } - } - } - - val openDialogMsg = flow.collectAsStateWithLifecycle(emptySet()) + // [requests] is THIS account's own cache. NotifyCoordinator only files a NOTIFY under the account + // whose AUTH the relay rejected, so there is no cross-account leak to filter out here — a prompt + // in this cache genuinely belongs to this account. + val openDialogMsg = requests.transientPaymentRequests.collectAsStateWithLifecycle() openDialogMsg.value.firstOrNull()?.let { request -> NotifyRequestDialog( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt index 404015a6b3..92b40d456e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt @@ -20,20 +20,98 @@ */ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model +import android.util.LruCache +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayNotifier +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import java.util.concurrent.ConcurrentHashMap +/** + * Routes relay `NOTIFY` payment prompts to the account they actually concern. + * + * A relay's NOTIFY does not reliably name a pubkey, so we can't parse the account out of the + * message. Instead we correlate it with the AUTH that triggered it: a paid relay answers an + * unauthorized AUTH with `OK false …` immediately followed by the NOTIFY. We *signed* + * that auth event, so [AuthCmd.event] tells us which account's key it was. We remember that per auth + * event, resolve the failing `OK` back to the signer, and drop the NOTIFY into THAT account's own + * [NotifyRequestsCache] ([Account.relayNotifications]). + * + * The cache is per account (not a process-wide singleton), so a prompt for account A can never + * surface under account B — and an unattributable NOTIFY is dropped rather than shown to the wrong + * account. This is the fix for the stale-global-cache leak where switching accounts re-surfaced + * another account's inbox.nostr.wine prompt. + */ class NotifyCoordinator( - client: INostrClient, + private val client: INostrClient, + private val accountForPubkey: (HexKey) -> Account?, ) { - val requests = NotifyRequestsCache() + companion object { + const val TAG = "NotifyCoordinator" + private const val AUTH_EVENT_CACHE = 256 + } - val receiver = - RelayNotifier(client) { message, relay -> - requests.addPaymentRequestIfNew(message, relay.url) + // authEventId -> the pubkey we signed it with. Bounded: only a handful of relays re-auth. + private val signerOfAuthEvent = LruCache(AUTH_EVENT_CACHE) + + // relay -> pubkey of the auth the relay most recently rejected there (the one it will bill). + private val billedPubkeyAt = ConcurrentHashMap() + + private val listener = + object : RelayConnectionListener { + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + if (cmd is AuthCmd) { + signerOfAuthEvent.put(cmd.event.id, cmd.event.pubKey) + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + when (msg) { + is OkMessage -> + if (!msg.success) { + signerOfAuthEvent.get(msg.eventId)?.let { billedPubkeyAt[relay.url] = it } + } + is NotifyMessage -> route(relay.url, msg.message) + else -> {} + } + } } + private fun route( + relay: NormalizedRelayUrl, + message: String, + ) { + // Consume the correlation so a later, unrelated NOTIFY can't reuse a stale attribution. + // An unattributable NOTIFY (none of our auths were rejected here) is dropped rather than + // risk surfacing it under the wrong account. + val account = billedPubkeyAt.remove(relay)?.let(accountForPubkey) + account?.relayNotifications?.addPaymentRequestIfNew(message, relay) + } + + init { + Log.d(TAG, "Init, Subscribe") + client.addConnectionListener(listener) + } + fun destroy() { - receiver.destroy() + Log.d(TAG, "Destroy, Unsubscribe") + client.removeConnectionListener(listener) } }