diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt index 74078dda7..7fcfb4bf6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils open class DiscoverNIP89FeedFilter( val account: Account, + val targetKind: Int = 5300, ) : AdditiveFeedFilter() { val lastAnnounced = TimeUtils.oneYearAgo() @@ -61,7 +62,7 @@ open class DiscoverNIP89FeedFilter( override fun feed(): List { val notes = LocalCache.addressables.filterIntoSet(AppDefinitionEvent.KIND) { _, it -> - acceptDVM(it) + acceptApp(it) } return sort(notes) @@ -75,29 +76,29 @@ open class DiscoverNIP89FeedFilter( account.hiddenUsers.flow.value, ) - fun acceptDVM(note: Note): Boolean { + fun acceptApp(note: Note): Boolean { val noteEvent = note.event return if (noteEvent is AppDefinitionEvent) { - acceptDVM(noteEvent, note.relays) + acceptApp(noteEvent, note.relays) } else { false } } - fun acceptDVM( + open fun acceptApp( noteEvent: AppDefinitionEvent, relays: List, ): Boolean { val filterParams = buildFilterParams(account) return noteEvent.appMetaData()?.subscription != true && filterParams.match(noteEvent, relays) && - noteEvent.includeKind(5300) && - noteEvent.createdAt > lastAnnounced // && params.match(noteEvent) + noteEvent.includeKind(targetKind) && + noteEvent.createdAt > lastAnnounced } protected open fun innerApplyFilter(collection: Collection): Set = collection.filterTo(HashSet()) { - acceptDVM(it) + acceptApp(it) } override fun sort(items: Set): List { diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 6f60b8975..509869ff3 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -730,6 +730,8 @@ Požadavky Poslední kontrola %1$d ms + Přijímané druhy + Poloha Aktivní odběry Čekající události k odeslání REQ odběry (%1$d) @@ -1263,6 +1265,10 @@ Git repositář: %1$s Internet: Klon: + Statický web: %1$s + Kořenový web + Zdroj: + Servery: OTS: %1$s Důkaz časového razítka Existuje důkaz, že tento příspěvek byl podepsán někdy před %1$s. Důkaz byl označen v Bitcoin blockchainu v tomto datu a čase. @@ -1825,4 +1831,21 @@ URL ikony relé Spravovat relé Spravovat + Členové + Členové %1$s + %1$d členů + Načítání členů… + Žádní členové nenalezeni + Požádat o připojení + Požádat o opuštění + Žádost o připojení odeslána + Žádost o opuštění odeslána + Jste členem + Seznam členů relé + Člen přidán do relé + %1$d členů přidáno do relé + Člen odebrán z relé + %1$d členů odebráno z relé + Žádost o připojení k relé + Žádost o opuštění relé diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 693a7a64f..ad410c999 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -735,6 +735,8 @@ anz der Bedingungen ist erforderlich Anforderungen Letzte Prüfung %1$d ms + Akzeptierte Arten + Standort Aktive Abonnements Ausstehende Outbox-Ereignisse REQ-Abonnements (%1$d) @@ -1268,6 +1270,10 @@ anz der Bedingungen ist erforderlich Git Repository: %1$s Internet: Klonen: + Statische Website: %1$s + Stammseite + Quelle: + Server: OTS: %1$s Zeitstempel Beweis Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt. @@ -1830,4 +1836,21 @@ anz der Bedingungen ist erforderlich Relay-Symbol-URL Relay verwalten Verwalten + Mitglieder + Mitglieder von %1$s + %1$d Mitglieder + Mitglieder werden geladen… + Keine Mitglieder gefunden + Beitritt anfragen + Austritt anfragen + Beitrittsanfrage gesendet + Austrittsanfrage gesendet + Du bist Mitglied + Relay-Mitgliederliste + Mitglied zum Relay hinzugefügt + %1$d Mitglieder zum Relay hinzugefügt + Mitglied aus Relay entfernt + %1$d Mitglieder aus Relay entfernt + Relay-Beitrittsanfrage + Relay-Austrittsanfrage diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index c55bc900c..166a4da5b 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -61,6 +61,8 @@ हस्ताक्षरकर्ता ने इस कार्य करने के लिए अपरहस्यीकरण का अधिकार नहीं दिया। आपके हस्ताक्षर क्रमक में निप॰-४४ अपरहस्यीकरण सक्रिय करें तथा पुनः प्रयास करें हस्ताक्षरकर्ता अप्राप्त क्या हस्ताक्षर क्रमक अस्थापित किया गया। जाँच करें कि यदि हस्ताक्षरकर्ता स्थापित है तथा यह लेखा वहाँ है। यदि हस्ताक्षर क्रमक में परिवर्तन है तो निर्गमनांकन तथा प्रवेशांकन पुनः करें। + हस्ताक्षरकर्ता द्वारा दुर्व्यवहार + बाहरी हस्ताक्षरकर्ता द्वारा दिया गया उत्तर विचित्र है अनुरोध के लिए। अमेथिस्ट अथवा हस्ताक्षरकर्ता में कुछ दोष हो सकता है। ज्साप दृष्ट संख्या उद्धृत करें @@ -290,6 +292,7 @@ नोस्ट्र पता कभी नहीं अभी + सेकण्ड घं॰ मि॰ दि॰ @@ -378,12 +381,16 @@ मूलविकल्प स्मर्त्तव्य सूची आपका मूलविकल्प स्मर्त्तव्य सूची जिसका अवलम्बन अनेक ग्राहक करते हैं पाण्डुलिपियाँ + मतदान निजी स्मर्तव्य सार्वजनिक स्मर्तव्य निजी स्मर्तव्य सूची में जोडें सार्वजनिक स्मर्तव्य सूची में जोडें निजी स्मर्तव्य सूची से हटाएँ सार्वजनिक स्मर्तव्य सूची से हटाएँ + टँगे गए पत्र + परिचय के साथ जोडें + परिचय से निकालें स्मर्त्तव्य सूची स्मर्त्तव्य सूची चिह्न नव्य स्मर्त्तव्य सूची @@ -447,6 +454,8 @@ ज्साप अधिकतम सर्वसम्मति (० से १००) प्रतिशत + एक विकल्प + अनेक विकल्प मतदान समापन दिनांक तथा समय मतदान %1$s में समाप्त होगा के पश्चात समाप्त करें @@ -486,6 +495,9 @@ ग्राहक तथा जनता को पता नहीं किसने भुगतान भेजा ज्साप अतिरिक्त नोस्ट्र में कोई पदचिह्न नहीं, केवल लैटनिंग पर + नामरहित + पत्र प्रकाशित करें एक नए क्षेपणार्थ लेखाचिह्न के साथ। आपका लेखा इस उत्तर के साथ नहीं जोडा जाएगा। + यह उत्तर भेजा जाएगा एक नए नामरहित लेखा से अभिलेख सेवासंगणक इस अभिलेख का आरोहण करने के लिए सेवासंगणक का चयन करें लै॰जाल पता अथवा @उपयोगकर्ता @@ -711,6 +723,19 @@ नियम तथा अवस्था अनुपलब्ध अपक्रम तथा सूचनाएँ इस पुनःप्रसारक से + पुनःप्रसारक परिवीक्षक सूचनाएँ + खोलें + पढें + लिखें + आरटीटी॰ + जाल + प्रकार + अवलम्बन किए हुए निप॰ सूची + आवश्यकताएँ + अन्तिम जाँच + %1$d सहस्रतमसेकण्ड + स्वीकृत प्रकार + स्थान सक्रिय ग्राहकताएँ अपूर्ण निर्गतपेटिका घटनाएँ अनुरोध ग्राहकताएँ (%1$d) @@ -1104,6 +1129,13 @@ नया सामुदायिक टीका नया उत्पाद नया स्थान विशेष पत्र + नया लेख + शीर्षक + साराम्श (वैकल्पिक) + बाहरी चित्र जालपता (वैकल्पिक) + आपका लेख मार्कडौन में लिखें… + पूर्वावलोकन + संपादित करें इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को खोलें इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को अवरोधित करें उत्तर @@ -1238,9 +1270,14 @@ गिट क्रमलेखकोश : %1$s जाल : अनुकृति : + स्थिर जालस्थान : %1$s + मूल स्थान + स्रोत : + सेवासंगणक : ओ॰टी॰एस : %1$s समयांकन प्रमाण प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया द्व्यंकरूप्य खण्डश्रृंखला में उस समय उस दिन पर। + लेख सम्पादित करें पत्र का सम्पादन करें पत्र शोधन के लिए प्रस्ताव परिवर्तनों का साराम्श @@ -1320,6 +1357,7 @@ विषयसूचक समुदाय सूचियाँ + पुनःप्रसारक यन्त्र ताला लगने पर निर्गमनांकन करें निजी सन्देश सार्वजनिक संदेश @@ -1556,6 +1594,7 @@ छोटे ध्वनि सन्देश ध्वनि उत्तर + जाल स्मर्त्तव्यचिह्न विकि॰ एक बढिया सूचनावली के साथ आरम्भ करें उन लोगों का अनुचरण करके जिनको आपके द्वारा विश्वास प्राप्त कोई व्यक्ति करते हैं। अनुचरित सूची आयात @@ -1584,6 +1623,21 @@ सभी चुनें %1$d%% समय निरन्तर उपलब्ध नामरूप्य स्थापना विकल्प + संयोजन परीक्षण + सेवासंगणकों का परीक्षण… + संयोजित + असफल + परीक्षा परिणाम + निदानार्थ लक्षण + अन्तिम परीक्षा + यन्त्र जानकारी + टीएलएस॰ जानकारी + अभी कोई परीक्षा चरण हुआ नहीं + %d सहस्रतमसेकण्ड + क्या सेवासंगणक प्रमाणपत्र पर विश्वास करें। + सेवासंगणक %1$s ने एक प्रमाणपत्र प्रस्तुत किया जो आपके विश्वास भण्डार में अभी नहीं है। सत्यापित करें कि नीचे के हस्ताक्षर वही है जो सेवासंगणक के परिचालक ने प्रकाशित किया है। तत्पश्चात चयन करें कि भविष्य में संयोजनों के लिए विश्वास करना है अथवा नहीं। + विश्वास करें + अस्वीकार करें पुनःप्रसारक समचरणीकरण पुनःप्रसारक समचरणीकरण अपने घटनाओं को पुनःप्रकाशित करें सभी ज्ञात पुनःप्रसारकों तक अपने निर्गतपेटिका आगतपेटिका तथा सीधासन्देश पुनःप्रसारकों का अद्यतन करने के लिए। वैफै॰ आवश्यक। यह सम्भाव्यतः बहुत जानकारी भेज सकता है। @@ -1622,8 +1676,12 @@ कोई घटना नहीं द्व्यंकरूप्य समन्वेषक (ओटीएस॰) घटनाएँ + घटनाएँ आप से + घटनाएँ आप को + खोज साध्य घटनाएँ सीधेसन्देश परिचय + निर्गतपेटिका सूचियाँ पुनःप्रसारक स्थापना विकल्प पूर्व दृष्ट %1$s पहले <%1$s @@ -1664,6 +1722,135 @@ प्रकारों के सत्यापन में निपुण : %1$s इस के लिए साक्षी इस के लिए साक्ष्यांकन अनुरोध + दिनांक सीमा + से + तक + अभी + समस्त काल + अन्तिम समचरणीकरण : %1$s + अन्तिम समचरणीकरण से + जाल स्मर्त्तव्यचिह्न + अभी कोई जाल स्मर्त्तव्यचिह्न नहीं। जोडने के लिए + दबाएँ। + जाल स्मर्त्तव्यचिह्न जोडें + जाल स्मर्त्तव्यचिह्न सम्पादित करें + जालपता + https://example.com + शीर्षक + स्मर्त्तव्यचिह्न शीर्षक + विवरण + एक छोटा विवरण + विषयसूचक (अल्पविराम विभाजित) + नोस्टर, तन्त्रज्ञान, जाललेख + अभिलेखन करें + मिटाएँ + क्या इस जाल स्मर्त्तव्यचिह्न को मिटाएँ। + जालपता खोलें + यह उद्देश्य समाप्त हो चुका है + %1$s वित्तपोषित %2$s साट्स उद्देश्य में से + उद्देश्य संख्या (साट्स) + १००००० + आपके उद्देश्य का विवरण करें + आप किस लिए धनसंग्रह कर रहे हैं। + छोटा साराम्श + संक्षिप्त विवरण पूर्वावलोकनों में दिखाने के लिए + चित्र जालपता (वैकल्पिक) + https://example.com/image.jpg + जालस्थान पता (वैकल्पिक) + https://example.com + समय सीमा (वैकल्पिक) + समय सीमा स्थापित करें + नया उद्देश्य + उद्देश्य बनाएँ + अदृश्य होने के लिए अनुरोध + पुनःप्रसारकों को अनुरोध करें कि आपकी सभी जानकारी मिटाएँ चयनित दिनांक तक। यह कार्य निप॰६२ पर आधारित है तथा कुछ अधिकारक्षेत्रों में न्यायिक रूप से बाध्यकारी है। + एक पुनःप्रसारक का चयन करें + लक्ष्य पुनःप्रसारक + सभी पुनःप्रसारक + यह सभी पुनःप्रसारकों को अनुरोध करेगा कि आपकी कुंचिका से सम्बन्धित सब कुछ मिटा दें चयनित दिनांक तक। इस घटना का यथासम्भाव्य विस्तृत प्रसार किया जाएगा। इसको पूर्ववत नहीं किया जा सकता। + जानकारी मिटाएँ इस तक + इस दिनांक के पूर्व बनाए गए आपके सभी घटनाओं को मिटाने का अनुरोध किया जाएगा चयनित पुनःप्रसारक से। + कारण (विकल्पात्मक) + कारण अथवा न्यायिक संदेश पुनःप्रसारक के परिचालक के लिए + अदृश्यीकरण अनुरोध भेजें + अदृश्यीकरण अनुरोध की पुष्टि करें + आप %1$s को अनुरोध करनेवाले हैं कि स्थायी रूप से चयनित दिनांक के पूर्व के आपकी सभी जानकारी मिटा दें। इसे पूर्ववत नहीं किया जा सकता। + आप ॰सभी॰ पुनःप्रसारकों को अनुरोध करनेवाले हैं कि स्थायी रूप से चयनित दिनांक के पूर्व के आपकी सभी जानकारी मिटा दें। इसे सर्वत्र प्रसारित किया जाएगा तथा पूर्ववत नहीं किया जा सकता। + अदृश्यीकरण अनुरोध भेजा गया + दिनांक का चयन करें + समय का चयन करें + अदृश्यीकरण इतिहास + नवीकरण + ये आपके पूर्वकाल के अदृश्यीकरण अनुरोध घटनाएँ है जो संयोजित पुनःप्रसारकों में से प्राप्त हुए। इन घटनाओं में सूचित पुनःप्रसारकों में घटना दिनांक के पूर्व की आपकी कोई जानकारी नहीं रखनी चाहिए। + कोई अदृश्यीकरण अनुरोध प्राप्त नहीं + आपने कोई अदृश्यीकरण अनुरोध अभी तक भेजे नहीं। + लक्ष्य पुनःप्रसारक + यह अनुरोध सभी पुनःप्रसारकों को लक्ष्यांकित करता है। अदृश्यीकरण अनुरोध पटल का उपयोग करें विशिष्ट पुनःप्रसारकों की अनुपालन परीक्षा करने के लिए। + परीक्षण + अनुपालक + अनुपालक नहीं + अपक्रम + अदृश्यीकरण इतिहास + प्रबन्धन %1$s + पुनःप्रसारक प्रबन्धन क्षमताओं का आवहन… + पुनःप्रसारक प्रबन्धक के साथ जुडने में अक्षम + कोई प्रबन्धक विधि उपलब्ध नहीं + हटाएँ + प्रयोक्ता सूची + घटनाएँ + प्रकार + अंकीय जालपताएँ + स्थापना विकल्प + प्रतिबन्धित प्रयोक्ता + कोई प्रतिबन्धित प्रयोक्ता नहीं + अनुमत प्रयोक्ता + कोई अनुमत प्रयोक्ता नहीं + नियमन पंक्ति + नियमनार्थ कोई घटनाएँ नहीं + प्रतिबन्धित घटनाएँ + कोई प्रतिबन्धित घटनाएँ नहीं + अनुमत प्रकार + कोई अनुमत प्रकार नहीं + अवरोधित अंकीय जालपताएँ + कोई अवरोधित अंकीय जालपताएँ नहीं + जोडें + हटाएँ + अनुमत + प्रतिबन्ध + ख्याप्य कुंचिका प्रतिबन्धित करें + ख्याप्य कुंचिका को अनुमति दें + घटना प्रतिबन्धित करें + प्रकार को अनुमति दें + अंकीय जालपता अवरोधित करें + ख्याप्य कुंचिका (षोडशांक) + घटना विभेदक (षोडशांक) + प्रकार संख्या + अंकीय जालपता + कारण (विकल्पात्मक) + पुष्टि करें + निरस्त करें + लागू करें + पुनःप्रसारक नाम + पुनःप्रसारक विवरण + पनःप्रसारक चिह्न जालपता + पुनःप्रसारक प्रबन्धन + प्रबन्धन + सदस्य सूची + %1$s के सदस्य + %1$d सदस्य + सदस्य आवहन… + कोई सदस्य प्राप्त नहीं + जुडने का अनुरोध + चले जाने का अनुरोध + जुडने का अनुरोध भेजा गया + चले जाने का अनुरोध भेजा गया + आप एक सदस्य हैं + पुनःप्रसारक सदस्यता सूची + सदस्य जोडा गया पुनःप्रसारक के साथ + %1$d सदस्य जोडे गये पुनःप्रसारक के साथ + सदस्य हटाया गया पुनःप्रसारक से + %1$d सदस्य हटाए गए पुनःप्रसारक से + पुनःप्रसारक से जुडने का अनुरोध + पुनःप्रसारक से चले जाने का अनुरोध diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 6abe741fd..6589c546a 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -1270,6 +1270,10 @@ Git tároló: %1$s Weboldal: Klónozás: + Statikus weboldal: %1$s + Főoldal + Forrás: + Kiszolgálók: OTS: %1$s Időbélyeg-igazolás Bizonyíték van arra, hogy ezt a bejegyzést valamikor %1$s előtt írták alá. A bizonyítékot ezen a napon és időpontban bélyegezték a Bitcoin blokkláncába. @@ -1832,4 +1836,21 @@ Átjátszó ikonjának webcíme Átjátszó kezelése Kezelés + Tagok + A(z) %1$s tagjai + %1$d tag + Tagok betöltése… + Nem találhatók tagok + Csatlakozási kérés + Elhagyási kérés + Csatlakozási kérés elküldve + Elhagyási kérés elküldve + Ön tagja az átjátszónak + Átjátszó tagsági listája + A tag hozzá lett adva az átjátszóhoz + %1$d tag hozzáadva az átjátszóhoz + A tag el lett távolítva az átjátszóról + %1$d tag el lett távolítva az átjátszóról + Átjátszóhoz való csatlakozás kérése + Átjátszó elhagyásának kérése diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 61625cac7..a578b8962 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -730,6 +730,8 @@ Requisitos Última verificação %1$d ms + Tipos Aceitos + Localização Assinaturas Ativas Eventos Pendentes na Outbox Assinaturas REQ (%1$d) @@ -1263,6 +1265,10 @@ Repositório Git: %1$s Site: Clonar: + Site Estático: %1$s + Site Raiz + Fonte: + Servidores: OTS: %1$s Prova de Carimbo de data/hora Há prova de que esta postagem foi assinada antes de %1$s. A prova foi carimbada no blockchain do Bitcoin naquela data e hora. @@ -1825,4 +1831,21 @@ URL do Ícone do Relay Gerenciar Relay Gerenciar + Membros + Membros de %1$s + %1$d membros + Carregando membros… + Nenhum membro encontrado + Solicitar Entrada + Solicitar Saída + Solicitação de entrada enviada + Solicitação de saída enviada + Você é membro + Lista de membros do relay + Membro adicionado ao relay + %1$d membros adicionados ao relay + Membro removido do relay + %1$d membros removidos do relay + Solicitação de entrada no relay + Solicitação de saída do relay diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 78c0d5f2f..88be9e2d2 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -729,6 +729,8 @@ Krav Senaste kontroll %1$d ms + Accepterade typer + Plats Aktiva prenumerationer Väntande utgående händelser REQ-prenumerationer (%1$d) @@ -1262,6 +1264,10 @@ Git Repository: %1$s Webbplats: Klona: + Statisk webbplats: %1$s + Webbplats rot + Källa: + Servrar: OTS: %1$s Tidsstämpel Bevis Det finns bevis på att detta inlägg signerades någon gång före %1$s. Beviset stämplades i Bitcoin-blockchainen vid det datumet och den tiden. @@ -1824,4 +1830,21 @@ URL för reläikon Hantera relä Hantera + Medlemmar + Medlemmar i %1$s + %1$d medlemmar + Laddar medlemmar… + Inga medlemmar hittades + Begär att gå med + Begär att lämna + Begäran om att gå med skickad + Begäran om att lämna skickad + Du är medlem + Relämedlemslista + Medlem tillagd i relä + %1$d medlemmar tillagda i relä + Medlem borttagen från relä + %1$d medlemmar borttagna från relä + Begäran om att gå med i relä + Begäran om att lämna relä diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 4d62b33c3..ac2075f8a 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1270,6 +1270,10 @@ Git 仓库: %1$s 网址 克隆: + 静态网站: %1$s + 根站点 + 源: + 服务器: OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 @@ -1832,4 +1836,21 @@ 中继图标 URL 管理中继 管理 + 成员 + %1$s 的成员 + %1$d 位成员 + 加载成员… + 未找到成员 + 申请加入 + 请求退出 + 加入请求已发送 + 已发送离开请求 + 您是成员 + 中继成员列表 + 已添加成员到中继 + 添加了 %1$d 位成员到中继 + 从中继删除了成员 + 从中继删除了 %1$d 位成员 + 中继加入请求 + 中继离开请求 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/elements/BoostedMark.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/elements/BoostedMark.kt new file mode 100644 index 000000000..173639ccd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/elements/BoostedMark.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.compose.elements + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun BoostedMark() { + Text( + "Boosted", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.padding(start = 5.dp), + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/layouts/GenericRepostLayout.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/layouts/GenericRepostLayout.kt new file mode 100644 index 000000000..a77f979ef --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/layouts/GenericRepostLayout.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.commons.compose.layouts + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.Repost + +private val Size55Modifier = Modifier.size(55.dp) +private val Size35Modifier = Modifier.size(35.dp) +private val Size18Modifier = Modifier.size(18.dp) + +@Composable +fun RepostIcon( + modifier: Modifier, + tint: Color = Color.Unspecified, +) { + Icon( + imageVector = Repost, + contentDescription = "Boost or quote", + modifier = modifier, + tint = tint, + ) +} + +@Composable +fun GenericRepostLayout( + baseAuthorPicture: @Composable () -> Unit, + repostAuthorPicture: @Composable () -> Unit, +) { + Box(modifier = Size55Modifier) { + Box(remember { Size35Modifier.align(Alignment.TopStart) }) { baseAuthorPicture() } + + Box( + remember { Size18Modifier.align(Alignment.BottomStart).padding(1.dp) }, + ) { + RepostIcon(modifier = Size18Modifier, MaterialTheme.colorScheme.onSurfaceVariant) + } + + Box( + remember { Size35Modifier.align(Alignment.BottomEnd) }, + contentAlignment = Alignment.BottomEnd, + ) { + repostAuthorPicture() + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt index 4b62fc9ab..aa03e9230 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt @@ -32,6 +32,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import kotlinx.coroutines.CoroutineScope @@ -66,6 +69,7 @@ class FeedMetadataCoordinator( // Track what we've already queued to avoid duplicates private val queuedPubkeys = mutableSetOf() private val queuedNoteIds = mutableSetOf() + private val queuedBoostedIds = mutableSetOf() /** * Start processing the subscription queue. @@ -110,6 +114,37 @@ class FeedMetadataCoordinator( fun loadMetadataForNotes(notes: List) { if (notes.isEmpty()) return + // Fetch referenced note content: reposts (via replyTo) + quoted notes (via e-tags) + val repostBoostedIds = + notes + .filter { it.event is RepostEvent || it.event is GenericRepostEvent } + .mapNotNull { it.replyTo?.lastOrNull() } + .filter { it.event == null } + .map { it.idHex } + + val quotedNoteIds = + notes + .mapNotNull { it.event } + .flatMap { event -> event.tags.mapNotNull { ETag.parseId(it) } } + + val allReferencedIds = + (repostBoostedIds + quotedNoteIds) + .filter { it !in queuedBoostedIds } + .distinct() + + if (allReferencedIds.isNotEmpty()) { + queuedBoostedIds.addAll(allReferencedIds) + val referencedFilter = + Filter( + ids = allReferencedIds, + ) + priorityQueue.enqueue( + SubscriptionPriority.METADATA, + referencedFilter, + tag = "feed-referenced-notes", + ) + } + // Extract unique authors that we haven't already queued val authors = notes diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 319ec94db..6fbf9c266 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -36,8 +36,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent @@ -148,6 +150,13 @@ class DesktopLocalCache : ICacheProvider { val newUserMetadata = event.contactMetaData() if (newUserMetadata != null) { user.updateUserInfo(newUserMetadata, event) + // Invalidate metadata flows on notes by this author that have observers + // so QuotedNoteEmbed/FeedNoteCard recompose with updated avatar/name + notes.forEach { _, note -> + if (note.author?.pubkeyHex == event.pubKey && note.flowSet?.metadata?.hasObservers() == true) { + note.flowSet?.metadata?.invalidateData() + } + } } } } @@ -165,7 +174,7 @@ class DesktopLocalCache : ICacheProvider { when (event) { is MetadataEvent -> { consumeMetadata(event) - true + false // metadata updates User, not Note — skip event stream } is TextNoteEvent -> { @@ -188,6 +197,10 @@ class DesktopLocalCache : ICacheProvider { consumeRepost(event, relay) } + is GenericRepostEvent -> { + consumeGenericRepost(event, relay) + } + is ContactListEvent -> { consumeContactList(event) } @@ -200,6 +213,10 @@ class DesktopLocalCache : ICacheProvider { consumeBookmarkList(event) } + is CommentEvent -> { + consumeComment(event, relay) + } + else -> { false } @@ -216,7 +233,25 @@ class DesktopLocalCache : ICacheProvider { val note = getOrCreateNote(event.id) if (note.event != null) return false val author = getOrCreateUser(event.pubKey) - val repliesTo = event.tagsWithoutCitations().mapNotNull { getNoteIfExists(it) } + val repliesTo = event.tagsWithoutCitations().map { getOrCreateNote(it) } + note.loadEvent(event, author, repliesTo) + relay?.let { note.addRelay(it) } + repliesTo.forEach { it.addReply(note) } + return true + } + + /** + * Consumes a kind 1111 comment event (NIP-22). + * Like text notes but uses BaseThreadedEvent reply structure. + */ + private fun consumeComment( + event: CommentEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + val repliesTo = event.tagsWithoutCitations().map { getOrCreateNote(it) } note.loadEvent(event, author, repliesTo) relay?.let { note.addRelay(it) } repliesTo.forEach { it.addReply(note) } @@ -299,6 +334,8 @@ class DesktopLocalCache : ICacheProvider { /** * Consumes a kind 6 repost event. * Links repost to target note via e-tag. + * Uses getOrCreateNote for the boosted note so the link exists even if + * the original note hasn't arrived yet (it will be filled in later). */ private fun consumeRepost( event: RepostEvent, @@ -307,7 +344,27 @@ class DesktopLocalCache : ICacheProvider { val note = getOrCreateNote(event.id) if (note.event != null) return false val author = getOrCreateUser(event.pubKey) - val boostedNote = event.boostedEventId()?.let { getNoteIfExists(it) } + val boostedId = event.boostedEventId() + val boostedNote = boostedId?.let { getOrCreateNote(it) } + val repliesTo = listOfNotNull(boostedNote) + note.loadEvent(event, author, repliesTo) + relay?.let { note.addRelay(it) } + boostedNote?.addBoost(note) + return true + } + + /** + * Consumes a kind 16 generic repost event. + * Links repost to target note via e-tag. + */ + private fun consumeGenericRepost( + event: GenericRepostEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + val author = getOrCreateUser(event.pubKey) + val boostedNote = event.boostedEventId()?.let { getOrCreateNote(it) } val repliesTo = listOfNotNull(boostedNote) note.loadEvent(event, author, repliesTo) relay?.let { note.addRelay(it) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt index 8f87d901a..a9d4cdead 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -28,12 +28,29 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +private fun isFeedNote(event: com.vitorpamplona.quartz.nip01Core.core.Event?): Boolean = + event is TextNoteEvent || + event is RepostEvent || + event is GenericRepostEvent + +private fun List.deduplicateReposts(): List = + distinctBy { note -> + val event = note.event + if (event is RepostEvent || event is GenericRepostEvent) { + note.replyTo?.lastOrNull()?.idHex ?: note.idHex + } else { + note.idHex + } + } + /** - * Global feed: all kind 1 text notes, sorted by createdAt desc. + * Global feed: kind 1 text notes + kind 6/16 reposts, sorted by createdAt desc. */ class DesktopGlobalFeedFilter( private val cache: DesktopLocalCache, @@ -42,19 +59,20 @@ class DesktopGlobalFeedFilter( override fun feed(): List = cache.notes - .filterIntoSet { _, note -> note.event is TextNoteEvent } + .filterIntoSet { _, note -> isFeedNote(note.event) } .sortedWith(DefaultFeedOrder) + .deduplicateReposts() .take(limit()) - override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { it.event is TextNoteEvent } + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { isFeedNote(it.event) } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder).deduplicateReposts() override fun limit(): Int = 2500 } /** - * Following feed: kind 1 text notes from followed pubkeys. + * Following feed: kind 1 text notes + kind 6/16 reposts from followed pubkeys. */ class DesktopFollowingFeedFilter( private val cache: DesktopLocalCache, @@ -66,19 +84,20 @@ class DesktopFollowingFeedFilter( val follows = followedPubkeys() return cache.notes .filterIntoSet { _, note -> - note.event is TextNoteEvent && note.author?.pubkeyHex in follows + isFeedNote(note.event) && note.author?.pubkeyHex in follows }.sortedWith(DefaultFeedOrder) + .deduplicateReposts() .take(limit()) } override fun applyFilter(newItems: Set): Set { val follows = followedPubkeys() return newItems.filterTo(HashSet()) { - it.event is TextNoteEvent && it.author?.pubkeyHex in follows + isFeedNote(it.event) && it.author?.pubkeyHex in follows } } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder).deduplicateReposts() override fun limit(): Int = 2500 } @@ -116,7 +135,7 @@ class DesktopThreadFilter( } /** - * Profile feed: all kind 1 notes by a specific pubkey. + * Profile feed: text notes + reposts by a specific pubkey. */ class DesktopProfileFeedFilter( private val pubkey: HexKey, @@ -124,19 +143,21 @@ class DesktopProfileFeedFilter( ) : AdditiveFeedFilter() { override fun feedKey(): String = "profile-$pubkey" + private fun isProfileNote(note: Note): Boolean { + val event = note.event ?: return false + return note.author?.pubkeyHex == pubkey && isFeedNote(event) + } + override fun feed(): List = cache.notes - .filterIntoSet { _, note -> - note.event is TextNoteEvent && note.author?.pubkeyHex == pubkey - }.sortedWith(DefaultFeedOrder) + .filterIntoSet { _, note -> isProfileNote(note) } + .sortedWith(DefaultFeedOrder) + .deduplicateReposts() .take(limit()) - override fun applyFilter(newItems: Set): Set = - newItems.filterTo(HashSet()) { - it.event is TextNoteEvent && it.author?.pubkeyHex == pubkey - } + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { isProfileNote(it) } - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder).deduplicateReposts() override fun limit(): Int = 1000 } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 474f3a163..b8fe4a15f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -29,7 +29,6 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -91,11 +90,9 @@ class DesktopRelaySubscriptionsCoordinator( scope = scope, indexRelays = indexRelays, preloader = preloader, - onEvent = { event, _ -> - // Consume metadata events into local cache - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - } + onEvent = { event, relay -> + // Route all fetched events (metadata, boosted notes, etc.) into cache + localCache.consume(event, relay) }, ) @@ -221,7 +218,7 @@ class DesktopRelaySubscriptionsCoordinator( filters = indexRelays.associateWith { listOf( - com.vitorpamplona.quartz.nip01Core.relay.filters.Filter( + Filter( kinds = listOf(com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent.KIND), authors = listOf(pubkey), limit = 1, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt index a7d4a633a..589e500cf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt @@ -37,7 +37,7 @@ enum class FeedMode { */ fun createGlobalFeedSubscription( relays: Set, - limit: Int = 50, + limit: Int = 200, onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, ): SubscriptionConfig = @@ -55,7 +55,7 @@ fun createGlobalFeedSubscription( fun createFollowingFeedSubscription( relays: Set, followedUsers: List, - limit: Int = 50, + limit: Int = 200, onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, ): SubscriptionConfig? { @@ -121,9 +121,21 @@ fun createThreadRepliesSubscription( subId = generateSubId("thread-${noteId.take(8)}"), filters = listOf( + // Kind 1 replies via lowercase e-tag (NIP-10) FilterBuilders.byETags( eventIds = listOf(noteId), - kinds = listOf(1), // TextNoteEvent + kinds = listOf(1), + limit = limit, + ), + // Kind 1111 comments via lowercase e-tag (reply parent) or uppercase E-tag (root) + FilterBuilders.byTags( + tags = mapOf("e" to listOf(noteId)), + kinds = listOf(1111), + limit = limit, + ), + FilterBuilders.byTags( + tags = mapOf("E" to listOf(noteId)), + kinds = listOf(1111), limit = limit, ), ), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index fee6d8c3b..ecdefb508 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter * Provides convenience functions for creating relay subscription filters. */ object FilterBuilders { + private val FEED_KINDS = listOf(1, 6, 16) // TextNoteEvent, RepostEvent, GenericRepostEvent + /** * Creates a filter for text notes (kind 1) from all authors. * @@ -41,7 +43,7 @@ object FilterBuilders { until: Long? = null, ): Filter = Filter( - kinds = listOf(1), // TextNoteEvent.KIND + kinds = FEED_KINDS, limit = limit, since = since, until = until, @@ -63,7 +65,7 @@ object FilterBuilders { until: Long? = null, ): Filter = Filter( - kinds = listOf(1), // TextNoteEvent.KIND + kinds = FEED_KINDS, authors = authors, limit = limit, since = since, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 654da5d7c..23dc88b35 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -55,9 +55,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.compose.elements.BoostedMark +import com.vitorpamplona.amethyst.commons.compose.layouts.GenericRepostLayout import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -67,14 +71,22 @@ import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote data class LightboxState( val urls: List, @@ -86,6 +98,8 @@ data class LightboxState( /** * Note card that reads counts from the Note model (cache-backed). * Event is extracted from Note for signing operations in NoteActionsRow. + * Handles reposts (kind 6/16) by showing overlapping avatars + "Boosted" label + * and rendering the original note content. */ @Composable fun FeedNoteCard( @@ -102,53 +116,141 @@ fun FeedNoteCard( onMediaClick: ((List, Int, Float) -> Unit)? = null, ) { val event = note.event ?: return + val isRepost = event is RepostEvent || event is GenericRepostEvent - // Observe Note.flowSet for live count updates - val flowSet = remember(note) { note.flow() } - val reactionsState by flowSet.reactions.stateFlow.collectAsState() - val repliesState by flowSet.replies.stateFlow.collectAsState() - val zapsState by flowSet.zaps.stateFlow.collectAsState() + if (isRepost) { + val originalNote = note.replyTo?.lastOrNull() + if (originalNote == null) { + return + } - // Read counts from Note model (re-read on each stateFlow emission) - val reactionCount = note.countReactions() - val replyCount = note.replies.size - val repostCount = note.boosts.size - val zapAmount = note.zapsAmount + // Observe original note's flowSet — MUST happen before reading .event + // so we recompose when the async fetch fills in the event + val flowSet = remember(originalNote) { originalNote.flow() } + val metadataState by flowSet.metadata.stateFlow.collectAsState() + val reactionsState by flowSet.reactions.stateFlow.collectAsState() + val repliesState by flowSet.replies.stateFlow.collectAsState() + val zapsState by flowSet.zaps.stateFlow.collectAsState() - // Clean up flowSet when card leaves composition - DisposableEffect(note) { - onDispose { note.clearFlow() } - } + DisposableEffect(originalNote) { + onDispose { originalNote.clearFlow() } + } - Column { - NoteCard( - note = event.toNoteDisplayData(localCache), - localCache = localCache, - onClick = { onNavigateToThread(event.id) }, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - onImageClick = onImageClick, - onMediaClick = onMediaClick, - ) + // Now read event — recomposition will re-read this when metadata invalidates + val originalEvent = originalNote.event + if (originalEvent == null) { + return + } - // Action buttons (only if logged in) - if (account != null) { - NoteActionsRow( - event = event, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReplyClick = onReply, - onZapFeedback = onZapFeedback, + val reactionCount = originalNote.countReactions() + val replyCount = originalNote.replies.size + val repostCount = originalNote.boosts.size + val zapAmount = originalNote.zapsAmount + + val reposterUser = localCache.getUserIfExists(event.pubKey) + val originalUser = localCache.getUserIfExists(originalEvent.pubKey) + + Column { + // Repost header: overlapping avatars + "Boosted" label + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - zapCount = note.zaps.size, - zapAmountSats = zapAmount.toLong(), - zapReceipts = emptyList(), // TODO: extract ZapReceipts from Note.zaps - reactionCount = reactionCount, - replyCount = replyCount, - repostCount = repostCount, + ) { + GenericRepostLayout( + baseAuthorPicture = { + UserAvatar( + userHex = originalEvent.pubKey, + pictureUrl = originalUser?.profilePicture(), + size = 35.dp, + ) + }, + repostAuthorPicture = { + UserAvatar( + userHex = event.pubKey, + pictureUrl = reposterUser?.profilePicture(), + size = 35.dp, + ) + }, + ) + BoostedMark() + } + + // Original note content + NoteCard( + note = originalEvent.toNoteDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(originalEvent.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onImageClick = onImageClick, + onMediaClick = onMediaClick, ) + + // Action buttons for original note + if (account != null) { + NoteActionsRow( + event = originalEvent, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = onReply, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = originalNote.zaps.size, + zapAmountSats = zapAmount.toLong(), + zapReceipts = emptyList(), + reactionCount = reactionCount, + replyCount = replyCount, + repostCount = repostCount, + ) + } + } + } else { + // Regular note rendering (unchanged) + val flowSet = remember(note) { note.flow() } + val reactionsState by flowSet.reactions.stateFlow.collectAsState() + val repliesState by flowSet.replies.stateFlow.collectAsState() + val zapsState by flowSet.zaps.stateFlow.collectAsState() + + val reactionCount = note.countReactions() + val replyCount = note.replies.size + val repostCount = note.boosts.size + val zapAmount = note.zapsAmount + + DisposableEffect(note) { + onDispose { note.clearFlow() } + } + + Column { + NoteCard( + note = event.toNoteDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + onImageClick = onImageClick, + onMediaClick = onMediaClick, + ) + + if (account != null) { + NoteActionsRow( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = onReply, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + zapCount = note.zaps.size, + zapAmountSats = zapAmount.toLong(), + zapReceipts = emptyList(), + reactionCount = reactionCount, + replyCount = replyCount, + repostCount = repostCount, + ) + } } } } @@ -248,16 +350,115 @@ fun FeedScreen( val feedState by viewModel.feedState.feedContent.collectAsState() - // Load metadata for visible notes via Coordinator (rate-limited) + // Load metadata for visible notes + repost/quoted note authors via Coordinator LaunchedEffect(feedState, subscriptionsCoordinator) { if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) { val notes = viewModel.feedState.visibleNotes() if (notes.isNotEmpty()) { subscriptionsCoordinator.loadMetadataForNotes(notes) + + // Also load metadata for repost original + quoted note authors + val referencedAuthors = + notes + .filter { it.event is RepostEvent || it.event is GenericRepostEvent } + .mapNotNull { + it.replyTo + ?.lastOrNull() + ?.author + ?.pubkeyHex + } + subscriptionsCoordinator.loadMetadataForPubkeys(referencedAuthors) } } } + // Fetch missing referenced notes (repost originals + quoted notes via e-tags) + // Uses a direct relay subscription — bypasses the coordinator pipeline + val missingNoteIds = + remember(feedState) { + if (feedState !is FeedState.Loaded) return@remember emptyList() + val notes = viewModel.feedState.visibleNotes() + + // Repost originals where event is null + val repostOriginals = + notes + .filter { it.event is RepostEvent || it.event is GenericRepostEvent } + .mapNotNull { it.replyTo?.lastOrNull() } + val repostIds = repostOriginals.filter { it.event == null }.map { it.idHex } + + // Quoted note IDs from content bech32s (nostr:nevent/nostr:note references) + val allEvents = (notes + repostOriginals.filter { it.event != null }).mapNotNull { it.event } + val contentQuotedIds = + allEvents + .flatMap { event -> + UrlParser().parseValidUrls(event.content).bech32s.mapNotNull { bech32 -> + when (val entity = Nip19Parser.uriToRoute(bech32)?.entity) { + is NNote -> entity.hex + is NEvent -> entity.hex + else -> null + } + } + }.filter { localCache.getNoteIfExists(it)?.event == null } + + (repostIds + contentQuotedIds).distinct() + } + + rememberSubscription(allRelayUrls, missingNoteIds, relayManager = relayManager) { + if (allRelayUrls.isEmpty() || missingNoteIds.isEmpty()) return@rememberSubscription null + SubscriptionConfig( + subId = generateSubId("fetch-referenced"), + filters = listOf(FilterBuilders.byIds(missingNoteIds)), + relays = allRelayUrls, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) + } + + // Fetch missing metadata (kind 0) for all note authors including referenced notes + val missingAuthorPubkeys = + remember(feedState) { + if (feedState !is FeedState.Loaded) return@remember emptyList() + val notes = viewModel.feedState.visibleNotes() + + // Collect all referenced notes (repost originals + e-tag/content referenced) + val repostOriginals = + notes + .filter { it.event is RepostEvent || it.event is GenericRepostEvent } + .mapNotNull { it.replyTo?.lastOrNull() } + + // All notes in cache that are referenced by visible notes + val allEvents = (notes + repostOriginals.filter { it.event != null }).mapNotNull { it.event } + val quotedNotes = + allEvents.flatMap { event -> + UrlParser().parseValidUrls(event.content).bech32s.mapNotNull { bech32 -> + when (val entity = Nip19Parser.uriToRoute(bech32)?.entity) { + is NNote -> localCache.getNoteIfExists(entity.hex) + is NEvent -> localCache.getNoteIfExists(entity.hex) + else -> null + } + } + } + + // Authors from feed notes + repost originals + quoted notes + (notes.mapNotNull { it.author } + repostOriginals.mapNotNull { it.author } + quotedNotes.mapNotNull { it.author }) + .filter { it.profilePicture() == null } + .map { it.pubkeyHex } + .distinct() + } + + rememberSubscription(allRelayUrls, missingAuthorPubkeys, relayManager = relayManager) { + if (allRelayUrls.isEmpty() || missingAuthorPubkeys.isEmpty()) return@rememberSubscription null + SubscriptionConfig( + subId = generateSubId("fetch-metadata"), + filters = listOf(FilterBuilders.userMetadataMultiple(missingAuthorPubkeys)), + relays = allRelayUrls, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) + } + // Request interaction subscriptions — keyed on feedMode (stable), not feedState (changes every 250ms) DisposableEffect(feedMode, subscriptionsCoordinator) { val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 80438b7b0..42f3ad82a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -204,7 +204,8 @@ fun ReadsScreen( val events by eventState.items.collectAsState() var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } - var followedUsers by remember { mutableStateOf>(emptySet()) } + // Seed followedUsers from cache so Following mode works immediately on back-nav + var followedUsers by remember { mutableStateOf(localCache.followedUsers.value) } var eoseReceivedCount by remember { mutableStateOf(0) } val initialLoadComplete = eoseReceivedCount > 0 @@ -272,7 +273,8 @@ fun ReadsScreen( createFollowingLongFormFeedSubscription( relays = connectedRelays, followedUsers = followedUsers.toList(), - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) if (event is LongTextNoteEvent) { eventState.addItem(event) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 0c9582151..914f6fcbd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -121,6 +121,7 @@ fun UserProfileScreen( onBack: () -> Unit, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, onNavigateToArticle: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, ) { @@ -796,6 +797,7 @@ fun UserProfileScreen( onReply = onCompose, onZapFeedback = onZapFeedback, onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 8d8f01805..a72032acd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -296,6 +296,7 @@ internal fun RootContent( onBack = {}, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) @@ -325,6 +326,7 @@ internal fun RootContent( onBack = {}, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, onZapFeedback = onZapFeedback, ) } @@ -426,6 +428,7 @@ internal fun OverlayContent( onBack = onBack, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 44417e19a..7cc2500c0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -38,6 +38,9 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -63,6 +66,7 @@ import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl +import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NNote @@ -324,6 +328,7 @@ fun NoteCard( private data class ResolvedMention( val displayText: String, val pubKeyHex: String? = null, + val noteIdHex: String? = null, ) /** @@ -354,11 +359,11 @@ private fun resolveBech32( } is NNote -> { - ResolvedMention("note:${entity.hex.take(8)}...") + ResolvedMention(displayText = "note:${entity.hex.take(8)}...", noteIdHex = entity.hex) } is NEvent -> { - ResolvedMention("note:${entity.hex.take(8)}...") + ResolvedMention(displayText = "note:${entity.hex.take(8)}...", noteIdHex = entity.hex) } else -> { @@ -386,12 +391,37 @@ fun extractMentionedPubkeys(bech32s: Set): List = * URLs are underlined in primary color; bech32 mentions show as @displayName in primary color * and navigate to profile on click. */ +private data class ContentSegment( + val start: Int, + val raw: String, + val isUrl: Boolean, +) + +private fun buildSegments( + content: String, + schemeUrls: Collection, + bech32s: Collection, +): List { + val segments = mutableListOf() + for (url in schemeUrls) { + val idx = content.indexOf(url) + if (idx != -1) segments.add(ContentSegment(idx, url, true)) + } + for (bech32 in bech32s) { + val idx = content.indexOf(bech32) + if (idx != -1) segments.add(ContentSegment(idx, bech32, false)) + } + segments.sortBy { it.start } + return segments +} + @Composable fun RichTextContent( content: String, urls: Urls, localCache: DesktopLocalCache? = null, onMentionClick: ((String) -> Unit)? = null, + onNavigateToThread: ((String) -> Unit)? = null, modifier: Modifier = Modifier, ) { val defaultColor = MaterialTheme.colorScheme.onSurface @@ -404,80 +434,159 @@ fun RichTextContent( color = defaultColor, modifier = modifier, ) + return + } + + // Resolve bech32s to find quoted notes vs inline mentions + val resolvedBech32s = + remember(urls.bech32s, localCache) { + urls.bech32s.associateWith { resolveBech32(it, localCache) } + } + + // Collect quoted note IDs (nevent/note references) + val quotedBech32s = remember(resolvedBech32s) { resolvedBech32s.filter { it.value.noteIdHex != null }.keys } + val quotedNoteIds = remember(resolvedBech32s) { resolvedBech32s.values.mapNotNull { it.noteIdHex }.toSet() } + + if (quotedNoteIds.isEmpty()) { + // No quoted notes — render everything as annotated text + val segments = remember(content, urls) { buildSegments(content, urls.withScheme, urls.bech32s) } + RichAnnotatedText(content, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick, modifier) } else { - data class Segment( - val start: Int, - val raw: String, - val isUrl: Boolean, - ) - - val segments = mutableListOf() - for (url in urls.withScheme) { - val idx = content.indexOf(url) - if (idx != -1) segments.add(Segment(idx, url, true)) - } - for (bech32 in urls.bech32s) { - val idx = content.indexOf(bech32) - if (idx != -1) segments.add(Segment(idx, bech32, false)) - } - segments.sortBy { it.start } - - val annotatedText = - buildAnnotatedString { - var lastIndex = 0 - - for (segment in segments) { - if (segment.start < lastIndex) continue - - // Add text before segment - if (segment.start > lastIndex) { - append(content.substring(lastIndex, segment.start)) + // Has quoted notes — render text + embedded note cards + Column(modifier = modifier) { + // Strip quoted note bech32 references from text + val strippedText = + remember(content, quotedBech32s) { + var text = content + for (bech32 in quotedBech32s) { + text = text.replace(bech32, "").trim() } - - if (segment.isUrl) { - withStyle( - SpanStyle( - color = primaryColor, - textDecoration = TextDecoration.Underline, - ), - ) { - append(segment.raw) - } - } else { - val resolved = resolveBech32(segment.raw, localCache) - if (resolved.pubKeyHex != null && onMentionClick != null) { - val pubKey = resolved.pubKeyHex - withLink( - LinkAnnotation.Clickable( - tag = "mention", - styles = TextLinkStyles(SpanStyle(color = primaryColor)), - ) { - onMentionClick(pubKey) - }, - ) { - append(resolved.displayText) - } - } else { - withStyle(SpanStyle(color = primaryColor)) { - append(resolved.displayText) - } - } - } - - lastIndex = segment.start + segment.raw.length + text } - // Add remaining text - if (lastIndex < content.length) { - append(content.substring(lastIndex)) - } + if (strippedText.isNotBlank()) { + val inlineBech32s = urls.bech32s - quotedBech32s + val segments = remember(strippedText, urls, inlineBech32s) { buildSegments(strippedText, urls.withScheme, inlineBech32s) } + RichAnnotatedText(strippedText, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick) } - Text( - text = annotatedText, - style = MaterialTheme.typography.bodyMedium, - color = defaultColor, - modifier = modifier, - ) + // Render quoted notes as embedded cards + for (noteId in quotedNoteIds) { + Spacer(Modifier.height(8.dp)) + QuotedNoteEmbed(noteId, localCache, onMentionClick, onNavigateToThread) + } + } } } + +/** + * Renders a quoted note by ID. Observes the note's metadata flow so it + * recomposes when the event arrives asynchronously from a relay fetch. + * Also observes the author's metadata for display name / avatar updates. + */ +@Composable +fun QuotedNoteEmbed( + noteId: String, + localCache: DesktopLocalCache?, + onMentionClick: ((String) -> Unit)? = null, + onNavigateToThread: ((String) -> Unit)? = null, +) { + if (localCache == null) return + + // getOrCreateNote ensures a placeholder exists so subscriptions can find it + val note = remember(noteId) { localCache.getOrCreateNote(noteId) } + + // Observe note metadata flow — recomposes when loadEvent() is called + val flowSet = remember(note) { note.flow() } + val metadataState by flowSet.metadata.stateFlow.collectAsState() + + DisposableEffect(note) { + onDispose { note.clearFlow() } + } + + val event = note.event + if (event != null) { + // Recompute on every recomposition — picks up user metadata changes + val displayData = event.toNoteDisplayData(localCache) + + NoteCard( + note = displayData, + localCache = localCache, + onClick = onNavigateToThread?.let { nav -> { nav(event.id) } }, + onAuthorClick = onMentionClick, + onMentionClick = onMentionClick, + ) + } else { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ) { + Text( + "Loading quoted note...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp), + ) + } + } +} + +@Composable +private fun RichAnnotatedText( + content: String, + segments: List, + resolvedBech32s: Map, + defaultColor: androidx.compose.ui.graphics.Color, + primaryColor: androidx.compose.ui.graphics.Color, + onMentionClick: ((String) -> Unit)?, + modifier: Modifier = Modifier, +) { + val annotatedText = + buildAnnotatedString { + var lastIndex = 0 + for (seg in segments) { + if (seg.start < lastIndex) continue + if (seg.start > lastIndex) { + append(content.substring(lastIndex, seg.start)) + } + if (seg.isUrl) { + withStyle(SpanStyle(color = primaryColor, textDecoration = TextDecoration.Underline)) { + append(seg.raw) + } + } else { + val resolved = resolvedBech32s[seg.raw] ?: ResolvedMention(seg.raw) + if (resolved.pubKeyHex != null && onMentionClick != null) { + val pubKey = resolved.pubKeyHex + withLink( + LinkAnnotation.Clickable( + tag = "mention", + styles = TextLinkStyles(SpanStyle(color = primaryColor)), + ) { + onMentionClick(pubKey) + }, + ) { + append(resolved.displayText) + } + } else { + withStyle(SpanStyle(color = primaryColor)) { + append(resolved.displayText) + } + } + } + lastIndex = seg.start + seg.raw.length + } + if (lastIndex < content.length) { + append(content.substring(lastIndex)) + } + } + + Text( + text = annotatedText, + style = MaterialTheme.typography.bodyMedium, + color = defaultColor, + modifier = modifier, + ) +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt index 162b178b8..f1fdbacbf 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt @@ -37,7 +37,7 @@ class FilterBuildersTest { fun testTextNotesGlobal() { val filter = FilterBuilders.textNotesGlobal(limit = 50) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(50, filter.limit) assertNull(filter.authors) assertNull(filter.tags) @@ -51,7 +51,7 @@ class FilterBuildersTest { val until = 1640995200L // 2022-01-01 val filter = FilterBuilders.textNotesGlobal(limit = 100, since = since, until = until) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(100, filter.limit) assertEquals(since, filter.since) assertEquals(until, filter.until) @@ -62,7 +62,7 @@ class FilterBuildersTest { val authors = listOf(testPubKey, testPubKey2) val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 25) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(authors, filter.authors) assertEquals(25, filter.limit) assertNull(filter.tags) @@ -74,7 +74,7 @@ class FilterBuildersTest { val since = 1609459200L val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 10, since = since) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(authors, filter.authors) assertEquals(10, filter.limit) assertEquals(since, filter.since) @@ -421,7 +421,7 @@ class FilterBuildersTest { val filter = FilterBuilders.textNotesGlobal(limit = 50) assertTrue(!filter.isEmpty()) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(50, filter.limit) } @@ -431,7 +431,7 @@ class FilterBuildersTest { val filter = FilterBuilders.textNotesFromAuthors(followedUsers, limit = 50) assertTrue(!filter.isEmpty()) - assertEquals(listOf(1), filter.kinds) + assertEquals(listOf(1, 6, 16), filter.kinds) assertEquals(followedUsers, filter.authors) assertEquals(50, filter.limit) } @@ -447,7 +447,7 @@ class FilterBuildersTest { assertTrue(!contactListFilter.isEmpty()) assertEquals(listOf(0), metadataFilter.kinds) - assertEquals(listOf(1), postsFilter.kinds) + assertEquals(listOf(1, 6, 16), postsFilter.kinds) assertEquals(listOf(3), contactListFilter.kinds) } diff --git a/quartz/CLIENT.md b/quartz/CLIENT.md new file mode 100644 index 000000000..c7d58b11d --- /dev/null +++ b/quartz/CLIENT.md @@ -0,0 +1,562 @@ +# Building a Nostr Client with Quartz + +This guide walks you through building a Nostr client using Quartz's `NostrClient` and [Ktor](https://ktor.io/) as the WebSocket transport layer. + +## Overview + +Quartz provides a complete, transport-agnostic client engine: + +| Component | Class | Role | +|-----------|-------|------| +| **NostrClient** | `com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient` | Manages relay connections, subscriptions, and event publishing | +| **WebsocketBuilder** | `com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder` | Factory interface for pluggable WebSocket transports | +| **Filter** | `com.vitorpamplona.quartz.nip01Core.relay.filters.Filter` | NIP-01 subscription filters | +| **KeyPair** | `com.vitorpamplona.quartz.nip01Core.crypto.KeyPair` | Schnorr key pair for signing events | +| **NostrSignerInternal** | `com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal` | Signs events with a local private key | + +`NostrClient` doesn't know about HTTP or WebSockets. You provide a `WebsocketBuilder` that creates transport connections, and the client handles relay pooling, subscription management, reconnection, and event delivery. + +## Quick Start + +### 1. Add Dependencies + +In your `build.gradle.kts`: + +```kotlin +dependencies { + // Ktor WebSocket client + implementation("io.ktor:ktor-client-core:3.1.1") + implementation("io.ktor:ktor-client-cio:3.1.1") + implementation("io.ktor:ktor-client-websockets:3.1.1") + + // Quartz (use your local module or published artifact) + implementation(project(":quartz")) +} +``` + +### 2. Implement the Ktor WebSocket Transport + +Quartz uses two interfaces for WebSocket connectivity: + +```kotlin +// WebSocket — a single connection to a relay +interface WebSocket { + fun needsReconnect(): Boolean + fun connect() + fun disconnect() + fun send(msg: String): Boolean +} + +// WebsocketBuilder — factory that creates WebSocket instances +interface WebsocketBuilder { + fun build(url: NormalizedRelayUrl, out: WebSocketListener): WebSocket +} + +// WebSocketListener — callbacks from the transport layer +interface WebSocketListener { + fun onOpen(pingMillis: Int, compression: Boolean) + fun onMessage(text: String) // must deliver messages in order + fun onClosed(code: Int, reason: String) + fun onFailure(t: Throwable, code: Int?, response: String?) +} +``` + +Here's a complete Ktor implementation: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.websocket.* +import io.ktor.websocket.* +import kotlinx.coroutines.* + +class KtorWebSocket( + private val url: NormalizedRelayUrl, + private val client: HttpClient, + private val out: WebSocketListener, +) : WebSocket { + private var session: DefaultClientWebSocketSession? = null + private var job: Job? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override fun needsReconnect() = session == null + + override fun connect() { + job = scope.launch { + try { + client.webSocket(url.url) { + session = this + out.onOpen(0, false) + + try { + for (frame in incoming) { + if (frame is Frame.Text) { + out.onMessage(frame.readText()) + } + } + } finally { + val reason = closeReason.await() + session = null + out.onClosed( + reason?.code?.toInt() ?: 1000, + reason?.message ?: "Connection closed", + ) + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + session = null + out.onFailure(e, null, e.message) + } + } + } + + override fun disconnect() { + job?.cancel() + job = null + session = null + } + + override fun send(msg: String): Boolean { + val currentSession = session ?: return false + return try { + runBlocking { currentSession.send(Frame.Text(msg)) } + true + } catch (e: Exception) { + false + } + } + + class Builder( + private val client: HttpClient, + ) : WebsocketBuilder { + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ) = KtorWebSocket(url, client, out) + } +} +``` + +### 3. Subscribe to Events (Flow API) + +The simplest way to receive events — `subscribeAsFlow` returns a `Flow>` that accumulates events as they arrive: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.websocket.* +import kotlinx.coroutines.* + +fun main() = runBlocking { + // 1. Create the Ktor HTTP client with WebSocket support + val httpClient = HttpClient(CIO) { + install(WebSockets) + } + + // 2. Create the NostrClient + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(KtorWebSocket.Builder(httpClient), scope) + + // 3. Subscribe to text notes (kind 1) from a relay + val flow = client.subscribeAsFlow( + relay = "wss://nos.lol", + filter = Filter(kinds = listOf(1), limit = 20), + ) + + // 4. Collect events as they arrive + val job = launch { + flow.collect { events -> + println("Got ${events.size} events so far") + events.forEach { event -> + println(" [${event.pubKey.take(8)}...] ${event.content.take(80)}") + } + } + } + + // Let it run for 10 seconds + delay(10_000) + job.cancel() + + // 5. Clean up + client.disconnect() + scope.cancel() + httpClient.close() +} +``` + +### 4. Subscribe to Events (Callback API) + +For more control, use the manual subscription API with `SubscriptionListener`: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.* + +fun main() = runBlocking { + val httpClient = HttpClient(CIO) { install(WebSockets) } + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(KtorWebSocket.Builder(httpClient), scope) + + // Define a listener for subscription events + val listener = object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + val label = if (isLive) "LIVE" else "STORED" + println("[$label] ${event.pubKey.take(8)}...: ${event.content.take(80)}") + } + + override fun onEose(relay: NormalizedRelayUrl, forFilters: List?) { + println("--- End of stored events from $relay ---") + } + } + + // Build filters per relay + val filters = mapOf( + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( + Filter(kinds = listOf(1), limit = 50), + ), + ) + + // Subscribe + client.subscribe("my-feed", filters, listener) + + delay(15_000) + + // Unsubscribe and clean up + client.unsubscribe("my-feed") + client.disconnect() + scope.cancel() + httpClient.close() +} +``` + +### 5. Publish Events + +Create a key pair, sign an event, and publish it to relays: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.* + +fun main() = runBlocking { + val httpClient = HttpClient(CIO) { install(WebSockets) } + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(KtorWebSocket.Builder(httpClient), scope) + + // 1. Create a key pair (generates random keys) + val signer = NostrSignerInternal(KeyPair()) + println("Public key: ${signer.pubKey}") + + // 2. Build and sign a text note (kind 1) + val event = signer.sign(TextNoteEvent.build("Hello from Quartz!")) + println("Event ID: ${event.id}") + + // 3. Publish and wait for relay confirmation + val accepted = client.publishAndConfirm( + event = event, + relayList = setOf( + "wss://nos.lol".normalizeRelayUrl(), + "wss://relay.damus.io".normalizeRelayUrl(), + ), + ) + + println("Published: $accepted") + + // 4. Clean up + client.disconnect() + scope.cancel() + httpClient.close() +} +``` + +For detailed per-relay results, use `publishAndConfirmDetailed`: + +```kotlin +val results = client.publishAndConfirmDetailed(event, relayList) +results.forEach { (relay, success) -> + println(" $relay: ${if (success) "accepted" else "rejected"}") +} +``` + +## How It Works + +### Architecture + +``` +┌──────────────────────────────────────────────┐ +│ Your Application │ +│ │ +│ subscribeAsFlow() / subscribe() / publish()│ +└─────────────────────┬────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ NostrClient │ +│ │ +│ PoolRequests (active subscriptions) │ +│ PoolCounts (count queries) │ +│ PoolEventOutbox (pending publishes) │ +└─────────────────────┬────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ RelayPool │ +│ │ +│ Manages BasicRelayClient per relay URL │ +│ Auto-connects/disconnects as needed │ +│ Tracks connected/available relay state │ +└─────────────────────┬────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ BasicRelayClient (per relay) │ +│ │ +│ Connection lifecycle + reconnection │ +│ NIP-01 message parsing │ +│ Exponential backoff on failures │ +└─────────────────────┬────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ WebsocketBuilder → WebSocket │ +│ │ +│ KtorWebSocket (this guide) │ +│ BasicOkHttpWebSocket (built-in for JVM) │ +└──────────────────────────────────────────────┘ +``` + +### Connection Lifecycle + +1. You call `subscribe()` or `publish()` with relay URLs +2. `NostrClient` tells `RelayPool` which relays are needed +3. `RelayPool` creates a `BasicRelayClient` for each new relay +4. `BasicRelayClient` uses `WebsocketBuilder` to create and open a `WebSocket` +5. On connection, all pending subscriptions and events are synced automatically +6. On disconnection, exponential backoff retries the connection +7. On reconnection, filters are re-sent and events re-delivered + +### Subscription Flow + +``` +client.subscribe("my-feed", filters, listener) + │ + ▼ +NostrClient stores filters in PoolRequests + │ + ▼ +RelayPool connects to needed relays + │ + ▼ +BasicRelayClient sends ["REQ", "my-feed", ...] + │ + ▼ +Relay responds with: + ["EVENT", "my-feed", ] → listener.onEvent(event, isLive=false) + ["EVENT", "my-feed", ] → listener.onEvent(event, isLive=false) + ["EOSE", "my-feed"] → listener.onEose(relay) + ["EVENT", "my-feed", ] → listener.onEvent(event, isLive=true) ← new events +``` + +### NIP-01 Commands + +| Direction | Command | Description | +|-----------|---------|-------------| +| Client → Relay | `["REQ", subId, filter...]` | Open a subscription | +| Client → Relay | `["CLOSE", subId]` | Close a subscription | +| Client → Relay | `["EVENT", event]` | Publish an event | +| Relay → Client | `["EVENT", subId, event]` | Deliver a matching event | +| Relay → Client | `["EOSE", subId]` | End of stored events | +| Relay → Client | `["OK", eventId, success, message]` | Publish acknowledgement | +| Relay → Client | `["NOTICE", message]` | Relay notice | + +## Filters + +Filters define what events a subscription matches. All fields are optional — omitted fields match everything. + +```kotlin +// Latest 20 text notes +Filter(kinds = listOf(1), limit = 20) + +// Metadata for specific authors +Filter( + kinds = listOf(0), + authors = listOf( + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + ), +) + +// Events since a timestamp +Filter(kinds = listOf(1), since = 1700000000) + +// Events tagged with a specific pubkey +Filter( + kinds = listOf(1), + tags = mapOf("p" to listOf("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")), +) + +// Full-text search (NIP-50, relay must support it) +Filter(kinds = listOf(1), search = "bitcoin", limit = 10) +``` + +### Filter Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `ids` | `List?` | Match specific event IDs (64-char hex) | +| `authors` | `List?` | Match specific author pubkeys (64-char hex) | +| `kinds` | `List?` | Match event kinds (0=metadata, 1=text note, 3=contacts, etc.) | +| `tags` | `Map>?` | Match events with any of the listed tag values | +| `since` | `Long?` | Events with `created_at` >= this Unix timestamp | +| `until` | `Long?` | Events with `created_at` <= this Unix timestamp | +| `limit` | `Int?` | Maximum number of events to return | +| `search` | `String?` | Full-text search (NIP-50) | + +## Key Management + +```kotlin +// Generate a new random key pair +val keyPair = KeyPair() + +// Import an existing private key (32 bytes) +val keyPair = KeyPair(privKey = hexToByteArray("your-64-char-hex-private-key")) + +// Read-only (public key only, cannot sign) +val keyPair = KeyPair(pubKey = hexToByteArray("your-64-char-hex-public-key")) + +// Create a signer for signing events +val signer = NostrSignerInternal(keyPair) +``` + +## Event Kinds + +Common event kinds you'll work with: + +| Kind | NIP | Class | Description | +|------|-----|-------|-------------| +| 0 | NIP-01 | `MetadataEvent` | User metadata (name, picture, about) | +| 1 | NIP-01 | `TextNoteEvent` | Text note (short post) | +| 3 | NIP-02 | `ContactListEvent` | Contact list / follow list | +| 4 | NIP-04 | `PrivateDmEvent` | Encrypted direct message | +| 7 | NIP-25 | `ReactionEvent` | Reaction (like, emoji) | +| 30023 | NIP-23 | `LongTextNoteEvent` | Long-form content (articles) | + +## Multi-Relay Subscriptions + +Subscribe to different filters on different relays: + +```kotlin +val filters = mapOf( + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( + Filter(kinds = listOf(1), limit = 50), + ), + RelayUrlNormalizer.normalize("wss://relay.damus.io") to listOf( + Filter(kinds = listOf(0, 3), authors = listOf(myPubKey)), + ), +) + +client.subscribe("multi-relay-feed", filters, listener) +``` + +## Using OkHttp Instead of Ktor + +Quartz ships with a built-in OkHttp WebSocket implementation for JVM/Android: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import okhttp3.OkHttpClient + +val httpClient = OkHttpClient.Builder() + .followRedirects(true) + .followSslRedirects(true) + .build() + +val socketBuilder = BasicOkHttpWebSocket.Builder { url -> httpClient } +val client = NostrClient(socketBuilder, scope) +``` + +## Full Example: Simple Feed Reader + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.websocket.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.take + +fun main() = runBlocking { + val httpClient = HttpClient(CIO) { install(WebSockets) } + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(KtorWebSocket.Builder(httpClient), scope) + + println("Connecting to relay...") + + client.subscribeAsFlow( + relay = "wss://nos.lol", + filter = Filter(kinds = listOf(1), limit = 10), + ).take(1).collect { events -> + println("\n=== Latest ${events.size} text notes ===\n") + events.forEach { event -> + println("Author: ${event.pubKey.take(16)}...") + println("Content: ${event.content.take(120)}") + println("---") + } + } + + client.disconnect() + scope.cancel() + httpClient.close() +} +``` + +## Key Source Files + +All client infrastructure lives in the `quartz` module: + +``` +quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/ +├── relay/client/ +│ ├── NostrClient.kt # Main client (manages relay pool + subscriptions) +│ ├── INostrClient.kt # Client interface +│ ├── reqs/ +│ │ ├── SubscriptionListener.kt # Callback interface for subscriptions +│ │ └── NostrClientSubscribeAsFlowExt.kt # Flow-based subscription API +│ └── accessories/ +│ └── NostrClientPublishExt.kt # publishAndConfirm / publishAndConfirmDetailed +├── relay/sockets/ +│ ├── WebSocket.kt # Transport interface +│ ├── WebSocketListener.kt # Transport callback interface +│ └── WebsocketBuilder.kt # Transport factory interface +├── relay/filters/ +│ └── Filter.kt # NIP-01 subscription filters +├── crypto/ +│ ├── KeyPair.kt # Schnorr key pair +│ └── Nip01Crypto.kt # Low-level crypto operations +└── signers/ + ├── NostrSigner.kt # Abstract signer + └── NostrSignerInternal.kt # Signs with local private key +``` diff --git a/quartz/RELAY.md b/quartz/RELAY.md new file mode 100644 index 000000000..2532aac38 --- /dev/null +++ b/quartz/RELAY.md @@ -0,0 +1,212 @@ +# Building a Nostr Relay with Quartz + +Quartz provides a transport-agnostic relay engine. You provide a `send` callback per connection, and it gives you a `RelaySession` that accepts raw JSON strings. Plug it into Ktor or any WebSocket transport. + +Both `NostrServer` and `EventStore` implement `AutoCloseable`. + +## Quick Start + +### 1. Add Dependencies + +```kotlin +dependencies { + implementation("io.ktor:ktor-server-core:3.1.1") + implementation("io.ktor:ktor-server-netty:3.1.1") + implementation("io.ktor:ktor-server-websockets:3.1.1") + implementation(project(":quartz")) +} +``` + +### 2. Create the Relay Server + +```kotlin +fun main() { + val store = EventStore(dbName = "relay-events.db") + val server = NostrServer(store) + + embeddedServer(Netty, port = 7777) { + install(WebSockets) + + routing { + webSocket("/") { + server.serve( + send = { json -> launch { send(Frame.Text(json)) } }, + ) { session -> + for (frame in incoming) { + if (frame is Frame.Text) { + session.receive(frame.readText()) + } + } + } + } + } + }.start(wait = true) +} +``` + +That's it. You now have a NIP-01 compliant relay running on `ws://localhost:7777`. + +## Event Store + +### SQLite (Persistent) + +```kotlin +val store = EventStore(dbName = "relay-events.db") +``` + +Uses `androidx.sqlite` with WAL journal mode and a 32 MB memory cache. + +### In-Memory (Testing) + +```kotlin +val store = EventStore(null) +``` + +### Indexing Strategy + +Control which indexes are created: + +```kotlin +val store = EventStore( + dbName = "relay-events.db", + indexStrategy = DefaultIndexingStrategy( + indexEventsByCreatedAtAlone = false, + indexTagsByCreatedAtAlone = false, + indexTagsWithKindAndPubkey = false, + useAndIndexIdOnOrderBy = false, + ), +) +``` + +By default, all single-letter tags with values are indexed. Override `shouldIndex(kind, tag)` for custom behavior. More indexes = faster queries but larger database. + +## Policies + +Policies control what clients can do. They validate commands and can rewrite filters. + +### Built-in Policies + +**`VerifyPolicy`** (default) — Verifies event signatures and IDs. Rejects malformed events. + +```kotlin +val server = NostrServer(store) // Uses VerifyPolicy by default +``` + +**`EmptyPolicy`** — Accepts everything. Useful for testing. + +```kotlin +val server = NostrServer(store, policyBuilder = { EmptyPolicy }) +``` + +**`FullAuthPolicy`** — Requires NIP-42 authentication before accepting any command. + +```kotlin +val server = NostrServer( + store = store, + policyBuilder = { + FullAuthPolicy(relay = "wss://myrelay.example.com/".normalizeRelayUrl()!!) + }, +) +``` + +### Composing Policies + +Chain policies with `+` or `PolicyStack`. All must approve; first rejection wins. + +```kotlin +val server = NostrServer( + store = store, + policyBuilder = { + VerifyPolicy + FullAuthPolicy(relay = "wss://myrelay.example.com/".normalizeRelayUrl()!!) + }, +) +``` + +### Writing a Custom Policy + +Implement `IRelayPolicy`: + +```kotlin +class KindWhitelistPolicy( + private val allowedKinds: Set, +) : IRelayPolicy { + + override fun onConnect(send: (Message) -> Unit) { } + + override fun accept(cmd: EventCmd): PolicyResult = + if (cmd.event.kind in allowedKinds) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("blocked: kind ${cmd.event.kind} not allowed") + } + + override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd) + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + override fun accept(cmd: AuthCmd) = PolicyResult.Accepted(cmd) +} +``` + +Use it: + +```kotlin +val server = NostrServer( + store = store, + policyBuilder = { + VerifyPolicy + KindWhitelistPolicy(allowedKinds = setOf(0, 1, 3, 7, 30023)) + }, +) +``` + +## Testing + +```kotlin +@OptIn(ExperimentalCoroutinesApi::class) +class MyRelayTest { + + @Test + fun clientCanPublishAndSubscribe() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + NostrServer( + store = EventStore(null), + policyBuilder = { EmptyPolicy }, + parentContext = dispatcher, + ).use { server -> + val messages = mutableListOf() + val session = server.connect { messages.add(it) } + + session.receive("""["EVENT",{"id":"${"0".repeat(64)}","pubkey":"${"a".repeat(64)}","created_at":1000,"kind":1,"tags":[],"content":"hello","sig":"${"b".repeat(128)}"}]""") + assertTrue(messages.any { it.contains("OK") }) + + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + assertTrue(messages.any { it.contains("EVENT") }) + assertTrue(messages.any { it.contains("EOSE") }) + } + } +} +``` + +## Key Source Files + +``` +quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/ +├── relay/server/ +│ ├── NostrServer.kt # Main entry point +│ ├── RelaySession.kt # Per-connection handler +│ ├── LiveEventStore.kt # Reactive event streaming +│ ├── IRelayPolicy.kt # Policy interface + PolicyResult +│ └── policies/ +│ ├── EmptyPolicy.kt # Accept everything +│ ├── VerifyPolicy.kt # Signature verification (default) +│ ├── FullAuthPolicy.kt # NIP-42 auth required +│ └── PolicyStack.kt # Chain multiple policies +├── store/ +│ ├── IEventStore.kt # Storage interface +│ └── sqlite/ +│ ├── EventStore.kt # Public SQLite store wrapper +│ ├── SQLiteEventStore.kt # Full implementation +│ └── IndexingStrategy.kt # Index configuration +└── relay/filters/ + ├── Filter.kt # NIP-01 subscription filters + └── FilterMatcher.kt # Event-to-filter matching +``` diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt index 113824d62..249ac7c75 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyStack /** * Defines custom behavior for this relay. @@ -76,7 +77,7 @@ interface IRelayPolicy { */ fun canSendToSession(event: Event): Boolean = true - operator fun plus(other: IRelayPolicy) = listOf(this, other) + operator fun plus(other: IRelayPolicy): IRelayPolicy = PolicyStack(this, other) } sealed interface PolicyResult { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt index 78651df5e..f72c9e7b2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt @@ -41,7 +41,7 @@ class NostrServer( private val store: IEventStore, private val policyBuilder: () -> IRelayPolicy = { VerifyPolicy }, private val parentContext: CoroutineContext = SupervisorJob(), -) { +) : AutoCloseable { private val subStore = LiveEventStore(store) /** Scope for all subscriptions. */ @@ -70,11 +70,38 @@ class NostrServer( } /** - * Shuts down the server, cancelling all subscriptions and sessions. + * Registers a new client connection and serves it for the duration of + * [incoming]. The session is automatically closed when [incoming] returns. + * + * @param send Callback the server uses to send JSON messages to this client. + * @param incoming Suspend block that yields raw JSON strings from the client + * (e.g., reading WebSocket text frames in a loop). */ - fun shutdown() { + suspend fun serve( + send: (String) -> Unit, + incoming: suspend (RelaySession) -> Unit, + ) { + val session = connect(send) + try { + incoming(session) + } finally { + session.close() + } + } + + /** + * Shuts down the server, cancelling all subscriptions and closing the store. + */ + override fun close() { connections.forEach { _, session -> session.cancelAllSubscriptions() } connections.clear() scope.cancel() + store.close() } + + /** + * Shuts down the server, cancelling all subscriptions and closing the store. + */ + @Deprecated("Use close() instead", replaceWith = ReplaceWith("close()")) + fun shutdown() = close() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt index 1d3d74efd..e9268922d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.store import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -interface IEventStore { +interface IEventStore : AutoCloseable { fun insert(event: Event) interface ITransaction { @@ -56,5 +56,5 @@ interface IEventStore { fun deleteExpiredEvents() - fun close() + override fun close() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/EventExt.kt new file mode 100644 index 000000000..6b6505ba6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/EventExt.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip89AppHandlers.definition + +fun AppDefinitionEvent.platformLinks() = tags.platformLinks() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayExt.kt new file mode 100644 index 000000000..87818b5e7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/TagArrayExt.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip89AppHandlers.definition + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.PlatformLinkTag + +fun TagArray.platformLinks() = this.mapNotNull(PlatformLinkTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt index 513f3b2ff..b6a8ba7b7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/tags/PlatformLinkTag.kt @@ -41,7 +41,7 @@ class PlatformLinkTag( } fun parse(tag: Tag): PlatformLinkTag? { - if (match(tag)) return PlatformLinkTag(tag[1], tag[2], tag.getOrNull(3)) + if (match(tag)) return PlatformLinkTag(tag[0], tag[1], tag.getOrNull(2)) return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayExt.kt new file mode 100644 index 000000000..1610ece8e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/recommendation/TagArrayExt.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip89AppHandlers.recommendation + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag + +fun TagArray.recommendations() = this.mapNotNull(RecommendationTag::parse) + +fun TagArray.recommendationAddresses() = this.mapNotNull(RecommendationTag::parseAddressId) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt index bc89df39f..4e7200643 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt @@ -142,7 +142,7 @@ class NostrServerAuthTest { assertEquals(1, collector.messages.size) assertTrue(collector.messages[0].contains("\"AUTH\"")) - server.shutdown() + server.close() } @Test @@ -167,7 +167,7 @@ class NostrServerAuthTest { assertTrue((session.policy as FullAuthPolicy).isAuthenticated()) assertTrue(session.policy.authenticatedUsers.contains(pubkey)) - server.shutdown() + server.close() } @Test @@ -188,7 +188,7 @@ class NostrServerAuthTest { assertTrue(okMessages[0].contains("challenge")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) - server.shutdown() + server.close() } @Test @@ -213,7 +213,7 @@ class NostrServerAuthTest { assertTrue(okMessages[0].contains("relay url")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) - server.shutdown() + server.close() } @Test @@ -242,7 +242,7 @@ class NostrServerAuthTest { assertTrue(okMessages[0].contains("created_at")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) - server.shutdown() + server.close() } @Test @@ -281,7 +281,7 @@ class NostrServerAuthTest { assertTrue(okMessages[0].contains("could not parse message")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) - server.shutdown() + server.close() } @Test @@ -315,7 +315,7 @@ class NostrServerAuthTest { assertTrue(authedPubkeys.contains(pubkey)) assertTrue(authedPubkeys.contains(pubkey2)) - server.shutdown() + server.close() } // -- NIP-42: requireAuth --------------------------------------------------- @@ -337,7 +337,7 @@ class NostrServerAuthTest { assertTrue(okMessages[0].contains("\"false\"")) assertTrue(okMessages[0].contains("auth-required:")) - server.shutdown() + server.close() } @Test @@ -354,7 +354,7 @@ class NostrServerAuthTest { assertEquals(1, closedMessages.size) assertTrue(closedMessages[0].contains("auth-required:")) - server.shutdown() + server.close() } @Test @@ -371,7 +371,7 @@ class NostrServerAuthTest { assertEquals(1, closedMessages.size) assertTrue(closedMessages[0].contains("auth-required:")) - server.shutdown() + server.close() } @Test @@ -410,7 +410,7 @@ class NostrServerAuthTest { val eoseMessages = collector.rawMessagesContaining("EOSE") assertTrue(eoseMessages.isNotEmpty()) - server.shutdown() + server.close() } @Test @@ -430,7 +430,7 @@ class NostrServerAuthTest { assertEquals(1, okMessages.size) assertTrue(okMessages[0].contains("\"true\"")) - server.shutdown() + server.close() } // -- Custom AuthPolicy tests ----------------------------------------------- @@ -471,7 +471,7 @@ class NostrServerAuthTest { assertTrue(okMessages[1].contains("\"false\"")) assertTrue(okMessages[1].contains("auth-required:")) - server.shutdown() + server.close() } @Test @@ -531,6 +531,6 @@ class NostrServerAuthTest { assertEquals(1, events.size) assertEquals(pubkey, events[0].event.pubKey) - server.shutdown() + server.close() } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt index 165bfb0e4..3e033cf49 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt @@ -115,7 +115,7 @@ class NostrServerTest { val stored = store.query(Filter(ids = listOf(event.id))) assertEquals(1, stored.size) - server.shutdown() + server.close() } @Test @@ -138,7 +138,7 @@ class NostrServerTest { assertTrue(okMessages[0].contains("\"true\"")) assertTrue(okMessages[1].contains("\"false\"")) - server.shutdown() + server.close() } // -- REQ command ----------------------------------------------------------- @@ -172,7 +172,7 @@ class NostrServerTest { // Events should be newest first assertTrue(events[0].event.createdAt >= events[1].event.createdAt) - server.shutdown() + server.close() } @Test @@ -195,7 +195,7 @@ class NostrServerTest { val events = collector.parsedEventMessages().filterIsInstance() assertEquals(3, events.size) - server.shutdown() + server.close() } // -- Live subscription ----------------------------------------------------- @@ -226,7 +226,7 @@ class NostrServerTest { assertTrue(newMessages.isNotEmpty()) assertTrue(newMessages[0].contains("\"EVENT\"")) - server.shutdown() + server.close() } @Test @@ -251,7 +251,7 @@ class NostrServerTest { assertEquals(countAfterEose, collector1.messages.size) - server.shutdown() + server.close() } // -- CLOSE command --------------------------------------------------------- @@ -282,7 +282,7 @@ class NostrServerTest { assertEquals(countAfterClose, collector1.messages.size) - server.shutdown() + server.close() } @Test @@ -318,7 +318,7 @@ class NostrServerTest { assertTrue(newMessages[0].contains("\"EVENT\"")) assertTrue(newMessages[0].contains(hexId(2))) - server.shutdown() + server.close() } // -- COUNT command (NIP-45) ------------------------------------------------ @@ -344,7 +344,7 @@ class NostrServerTest { assertEquals(1, countMessages.size) assertTrue(countMessages[0].contains("\"count\":2")) - server.shutdown() + server.close() } // -- Disconnect ------------------------------------------------------------ @@ -374,7 +374,7 @@ class NostrServerTest { assertEquals(countAfterDisconnect, collector1.messages.size) assertEquals(2, collector2.messages.size) - server.shutdown() + server.close() } // -- Invalid messages ------------------------------------------------------ @@ -392,6 +392,6 @@ class NostrServerTest { assertEquals(1, collector.messages.size) assertTrue(collector.messages[0].contains("NOTICE")) - server.shutdown() + server.close() } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/PlatformLinkTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/PlatformLinkTagTest.kt new file mode 100644 index 000000000..27b5e1f54 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/PlatformLinkTagTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip89AppHandlers.definition + +import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.PlatformLinkTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class PlatformLinkTagTest { + @Test + fun parsesWebPlatformLinkWithEntityType() { + val tag = arrayOf("web", "https://example.com/a/", "nevent") + val result = PlatformLinkTag.parse(tag) + + assertNotNull(result) + assertEquals("web", result.platform) + assertEquals("https://example.com/a/", result.uri) + assertEquals("nevent", result.entityType) + } + + @Test + fun parsesAndroidPlatformLink() { + val tag = arrayOf("android", "amethyst://note/", "note") + val result = PlatformLinkTag.parse(tag) + + assertNotNull(result) + assertEquals("android", result.platform) + assertEquals("amethyst://note/", result.uri) + assertEquals("note", result.entityType) + } + + @Test + fun parsesIosPlatformLink() { + val tag = arrayOf("ios", "damus://note/", "naddr") + val result = PlatformLinkTag.parse(tag) + + assertNotNull(result) + assertEquals("ios", result.platform) + assertEquals("damus://note/", result.uri) + assertEquals("naddr", result.entityType) + } + + @Test + fun rejectsTagWithOnlyOneElement() { + val tag = arrayOf("web") + assertNull(PlatformLinkTag.parse(tag)) + } + + @Test + fun rejectsUnknownPlatform() { + val tag = arrayOf("windows", "https://example.com", "note") + assertNull(PlatformLinkTag.parse(tag)) + } + + @Test + fun roundTripPreservesValues() { + val original = PlatformLinkTag("web", "https://example.com/e/", "nevent") + val tagArray = original.toTagArray() + val parsed = PlatformLinkTag.parse(tagArray) + + assertNotNull(parsed) + assertEquals(original.platform, parsed.platform) + assertEquals(original.uri, parsed.uri) + assertEquals(original.entityType, parsed.entityType) + } + + @Test + fun roundTripWithoutEntityType() { + val original = PlatformLinkTag("android", "amethyst://open/", null) + val tagArray = original.toTagArray() + val parsed = PlatformLinkTag.parse(tagArray) + + // Without entityType, tag only has 2 elements, so match requires has(2) which means 3 elements + // This is expected behavior per NIP-89 spec (entityType is specified) + assertNull(parsed) + } +}