Merge branch 'main-upstream' into chess-enhancements-and-bug-fixes
This commit is contained in:
+8
-7
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
|||||||
|
|
||||||
open class DiscoverNIP89FeedFilter(
|
open class DiscoverNIP89FeedFilter(
|
||||||
val account: Account,
|
val account: Account,
|
||||||
|
val targetKind: Int = 5300,
|
||||||
) : AdditiveFeedFilter<Note>() {
|
) : AdditiveFeedFilter<Note>() {
|
||||||
val lastAnnounced = TimeUtils.oneYearAgo()
|
val lastAnnounced = TimeUtils.oneYearAgo()
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ open class DiscoverNIP89FeedFilter(
|
|||||||
override fun feed(): List<Note> {
|
override fun feed(): List<Note> {
|
||||||
val notes =
|
val notes =
|
||||||
LocalCache.addressables.filterIntoSet(AppDefinitionEvent.KIND) { _, it ->
|
LocalCache.addressables.filterIntoSet(AppDefinitionEvent.KIND) { _, it ->
|
||||||
acceptDVM(it)
|
acceptApp(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
return sort(notes)
|
return sort(notes)
|
||||||
@@ -75,29 +76,29 @@ open class DiscoverNIP89FeedFilter(
|
|||||||
account.hiddenUsers.flow.value,
|
account.hiddenUsers.flow.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun acceptDVM(note: Note): Boolean {
|
fun acceptApp(note: Note): Boolean {
|
||||||
val noteEvent = note.event
|
val noteEvent = note.event
|
||||||
return if (noteEvent is AppDefinitionEvent) {
|
return if (noteEvent is AppDefinitionEvent) {
|
||||||
acceptDVM(noteEvent, note.relays)
|
acceptApp(noteEvent, note.relays)
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun acceptDVM(
|
open fun acceptApp(
|
||||||
noteEvent: AppDefinitionEvent,
|
noteEvent: AppDefinitionEvent,
|
||||||
relays: List<NormalizedRelayUrl>,
|
relays: List<NormalizedRelayUrl>,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val filterParams = buildFilterParams(account)
|
val filterParams = buildFilterParams(account)
|
||||||
return noteEvent.appMetaData()?.subscription != true &&
|
return noteEvent.appMetaData()?.subscription != true &&
|
||||||
filterParams.match(noteEvent, relays) &&
|
filterParams.match(noteEvent, relays) &&
|
||||||
noteEvent.includeKind(5300) &&
|
noteEvent.includeKind(targetKind) &&
|
||||||
noteEvent.createdAt > lastAnnounced // && params.match(noteEvent)
|
noteEvent.createdAt > lastAnnounced
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> =
|
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> =
|
||||||
collection.filterTo(HashSet()) {
|
collection.filterTo(HashSet()) {
|
||||||
acceptDVM(it)
|
acceptApp(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun sort(items: Set<Note>): List<Note> {
|
override fun sort(items: Set<Note>): List<Note> {
|
||||||
|
|||||||
@@ -730,6 +730,8 @@
|
|||||||
<string name="relay_monitor_requirements">Požadavky</string>
|
<string name="relay_monitor_requirements">Požadavky</string>
|
||||||
<string name="relay_monitor_last_check">Poslední kontrola</string>
|
<string name="relay_monitor_last_check">Poslední kontrola</string>
|
||||||
<string name="relay_monitor_ms">%1$d ms</string>
|
<string name="relay_monitor_ms">%1$d ms</string>
|
||||||
|
<string name="relay_discovery_accepted_kinds">Přijímané druhy</string>
|
||||||
|
<string name="relay_discovery_geohash">Poloha</string>
|
||||||
<string name="relay_active_subscriptions">Aktivní odběry</string>
|
<string name="relay_active_subscriptions">Aktivní odběry</string>
|
||||||
<string name="relay_active_outbox">Čekající události k odeslání</string>
|
<string name="relay_active_outbox">Čekající události k odeslání</string>
|
||||||
<string name="relay_req_subscriptions">REQ odběry (%1$d)</string>
|
<string name="relay_req_subscriptions">REQ odběry (%1$d)</string>
|
||||||
@@ -1263,6 +1265,10 @@
|
|||||||
<string name="git_repository">Git repositář: %1$s</string>
|
<string name="git_repository">Git repositář: %1$s</string>
|
||||||
<string name="git_web_address">Internet:</string>
|
<string name="git_web_address">Internet:</string>
|
||||||
<string name="git_clone_address">Klon:</string>
|
<string name="git_clone_address">Klon:</string>
|
||||||
|
<string name="nsite_title">Statický web: %1$s</string>
|
||||||
|
<string name="nsite_root_site">Kořenový web</string>
|
||||||
|
<string name="nsite_source">Zdroj:</string>
|
||||||
|
<string name="nsite_servers">Servery:</string>
|
||||||
<string name="existed_since">OTS: %1$s</string>
|
<string name="existed_since">OTS: %1$s</string>
|
||||||
<string name="ots_info_title">Důkaz časového razítka</string>
|
<string name="ots_info_title">Důkaz časového razítka</string>
|
||||||
<string name="ots_info_description">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.</string>
|
<string name="ots_info_description">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.</string>
|
||||||
@@ -1825,4 +1831,21 @@
|
|||||||
<string name="relay_management_relay_icon_url">URL ikony relé</string>
|
<string name="relay_management_relay_icon_url">URL ikony relé</string>
|
||||||
<string name="relay_management_button">Spravovat relé</string>
|
<string name="relay_management_button">Spravovat relé</string>
|
||||||
<string name="manage">Spravovat</string>
|
<string name="manage">Spravovat</string>
|
||||||
|
<string name="relay_members">Členové</string>
|
||||||
|
<string name="relay_members_title">Členové %1$s</string>
|
||||||
|
<string name="relay_members_count">%1$d členů</string>
|
||||||
|
<string name="relay_members_loading">Načítání členů…</string>
|
||||||
|
<string name="relay_members_empty">Žádní členové nenalezeni</string>
|
||||||
|
<string name="relay_members_request_join">Požádat o připojení</string>
|
||||||
|
<string name="relay_members_request_leave">Požádat o opuštění</string>
|
||||||
|
<string name="relay_members_join_sent">Žádost o připojení odeslána</string>
|
||||||
|
<string name="relay_members_leave_sent">Žádost o opuštění odeslána</string>
|
||||||
|
<string name="relay_members_you_are_member">Jste členem</string>
|
||||||
|
<string name="relay_membership_list">Seznam členů relé</string>
|
||||||
|
<string name="relay_member_added">Člen přidán do relé</string>
|
||||||
|
<string name="relay_members_added">%1$d členů přidáno do relé</string>
|
||||||
|
<string name="relay_member_removed">Člen odebrán z relé</string>
|
||||||
|
<string name="relay_members_removed">%1$d členů odebráno z relé</string>
|
||||||
|
<string name="relay_join_request">Žádost o připojení k relé</string>
|
||||||
|
<string name="relay_leave_request">Žádost o opuštění relé</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -735,6 +735,8 @@ anz der Bedingungen ist erforderlich</string>
|
|||||||
<string name="relay_monitor_requirements">Anforderungen</string>
|
<string name="relay_monitor_requirements">Anforderungen</string>
|
||||||
<string name="relay_monitor_last_check">Letzte Prüfung</string>
|
<string name="relay_monitor_last_check">Letzte Prüfung</string>
|
||||||
<string name="relay_monitor_ms">%1$d ms</string>
|
<string name="relay_monitor_ms">%1$d ms</string>
|
||||||
|
<string name="relay_discovery_accepted_kinds">Akzeptierte Arten</string>
|
||||||
|
<string name="relay_discovery_geohash">Standort</string>
|
||||||
<string name="relay_active_subscriptions">Aktive Abonnements</string>
|
<string name="relay_active_subscriptions">Aktive Abonnements</string>
|
||||||
<string name="relay_active_outbox">Ausstehende Outbox-Ereignisse</string>
|
<string name="relay_active_outbox">Ausstehende Outbox-Ereignisse</string>
|
||||||
<string name="relay_req_subscriptions">REQ-Abonnements (%1$d)</string>
|
<string name="relay_req_subscriptions">REQ-Abonnements (%1$d)</string>
|
||||||
@@ -1268,6 +1270,10 @@ anz der Bedingungen ist erforderlich</string>
|
|||||||
<string name="git_repository">Git Repository: %1$s</string>
|
<string name="git_repository">Git Repository: %1$s</string>
|
||||||
<string name="git_web_address">Internet:</string>
|
<string name="git_web_address">Internet:</string>
|
||||||
<string name="git_clone_address">Klonen:</string>
|
<string name="git_clone_address">Klonen:</string>
|
||||||
|
<string name="nsite_title">Statische Website: %1$s</string>
|
||||||
|
<string name="nsite_root_site">Stammseite</string>
|
||||||
|
<string name="nsite_source">Quelle:</string>
|
||||||
|
<string name="nsite_servers">Server:</string>
|
||||||
<string name="existed_since">OTS: %1$s</string>
|
<string name="existed_since">OTS: %1$s</string>
|
||||||
<string name="ots_info_title">Zeitstempel Beweis</string>
|
<string name="ots_info_title">Zeitstempel Beweis</string>
|
||||||
<string name="ots_info_description">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.</string>
|
<string name="ots_info_description">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.</string>
|
||||||
@@ -1830,4 +1836,21 @@ anz der Bedingungen ist erforderlich</string>
|
|||||||
<string name="relay_management_relay_icon_url">Relay-Symbol-URL</string>
|
<string name="relay_management_relay_icon_url">Relay-Symbol-URL</string>
|
||||||
<string name="relay_management_button">Relay verwalten</string>
|
<string name="relay_management_button">Relay verwalten</string>
|
||||||
<string name="manage">Verwalten</string>
|
<string name="manage">Verwalten</string>
|
||||||
|
<string name="relay_members">Mitglieder</string>
|
||||||
|
<string name="relay_members_title">Mitglieder von %1$s</string>
|
||||||
|
<string name="relay_members_count">%1$d Mitglieder</string>
|
||||||
|
<string name="relay_members_loading">Mitglieder werden geladen…</string>
|
||||||
|
<string name="relay_members_empty">Keine Mitglieder gefunden</string>
|
||||||
|
<string name="relay_members_request_join">Beitritt anfragen</string>
|
||||||
|
<string name="relay_members_request_leave">Austritt anfragen</string>
|
||||||
|
<string name="relay_members_join_sent">Beitrittsanfrage gesendet</string>
|
||||||
|
<string name="relay_members_leave_sent">Austrittsanfrage gesendet</string>
|
||||||
|
<string name="relay_members_you_are_member">Du bist Mitglied</string>
|
||||||
|
<string name="relay_membership_list">Relay-Mitgliederliste</string>
|
||||||
|
<string name="relay_member_added">Mitglied zum Relay hinzugefügt</string>
|
||||||
|
<string name="relay_members_added">%1$d Mitglieder zum Relay hinzugefügt</string>
|
||||||
|
<string name="relay_member_removed">Mitglied aus Relay entfernt</string>
|
||||||
|
<string name="relay_members_removed">%1$d Mitglieder aus Relay entfernt</string>
|
||||||
|
<string name="relay_join_request">Relay-Beitrittsanfrage</string>
|
||||||
|
<string name="relay_leave_request">Relay-Austrittsanfrage</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -61,6 +61,8 @@
|
|||||||
<string name="unauthorized_exception_description">हस्ताक्षरकर्ता ने इस कार्य करने के लिए अपरहस्यीकरण का अधिकार नहीं दिया। आपके हस्ताक्षर क्रमक में निप॰-४४ अपरहस्यीकरण सक्रिय करें तथा पुनः प्रयास करें</string>
|
<string name="unauthorized_exception_description">हस्ताक्षरकर्ता ने इस कार्य करने के लिए अपरहस्यीकरण का अधिकार नहीं दिया। आपके हस्ताक्षर क्रमक में निप॰-४४ अपरहस्यीकरण सक्रिय करें तथा पुनः प्रयास करें</string>
|
||||||
<string name="signer_not_found_exception">हस्ताक्षरकर्ता अप्राप्त</string>
|
<string name="signer_not_found_exception">हस्ताक्षरकर्ता अप्राप्त</string>
|
||||||
<string name="signer_not_found_exception_description">क्या हस्ताक्षर क्रमक अस्थापित किया गया। जाँच करें कि यदि हस्ताक्षरकर्ता स्थापित है तथा यह लेखा वहाँ है। यदि हस्ताक्षर क्रमक में परिवर्तन है तो निर्गमनांकन तथा प्रवेशांकन पुनः करें।</string>
|
<string name="signer_not_found_exception_description">क्या हस्ताक्षर क्रमक अस्थापित किया गया। जाँच करें कि यदि हस्ताक्षरकर्ता स्थापित है तथा यह लेखा वहाँ है। यदि हस्ताक्षर क्रमक में परिवर्तन है तो निर्गमनांकन तथा प्रवेशांकन पुनः करें।</string>
|
||||||
|
<string name="signer_illegal_state_exception">हस्ताक्षरकर्ता द्वारा दुर्व्यवहार</string>
|
||||||
|
<string name="signer_illegal_state_exception_description">बाहरी हस्ताक्षरकर्ता द्वारा दिया गया उत्तर विचित्र है अनुरोध के लिए। अमेथिस्ट अथवा हस्ताक्षरकर्ता में कुछ दोष हो सकता है।</string>
|
||||||
<string name="zaps">ज्साप</string>
|
<string name="zaps">ज्साप</string>
|
||||||
<string name="view_count">दृष्ट संख्या</string>
|
<string name="view_count">दृष्ट संख्या</string>
|
||||||
<string name="boost">उद्धृत करें</string>
|
<string name="boost">उद्धृत करें</string>
|
||||||
@@ -290,6 +292,7 @@
|
|||||||
<string name="nip_05">नोस्ट्र पता</string>
|
<string name="nip_05">नोस्ट्र पता</string>
|
||||||
<string name="never">कभी नहीं</string>
|
<string name="never">कभी नहीं</string>
|
||||||
<string name="now">अभी</string>
|
<string name="now">अभी</string>
|
||||||
|
<string name="seconds">सेकण्ड</string>
|
||||||
<string name="h"> घं॰</string>
|
<string name="h"> घं॰</string>
|
||||||
<string name="m"> मि॰</string>
|
<string name="m"> मि॰</string>
|
||||||
<string name="d"> दि॰</string>
|
<string name="d"> दि॰</string>
|
||||||
@@ -378,12 +381,16 @@
|
|||||||
<string name="bookmarks_title">मूलविकल्प स्मर्त्तव्य सूची</string>
|
<string name="bookmarks_title">मूलविकल्प स्मर्त्तव्य सूची</string>
|
||||||
<string name="bookmarks_explainer">आपका मूलविकल्प स्मर्त्तव्य सूची जिसका अवलम्बन अनेक ग्राहक करते हैं</string>
|
<string name="bookmarks_explainer">आपका मूलविकल्प स्मर्त्तव्य सूची जिसका अवलम्बन अनेक ग्राहक करते हैं</string>
|
||||||
<string name="drafts">पाण्डुलिपियाँ</string>
|
<string name="drafts">पाण्डुलिपियाँ</string>
|
||||||
|
<string name="polls">मतदान</string>
|
||||||
<string name="private_bookmarks">निजी स्मर्तव्य</string>
|
<string name="private_bookmarks">निजी स्मर्तव्य</string>
|
||||||
<string name="public_bookmarks">सार्वजनिक स्मर्तव्य</string>
|
<string name="public_bookmarks">सार्वजनिक स्मर्तव्य</string>
|
||||||
<string name="add_to_private_bookmarks">निजी स्मर्तव्य सूची में जोडें</string>
|
<string name="add_to_private_bookmarks">निजी स्मर्तव्य सूची में जोडें</string>
|
||||||
<string name="add_to_public_bookmarks">सार्वजनिक स्मर्तव्य सूची में जोडें</string>
|
<string name="add_to_public_bookmarks">सार्वजनिक स्मर्तव्य सूची में जोडें</string>
|
||||||
<string name="remove_from_private_bookmarks">निजी स्मर्तव्य सूची से हटाएँ</string>
|
<string name="remove_from_private_bookmarks">निजी स्मर्तव्य सूची से हटाएँ</string>
|
||||||
<string name="remove_from_public_bookmarks">सार्वजनिक स्मर्तव्य सूची से हटाएँ</string>
|
<string name="remove_from_public_bookmarks">सार्वजनिक स्मर्तव्य सूची से हटाएँ</string>
|
||||||
|
<string name="pinned_notes">टँगे गए पत्र</string>
|
||||||
|
<string name="pin_to_profile">परिचय के साथ जोडें</string>
|
||||||
|
<string name="unpin_from_profile">परिचय से निकालें</string>
|
||||||
<string name="bookmark_lists">स्मर्त्तव्य सूची</string>
|
<string name="bookmark_lists">स्मर्त्तव्य सूची</string>
|
||||||
<string name="bookmark_list_icon_label">स्मर्त्तव्य सूची चिह्न</string>
|
<string name="bookmark_list_icon_label">स्मर्त्तव्य सूची चिह्न</string>
|
||||||
<string name="bookmark_list_creation_screen_title">नव्य स्मर्त्तव्य सूची</string>
|
<string name="bookmark_list_creation_screen_title">नव्य स्मर्त्तव्य सूची</string>
|
||||||
@@ -447,6 +454,8 @@
|
|||||||
<string name="poll_zap_value_max">ज्साप अधिकतम</string>
|
<string name="poll_zap_value_max">ज्साप अधिकतम</string>
|
||||||
<string name="poll_consensus_threshold">सर्वसम्मति</string>
|
<string name="poll_consensus_threshold">सर्वसम्मति</string>
|
||||||
<string name="poll_consensus_threshold_percent">(० से १००) प्रतिशत</string>
|
<string name="poll_consensus_threshold_percent">(० से १००) प्रतिशत</string>
|
||||||
|
<string name="poll_single_choice">एक विकल्प</string>
|
||||||
|
<string name="poll_multiple_choice">अनेक विकल्प</string>
|
||||||
<string name="poll_closing_date_time">मतदान समापन दिनांक तथा समय</string>
|
<string name="poll_closing_date_time">मतदान समापन दिनांक तथा समय</string>
|
||||||
<string name="poll_closing_in">मतदान %1$s में समाप्त होगा</string>
|
<string name="poll_closing_in">मतदान %1$s में समाप्त होगा</string>
|
||||||
<string name="poll_closing_time">के पश्चात समाप्त करें</string>
|
<string name="poll_closing_time">के पश्चात समाप्त करें</string>
|
||||||
@@ -486,6 +495,9 @@
|
|||||||
<string name="zap_type_anonymous_explainer">ग्राहक तथा जनता को पता नहीं किसने भुगतान भेजा</string>
|
<string name="zap_type_anonymous_explainer">ग्राहक तथा जनता को पता नहीं किसने भुगतान भेजा</string>
|
||||||
<string name="zap_type_nonzap">ज्साप अतिरिक्त</string>
|
<string name="zap_type_nonzap">ज्साप अतिरिक्त</string>
|
||||||
<string name="zap_type_nonzap_explainer">नोस्ट्र में कोई पदचिह्न नहीं, केवल लैटनिंग पर</string>
|
<string name="zap_type_nonzap_explainer">नोस्ट्र में कोई पदचिह्न नहीं, केवल लैटनिंग पर</string>
|
||||||
|
<string name="post_anonymously">नामरहित</string>
|
||||||
|
<string name="post_anonymously_explainer">पत्र प्रकाशित करें एक नए क्षेपणार्थ लेखाचिह्न के साथ। आपका लेखा इस उत्तर के साथ नहीं जोडा जाएगा।</string>
|
||||||
|
<string name="anonymous_reply_warning">यह उत्तर भेजा जाएगा एक नए नामरहित लेखा से</string>
|
||||||
<string name="file_server">अभिलेख सेवासंगणक</string>
|
<string name="file_server">अभिलेख सेवासंगणक</string>
|
||||||
<string name="file_server_description">इस अभिलेख का आरोहण करने के लिए सेवासंगणक का चयन करें</string>
|
<string name="file_server_description">इस अभिलेख का आरोहण करने के लिए सेवासंगणक का चयन करें</string>
|
||||||
<string name="zap_forward_lnAddress">लै॰जाल पता अथवा @उपयोगकर्ता</string>
|
<string name="zap_forward_lnAddress">लै॰जाल पता अथवा @उपयोगकर्ता</string>
|
||||||
@@ -711,6 +723,19 @@
|
|||||||
<string name="terms_and_conditions">नियम तथा अवस्था</string>
|
<string name="terms_and_conditions">नियम तथा अवस्था</string>
|
||||||
<string name="not_available_acronym">अनुपलब्ध</string>
|
<string name="not_available_acronym">अनुपलब्ध</string>
|
||||||
<string name="relay_error_messages">अपक्रम तथा सूचनाएँ इस पुनःप्रसारक से</string>
|
<string name="relay_error_messages">अपक्रम तथा सूचनाएँ इस पुनःप्रसारक से</string>
|
||||||
|
<string name="relay_monitor_reports">पुनःप्रसारक परिवीक्षक सूचनाएँ</string>
|
||||||
|
<string name="relay_monitor_rtt_open">खोलें</string>
|
||||||
|
<string name="relay_monitor_rtt_read">पढें</string>
|
||||||
|
<string name="relay_monitor_rtt_write">लिखें</string>
|
||||||
|
<string name="relay_monitor_rtt">आरटीटी॰</string>
|
||||||
|
<string name="relay_monitor_network">जाल</string>
|
||||||
|
<string name="relay_monitor_relay_type">प्रकार</string>
|
||||||
|
<string name="relay_monitor_supported_nips">अवलम्बन किए हुए निप॰ सूची</string>
|
||||||
|
<string name="relay_monitor_requirements">आवश्यकताएँ</string>
|
||||||
|
<string name="relay_monitor_last_check">अन्तिम जाँच</string>
|
||||||
|
<string name="relay_monitor_ms">%1$d सहस्रतमसेकण्ड</string>
|
||||||
|
<string name="relay_discovery_accepted_kinds">स्वीकृत प्रकार</string>
|
||||||
|
<string name="relay_discovery_geohash">स्थान</string>
|
||||||
<string name="relay_active_subscriptions">सक्रिय ग्राहकताएँ</string>
|
<string name="relay_active_subscriptions">सक्रिय ग्राहकताएँ</string>
|
||||||
<string name="relay_active_outbox">अपूर्ण निर्गतपेटिका घटनाएँ</string>
|
<string name="relay_active_outbox">अपूर्ण निर्गतपेटिका घटनाएँ</string>
|
||||||
<string name="relay_req_subscriptions">अनुरोध ग्राहकताएँ (%1$d)</string>
|
<string name="relay_req_subscriptions">अनुरोध ग्राहकताएँ (%1$d)</string>
|
||||||
@@ -1104,6 +1129,13 @@
|
|||||||
<string name="new_community_note">नया सामुदायिक टीका</string>
|
<string name="new_community_note">नया सामुदायिक टीका</string>
|
||||||
<string name="new_product">नया उत्पाद</string>
|
<string name="new_product">नया उत्पाद</string>
|
||||||
<string name="new_exclusive_geo_note">नया स्थान विशेष पत्र</string>
|
<string name="new_exclusive_geo_note">नया स्थान विशेष पत्र</string>
|
||||||
|
<string name="new_long_form_post">नया लेख</string>
|
||||||
|
<string name="article_title">शीर्षक</string>
|
||||||
|
<string name="article_summary">साराम्श (वैकल्पिक)</string>
|
||||||
|
<string name="article_cover_image_url">बाहरी चित्र जालपता (वैकल्पिक)</string>
|
||||||
|
<string name="write_your_article_in_markdown">आपका लेख मार्कडौन में लिखें…</string>
|
||||||
|
<string name="markdown_preview">पूर्वावलोकन</string>
|
||||||
|
<string name="markdown_edit">संपादित करें</string>
|
||||||
<string name="open_all_reactions_to_this_post">इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को खोलें</string>
|
<string name="open_all_reactions_to_this_post">इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को खोलें</string>
|
||||||
<string name="close_all_reactions_to_this_post">इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को अवरोधित करें</string>
|
<string name="close_all_reactions_to_this_post">इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को अवरोधित करें</string>
|
||||||
<string name="reply_description">उत्तर</string>
|
<string name="reply_description">उत्तर</string>
|
||||||
@@ -1238,9 +1270,14 @@
|
|||||||
<string name="git_repository">गिट क्रमलेखकोश : %1$s</string>
|
<string name="git_repository">गिट क्रमलेखकोश : %1$s</string>
|
||||||
<string name="git_web_address">जाल :</string>
|
<string name="git_web_address">जाल :</string>
|
||||||
<string name="git_clone_address">अनुकृति :</string>
|
<string name="git_clone_address">अनुकृति :</string>
|
||||||
|
<string name="nsite_title">स्थिर जालस्थान : %1$s</string>
|
||||||
|
<string name="nsite_root_site">मूल स्थान</string>
|
||||||
|
<string name="nsite_source">स्रोत :</string>
|
||||||
|
<string name="nsite_servers">सेवासंगणक :</string>
|
||||||
<string name="existed_since">ओ॰टी॰एस : %1$s</string>
|
<string name="existed_since">ओ॰टी॰एस : %1$s</string>
|
||||||
<string name="ots_info_title">समयांकन प्रमाण</string>
|
<string name="ots_info_title">समयांकन प्रमाण</string>
|
||||||
<string name="ots_info_description">प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया द्व्यंकरूप्य खण्डश्रृंखला में उस समय उस दिन पर।</string>
|
<string name="ots_info_description">प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया द्व्यंकरूप्य खण्डश्रृंखला में उस समय उस दिन पर।</string>
|
||||||
|
<string name="edit_article">लेख सम्पादित करें</string>
|
||||||
<string name="edit_post">पत्र का सम्पादन करें</string>
|
<string name="edit_post">पत्र का सम्पादन करें</string>
|
||||||
<string name="proposal_to_edit">पत्र शोधन के लिए प्रस्ताव</string>
|
<string name="proposal_to_edit">पत्र शोधन के लिए प्रस्ताव</string>
|
||||||
<string name="message_to_author">परिवर्तनों का साराम्श</string>
|
<string name="message_to_author">परिवर्तनों का साराम्श</string>
|
||||||
@@ -1320,6 +1357,7 @@
|
|||||||
<string name="feed_group_hashtags">विषयसूचक</string>
|
<string name="feed_group_hashtags">विषयसूचक</string>
|
||||||
<string name="feed_group_communities">समुदाय</string>
|
<string name="feed_group_communities">समुदाय</string>
|
||||||
<string name="feed_group_lists">सूचियाँ</string>
|
<string name="feed_group_lists">सूचियाँ</string>
|
||||||
|
<string name="feed_group_relays">पुनःप्रसारक</string>
|
||||||
<string name="temporary_account">यन्त्र ताला लगने पर निर्गमनांकन करें</string>
|
<string name="temporary_account">यन्त्र ताला लगने पर निर्गमनांकन करें</string>
|
||||||
<string name="private_message">निजी सन्देश</string>
|
<string name="private_message">निजी सन्देश</string>
|
||||||
<string name="public_message">सार्वजनिक संदेश</string>
|
<string name="public_message">सार्वजनिक संदेश</string>
|
||||||
@@ -1556,6 +1594,7 @@
|
|||||||
<string name="kind_shorts">छोटे</string>
|
<string name="kind_shorts">छोटे</string>
|
||||||
<string name="kind_voice_msg">ध्वनि सन्देश</string>
|
<string name="kind_voice_msg">ध्वनि सन्देश</string>
|
||||||
<string name="kind_voice_reply">ध्वनि उत्तर</string>
|
<string name="kind_voice_reply">ध्वनि उत्तर</string>
|
||||||
|
<string name="kind_web_bookmark">जाल स्मर्त्तव्यचिह्न</string>
|
||||||
<string name="kind_wiki">विकि॰</string>
|
<string name="kind_wiki">विकि॰</string>
|
||||||
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">एक बढिया सूचनावली के साथ आरम्भ करें उन लोगों का अनुचरण करके जिनको आपके द्वारा विश्वास प्राप्त कोई व्यक्ति करते हैं।</string>
|
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">एक बढिया सूचनावली के साथ आरम्भ करें उन लोगों का अनुचरण करके जिनको आपके द्वारा विश्वास प्राप्त कोई व्यक्ति करते हैं।</string>
|
||||||
<string name="import_follow_list">अनुचरित सूची आयात</string>
|
<string name="import_follow_list">अनुचरित सूची आयात</string>
|
||||||
@@ -1584,6 +1623,21 @@
|
|||||||
<string name="select_all">सभी चुनें</string>
|
<string name="select_all">सभी चुनें</string>
|
||||||
<string name="uptime">%1$d%% समय निरन्तर उपलब्ध</string>
|
<string name="uptime">%1$d%% समय निरन्तर उपलब्ध</string>
|
||||||
<string name="namecoin_settings">नामरूप्य स्थापना विकल्प</string>
|
<string name="namecoin_settings">नामरूप्य स्थापना विकल्प</string>
|
||||||
|
<string name="namecoin_test_connection">संयोजन परीक्षण</string>
|
||||||
|
<string name="namecoin_testing">सेवासंगणकों का परीक्षण…</string>
|
||||||
|
<string name="namecoin_test_success">संयोजित</string>
|
||||||
|
<string name="namecoin_test_failed">असफल</string>
|
||||||
|
<string name="namecoin_test_results">परीक्षा परिणाम</string>
|
||||||
|
<string name="namecoin_diagnostics">निदानार्थ लक्षण</string>
|
||||||
|
<string name="namecoin_last_test">अन्तिम परीक्षा</string>
|
||||||
|
<string name="namecoin_device_info">यन्त्र जानकारी</string>
|
||||||
|
<string name="namecoin_tls_info">टीएलएस॰ जानकारी</string>
|
||||||
|
<string name="namecoin_no_test_yet">अभी कोई परीक्षा चरण हुआ नहीं</string>
|
||||||
|
<string name="namecoin_response_time">%d सहस्रतमसेकण्ड</string>
|
||||||
|
<string name="namecoin_pin_cert_title">क्या सेवासंगणक प्रमाणपत्र पर विश्वास करें।</string>
|
||||||
|
<string name="namecoin_pin_cert_body">सेवासंगणक %1$s ने एक प्रमाणपत्र प्रस्तुत किया जो आपके विश्वास भण्डार में अभी नहीं है। सत्यापित करें कि नीचे के हस्ताक्षर वही है जो सेवासंगणक के परिचालक ने प्रकाशित किया है। तत्पश्चात चयन करें कि भविष्य में संयोजनों के लिए विश्वास करना है अथवा नहीं।</string>
|
||||||
|
<string name="namecoin_pin_cert_accept">विश्वास करें</string>
|
||||||
|
<string name="namecoin_pin_cert_reject">अस्वीकार करें</string>
|
||||||
<string name="event_sync_title">पुनःप्रसारक समचरणीकरण</string>
|
<string name="event_sync_title">पुनःप्रसारक समचरणीकरण</string>
|
||||||
<string name="event_sync_section">पुनःप्रसारक समचरणीकरण</string>
|
<string name="event_sync_section">पुनःप्रसारक समचरणीकरण</string>
|
||||||
<string name="event_sync_section_explainer">अपने घटनाओं को पुनःप्रकाशित करें सभी ज्ञात पुनःप्रसारकों तक अपने निर्गतपेटिका आगतपेटिका तथा सीधासन्देश पुनःप्रसारकों का अद्यतन करने के लिए। वैफै॰ आवश्यक। यह सम्भाव्यतः बहुत जानकारी भेज सकता है।</string>
|
<string name="event_sync_section_explainer">अपने घटनाओं को पुनःप्रकाशित करें सभी ज्ञात पुनःप्रसारकों तक अपने निर्गतपेटिका आगतपेटिका तथा सीधासन्देश पुनःप्रसारकों का अद्यतन करने के लिए। वैफै॰ आवश्यक। यह सम्भाव्यतः बहुत जानकारी भेज सकता है।</string>
|
||||||
@@ -1622,8 +1676,12 @@
|
|||||||
<string name="event_sync_no_events">कोई घटना नहीं</string>
|
<string name="event_sync_no_events">कोई घटना नहीं</string>
|
||||||
<string name="ots_explorer_settings">द्व्यंकरूप्य समन्वेषक (ओटीएस॰)</string>
|
<string name="ots_explorer_settings">द्व्यंकरूप्य समन्वेषक (ओटीएस॰)</string>
|
||||||
<string name="events">घटनाएँ</string>
|
<string name="events">घटनाएँ</string>
|
||||||
|
<string name="events_from_you">घटनाएँ आप से</string>
|
||||||
|
<string name="events_to_you">घटनाएँ आप को</string>
|
||||||
|
<string name="searchable_events">खोज साध्य घटनाएँ</string>
|
||||||
<string name="dms">सीधेसन्देश</string>
|
<string name="dms">सीधेसन्देश</string>
|
||||||
<string name="profiles">परिचय</string>
|
<string name="profiles">परिचय</string>
|
||||||
|
<string name="relay_settings_lower2">निर्गतपेटिका सूचियाँ</string>
|
||||||
<string name="relay_settings_lower">पुनःप्रसारक स्थापना विकल्प</string>
|
<string name="relay_settings_lower">पुनःप्रसारक स्थापना विकल्प</string>
|
||||||
<string name="last_seen">पूर्व दृष्ट %1$s पहले</string>
|
<string name="last_seen">पूर्व दृष्ट %1$s पहले</string>
|
||||||
<string name="event_sync_less_than_until"><%1$s</string>
|
<string name="event_sync_less_than_until"><%1$s</string>
|
||||||
@@ -1664,6 +1722,135 @@
|
|||||||
<string name="attestor_proficiency_for_kinds">प्रकारों के सत्यापन में निपुण : %1$s</string>
|
<string name="attestor_proficiency_for_kinds">प्रकारों के सत्यापन में निपुण : %1$s</string>
|
||||||
<string name="attestation_attests_to">इस के लिए साक्षी</string>
|
<string name="attestation_attests_to">इस के लिए साक्षी</string>
|
||||||
<string name="attestation_requests_attestation_to">इस के लिए साक्ष्यांकन अनुरोध</string>
|
<string name="attestation_requests_attestation_to">इस के लिए साक्ष्यांकन अनुरोध</string>
|
||||||
|
<string name="event_sync_date_filter_title">दिनांक सीमा</string>
|
||||||
|
<string name="event_sync_date_filter_since">से</string>
|
||||||
|
<string name="event_sync_date_filter_until">तक</string>
|
||||||
|
<string name="event_sync_date_filter_now">अभी</string>
|
||||||
|
<string name="event_sync_date_filter_all_time">समस्त काल</string>
|
||||||
|
<string name="event_sync_date_filter_last_sync">अन्तिम समचरणीकरण : %1$s</string>
|
||||||
|
<string name="event_sync_date_filter_since_last_sync">अन्तिम समचरणीकरण से</string>
|
||||||
|
<string name="web_bookmarks">जाल स्मर्त्तव्यचिह्न</string>
|
||||||
|
<string name="web_bookmarks_empty">अभी कोई जाल स्मर्त्तव्यचिह्न नहीं। जोडने के लिए + दबाएँ।</string>
|
||||||
|
<string name="web_bookmark_add_title">जाल स्मर्त्तव्यचिह्न जोडें</string>
|
||||||
|
<string name="web_bookmark_edit_title">जाल स्मर्त्तव्यचिह्न सम्पादित करें</string>
|
||||||
|
<string name="web_bookmark_url_label">जालपता</string>
|
||||||
|
<string name="web_bookmark_url_placeholder">https://example.com</string>
|
||||||
|
<string name="web_bookmark_title_label">शीर्षक</string>
|
||||||
|
<string name="web_bookmark_title_placeholder">स्मर्त्तव्यचिह्न शीर्षक</string>
|
||||||
|
<string name="web_bookmark_description_label">विवरण</string>
|
||||||
|
<string name="web_bookmark_description_placeholder">एक छोटा विवरण</string>
|
||||||
|
<string name="web_bookmark_tags_label">विषयसूचक (अल्पविराम विभाजित)</string>
|
||||||
|
<string name="web_bookmark_tags_placeholder">नोस्टर, तन्त्रज्ञान, जाललेख</string>
|
||||||
|
<string name="web_bookmark_save">अभिलेखन करें</string>
|
||||||
|
<string name="web_bookmark_delete">मिटाएँ</string>
|
||||||
|
<string name="web_bookmark_delete_confirm">क्या इस जाल स्मर्त्तव्यचिह्न को मिटाएँ।</string>
|
||||||
|
<string name="web_bookmark_open_url">जालपता खोलें</string>
|
||||||
<!-- NIP-75 Zap Goals -->
|
<!-- NIP-75 Zap Goals -->
|
||||||
|
<string name="goal_closed">यह उद्देश्य समाप्त हो चुका है</string>
|
||||||
|
<string name="goal_progress">%1$s वित्तपोषित %2$s साट्स उद्देश्य में से</string>
|
||||||
|
<string name="goal_amount_label">उद्देश्य संख्या (साट्स)</string>
|
||||||
|
<string name="goal_amount_placeholder">१०००००</string>
|
||||||
|
<string name="goal_description_label">आपके उद्देश्य का विवरण करें</string>
|
||||||
|
<string name="goal_description_placeholder">आप किस लिए धनसंग्रह कर रहे हैं।</string>
|
||||||
|
<string name="goal_summary_label">छोटा साराम्श</string>
|
||||||
|
<string name="goal_summary_placeholder">संक्षिप्त विवरण पूर्वावलोकनों में दिखाने के लिए</string>
|
||||||
|
<string name="goal_image_label">चित्र जालपता (वैकल्पिक)</string>
|
||||||
|
<string name="goal_image_placeholder">https://example.com/image.jpg</string>
|
||||||
|
<string name="goal_website_label">जालस्थान पता (वैकल्पिक)</string>
|
||||||
|
<string name="goal_website_placeholder">https://example.com</string>
|
||||||
|
<string name="goal_deadline_label">समय सीमा (वैकल्पिक)</string>
|
||||||
|
<string name="goal_set_deadline">समय सीमा स्थापित करें</string>
|
||||||
|
<string name="new_goal">नया उद्देश्य</string>
|
||||||
|
<string name="goal_create">उद्देश्य बनाएँ</string>
|
||||||
|
<string name="request_to_vanish">अदृश्य होने के लिए अनुरोध</string>
|
||||||
|
<string name="request_to_vanish_description">पुनःप्रसारकों को अनुरोध करें कि आपकी सभी जानकारी मिटाएँ चयनित दिनांक तक। यह कार्य निप॰६२ पर आधारित है तथा कुछ अधिकारक्षेत्रों में न्यायिक रूप से बाध्यकारी है।</string>
|
||||||
|
<string name="vanish_select_relay">एक पुनःप्रसारक का चयन करें</string>
|
||||||
|
<string name="vanish_target_relay">लक्ष्य पुनःप्रसारक</string>
|
||||||
|
<string name="vanish_all_relays">सभी पुनःप्रसारक</string>
|
||||||
|
<string name="vanish_all_relays_warning">यह सभी पुनःप्रसारकों को अनुरोध करेगा कि आपकी कुंचिका से सम्बन्धित सब कुछ मिटा दें चयनित दिनांक तक। इस घटना का यथासम्भाव्य विस्तृत प्रसार किया जाएगा। इसको पूर्ववत नहीं किया जा सकता।</string>
|
||||||
|
<string name="vanish_date_label">जानकारी मिटाएँ इस तक</string>
|
||||||
|
<string name="vanish_date_explainer">इस दिनांक के पूर्व बनाए गए आपके सभी घटनाओं को मिटाने का अनुरोध किया जाएगा चयनित पुनःप्रसारक से।</string>
|
||||||
|
<string name="vanish_reason_label">कारण (विकल्पात्मक)</string>
|
||||||
|
<string name="vanish_reason_placeholder">कारण अथवा न्यायिक संदेश पुनःप्रसारक के परिचालक के लिए</string>
|
||||||
|
<string name="vanish_send_request">अदृश्यीकरण अनुरोध भेजें</string>
|
||||||
|
<string name="vanish_confirm_title">अदृश्यीकरण अनुरोध की पुष्टि करें</string>
|
||||||
|
<string name="vanish_confirm_single_relay">आप %1$s को अनुरोध करनेवाले हैं कि स्थायी रूप से चयनित दिनांक के पूर्व के आपकी सभी जानकारी मिटा दें। इसे पूर्ववत नहीं किया जा सकता।</string>
|
||||||
|
<string name="vanish_confirm_all_relays">आप ॰सभी॰ पुनःप्रसारकों को अनुरोध करनेवाले हैं कि स्थायी रूप से चयनित दिनांक के पूर्व के आपकी सभी जानकारी मिटा दें। इसे सर्वत्र प्रसारित किया जाएगा तथा पूर्ववत नहीं किया जा सकता।</string>
|
||||||
|
<string name="vanish_request_sent">अदृश्यीकरण अनुरोध भेजा गया</string>
|
||||||
|
<string name="vanish_select_date">दिनांक का चयन करें</string>
|
||||||
|
<string name="vanish_select_time">समय का चयन करें</string>
|
||||||
|
<string name="vanish_events_title">अदृश्यीकरण इतिहास</string>
|
||||||
|
<string name="vanish_events_refresh">नवीकरण</string>
|
||||||
|
<string name="vanish_events_description">ये आपके पूर्वकाल के अदृश्यीकरण अनुरोध घटनाएँ है जो संयोजित पुनःप्रसारकों में से प्राप्त हुए। इन घटनाओं में सूचित पुनःप्रसारकों में घटना दिनांक के पूर्व की आपकी कोई जानकारी नहीं रखनी चाहिए।</string>
|
||||||
|
<string name="vanish_events_empty">कोई अदृश्यीकरण अनुरोध प्राप्त नहीं</string>
|
||||||
|
<string name="vanish_events_empty_hint">आपने कोई अदृश्यीकरण अनुरोध अभी तक भेजे नहीं।</string>
|
||||||
|
<string name="vanish_target_relays_label">लक्ष्य पुनःप्रसारक</string>
|
||||||
|
<string name="vanish_all_relays_compliance_hint">यह अनुरोध सभी पुनःप्रसारकों को लक्ष्यांकित करता है। अदृश्यीकरण अनुरोध पटल का उपयोग करें विशिष्ट पुनःप्रसारकों की अनुपालन परीक्षा करने के लिए।</string>
|
||||||
|
<string name="vanish_test_button">परीक्षण</string>
|
||||||
|
<string name="vanish_compliant">अनुपालक</string>
|
||||||
|
<string name="vanish_non_compliant">अनुपालक नहीं</string>
|
||||||
|
<string name="vanish_test_error">अपक्रम</string>
|
||||||
|
<string name="vanish_history">अदृश्यीकरण इतिहास</string>
|
||||||
<!-- NIP-86 Relay Management -->
|
<!-- NIP-86 Relay Management -->
|
||||||
|
<string name="relay_management_title">प्रबन्धन %1$s</string>
|
||||||
|
<string name="relay_management_loading">पुनःप्रसारक प्रबन्धन क्षमताओं का आवहन…</string>
|
||||||
|
<string name="relay_management_error">पुनःप्रसारक प्रबन्धक के साथ जुडने में अक्षम</string>
|
||||||
|
<string name="relay_management_no_methods">कोई प्रबन्धक विधि उपलब्ध नहीं</string>
|
||||||
|
<string name="relay_management_dismiss">हटाएँ</string>
|
||||||
|
<string name="relay_management_tab_pubkeys">प्रयोक्ता सूची</string>
|
||||||
|
<string name="relay_management_tab_events">घटनाएँ</string>
|
||||||
|
<string name="relay_management_tab_kinds">प्रकार</string>
|
||||||
|
<string name="relay_management_tab_ips">अंकीय जालपताएँ</string>
|
||||||
|
<string name="relay_management_tab_settings">स्थापना विकल्प</string>
|
||||||
|
<string name="relay_management_banned_pubkeys">प्रतिबन्धित प्रयोक्ता</string>
|
||||||
|
<string name="relay_management_no_banned_pubkeys">कोई प्रतिबन्धित प्रयोक्ता नहीं</string>
|
||||||
|
<string name="relay_management_allowed_pubkeys">अनुमत प्रयोक्ता</string>
|
||||||
|
<string name="relay_management_no_allowed_pubkeys">कोई अनुमत प्रयोक्ता नहीं</string>
|
||||||
|
<string name="relay_management_moderation_queue">नियमन पंक्ति</string>
|
||||||
|
<string name="relay_management_no_moderation_events">नियमनार्थ कोई घटनाएँ नहीं</string>
|
||||||
|
<string name="relay_management_banned_events">प्रतिबन्धित घटनाएँ</string>
|
||||||
|
<string name="relay_management_no_banned_events">कोई प्रतिबन्धित घटनाएँ नहीं</string>
|
||||||
|
<string name="relay_management_allowed_kinds">अनुमत प्रकार</string>
|
||||||
|
<string name="relay_management_no_allowed_kinds">कोई अनुमत प्रकार नहीं</string>
|
||||||
|
<string name="relay_management_blocked_ips">अवरोधित अंकीय जालपताएँ</string>
|
||||||
|
<string name="relay_management_no_blocked_ips">कोई अवरोधित अंकीय जालपताएँ नहीं</string>
|
||||||
|
<string name="relay_management_add">जोडें</string>
|
||||||
|
<string name="relay_management_remove">हटाएँ</string>
|
||||||
|
<string name="relay_management_allow">अनुमत</string>
|
||||||
|
<string name="relay_management_ban">प्रतिबन्ध</string>
|
||||||
|
<string name="relay_management_ban_pubkey">ख्याप्य कुंचिका प्रतिबन्धित करें</string>
|
||||||
|
<string name="relay_management_allow_pubkey">ख्याप्य कुंचिका को अनुमति दें</string>
|
||||||
|
<string name="relay_management_ban_event">घटना प्रतिबन्धित करें</string>
|
||||||
|
<string name="relay_management_allow_kind">प्रकार को अनुमति दें</string>
|
||||||
|
<string name="relay_management_block_ip">अंकीय जालपता अवरोधित करें</string>
|
||||||
|
<string name="relay_management_pubkey_hex">ख्याप्य कुंचिका (षोडशांक)</string>
|
||||||
|
<string name="relay_management_event_id_hex">घटना विभेदक (षोडशांक)</string>
|
||||||
|
<string name="relay_management_kind_number">प्रकार संख्या</string>
|
||||||
|
<string name="relay_management_ip_address">अंकीय जालपता</string>
|
||||||
|
<string name="relay_management_reason_optional">कारण (विकल्पात्मक)</string>
|
||||||
|
<string name="relay_management_confirm">पुष्टि करें</string>
|
||||||
|
<string name="relay_management_cancel">निरस्त करें</string>
|
||||||
|
<string name="relay_management_apply">लागू करें</string>
|
||||||
|
<string name="relay_management_relay_name">पुनःप्रसारक नाम</string>
|
||||||
|
<string name="relay_management_relay_description">पुनःप्रसारक विवरण</string>
|
||||||
|
<string name="relay_management_relay_icon_url">पनःप्रसारक चिह्न जालपता</string>
|
||||||
|
<string name="relay_management_button">पुनःप्रसारक प्रबन्धन</string>
|
||||||
|
<string name="manage">प्रबन्धन</string>
|
||||||
|
<string name="relay_members">सदस्य सूची</string>
|
||||||
|
<string name="relay_members_title">%1$s के सदस्य</string>
|
||||||
|
<string name="relay_members_count">%1$d सदस्य</string>
|
||||||
|
<string name="relay_members_loading">सदस्य आवहन…</string>
|
||||||
|
<string name="relay_members_empty">कोई सदस्य प्राप्त नहीं</string>
|
||||||
|
<string name="relay_members_request_join">जुडने का अनुरोध</string>
|
||||||
|
<string name="relay_members_request_leave">चले जाने का अनुरोध</string>
|
||||||
|
<string name="relay_members_join_sent">जुडने का अनुरोध भेजा गया</string>
|
||||||
|
<string name="relay_members_leave_sent">चले जाने का अनुरोध भेजा गया</string>
|
||||||
|
<string name="relay_members_you_are_member">आप एक सदस्य हैं</string>
|
||||||
|
<string name="relay_membership_list">पुनःप्रसारक सदस्यता सूची</string>
|
||||||
|
<string name="relay_member_added">सदस्य जोडा गया पुनःप्रसारक के साथ</string>
|
||||||
|
<string name="relay_members_added">%1$d सदस्य जोडे गये पुनःप्रसारक के साथ</string>
|
||||||
|
<string name="relay_member_removed">सदस्य हटाया गया पुनःप्रसारक से</string>
|
||||||
|
<string name="relay_members_removed">%1$d सदस्य हटाए गए पुनःप्रसारक से</string>
|
||||||
|
<string name="relay_join_request">पुनःप्रसारक से जुडने का अनुरोध</string>
|
||||||
|
<string name="relay_leave_request">पुनःप्रसारक से चले जाने का अनुरोध</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1270,6 +1270,10 @@
|
|||||||
<string name="git_repository">Git tároló: %1$s</string>
|
<string name="git_repository">Git tároló: %1$s</string>
|
||||||
<string name="git_web_address">Weboldal:</string>
|
<string name="git_web_address">Weboldal:</string>
|
||||||
<string name="git_clone_address">Klónozás:</string>
|
<string name="git_clone_address">Klónozás:</string>
|
||||||
|
<string name="nsite_title">Statikus weboldal: %1$s</string>
|
||||||
|
<string name="nsite_root_site">Főoldal</string>
|
||||||
|
<string name="nsite_source">Forrás:</string>
|
||||||
|
<string name="nsite_servers">Kiszolgálók:</string>
|
||||||
<string name="existed_since">OTS: %1$s</string>
|
<string name="existed_since">OTS: %1$s</string>
|
||||||
<string name="ots_info_title">Időbélyeg-igazolás</string>
|
<string name="ots_info_title">Időbélyeg-igazolás</string>
|
||||||
<string name="ots_info_description">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.</string>
|
<string name="ots_info_description">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.</string>
|
||||||
@@ -1832,4 +1836,21 @@
|
|||||||
<string name="relay_management_relay_icon_url">Átjátszó ikonjának webcíme</string>
|
<string name="relay_management_relay_icon_url">Átjátszó ikonjának webcíme</string>
|
||||||
<string name="relay_management_button">Átjátszó kezelése</string>
|
<string name="relay_management_button">Átjátszó kezelése</string>
|
||||||
<string name="manage">Kezelés</string>
|
<string name="manage">Kezelés</string>
|
||||||
|
<string name="relay_members">Tagok</string>
|
||||||
|
<string name="relay_members_title">A(z) %1$s tagjai</string>
|
||||||
|
<string name="relay_members_count">%1$d tag</string>
|
||||||
|
<string name="relay_members_loading">Tagok betöltése…</string>
|
||||||
|
<string name="relay_members_empty">Nem találhatók tagok</string>
|
||||||
|
<string name="relay_members_request_join">Csatlakozási kérés</string>
|
||||||
|
<string name="relay_members_request_leave">Elhagyási kérés</string>
|
||||||
|
<string name="relay_members_join_sent">Csatlakozási kérés elküldve</string>
|
||||||
|
<string name="relay_members_leave_sent">Elhagyási kérés elküldve</string>
|
||||||
|
<string name="relay_members_you_are_member">Ön tagja az átjátszónak</string>
|
||||||
|
<string name="relay_membership_list">Átjátszó tagsági listája</string>
|
||||||
|
<string name="relay_member_added">A tag hozzá lett adva az átjátszóhoz</string>
|
||||||
|
<string name="relay_members_added">%1$d tag hozzáadva az átjátszóhoz</string>
|
||||||
|
<string name="relay_member_removed">A tag el lett távolítva az átjátszóról</string>
|
||||||
|
<string name="relay_members_removed">%1$d tag el lett távolítva az átjátszóról</string>
|
||||||
|
<string name="relay_join_request">Átjátszóhoz való csatlakozás kérése</string>
|
||||||
|
<string name="relay_leave_request">Átjátszó elhagyásának kérése</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -730,6 +730,8 @@
|
|||||||
<string name="relay_monitor_requirements">Requisitos</string>
|
<string name="relay_monitor_requirements">Requisitos</string>
|
||||||
<string name="relay_monitor_last_check">Última verificação</string>
|
<string name="relay_monitor_last_check">Última verificação</string>
|
||||||
<string name="relay_monitor_ms">%1$d ms</string>
|
<string name="relay_monitor_ms">%1$d ms</string>
|
||||||
|
<string name="relay_discovery_accepted_kinds">Tipos Aceitos</string>
|
||||||
|
<string name="relay_discovery_geohash">Localização</string>
|
||||||
<string name="relay_active_subscriptions">Assinaturas Ativas</string>
|
<string name="relay_active_subscriptions">Assinaturas Ativas</string>
|
||||||
<string name="relay_active_outbox">Eventos Pendentes na Outbox</string>
|
<string name="relay_active_outbox">Eventos Pendentes na Outbox</string>
|
||||||
<string name="relay_req_subscriptions">Assinaturas REQ (%1$d)</string>
|
<string name="relay_req_subscriptions">Assinaturas REQ (%1$d)</string>
|
||||||
@@ -1263,6 +1265,10 @@
|
|||||||
<string name="git_repository">Repositório Git: %1$s</string>
|
<string name="git_repository">Repositório Git: %1$s</string>
|
||||||
<string name="git_web_address">Site:</string>
|
<string name="git_web_address">Site:</string>
|
||||||
<string name="git_clone_address">Clonar:</string>
|
<string name="git_clone_address">Clonar:</string>
|
||||||
|
<string name="nsite_title">Site Estático: %1$s</string>
|
||||||
|
<string name="nsite_root_site">Site Raiz</string>
|
||||||
|
<string name="nsite_source">Fonte:</string>
|
||||||
|
<string name="nsite_servers">Servidores:</string>
|
||||||
<string name="existed_since">OTS: %1$s</string>
|
<string name="existed_since">OTS: %1$s</string>
|
||||||
<string name="ots_info_title">Prova de Carimbo de data/hora</string>
|
<string name="ots_info_title">Prova de Carimbo de data/hora</string>
|
||||||
<string name="ots_info_description">Há prova de que esta postagem foi assinada antes de %1$s. A prova foi carimbada no blockchain do Bitcoin naquela data e hora.</string>
|
<string name="ots_info_description">Há prova de que esta postagem foi assinada antes de %1$s. A prova foi carimbada no blockchain do Bitcoin naquela data e hora.</string>
|
||||||
@@ -1825,4 +1831,21 @@
|
|||||||
<string name="relay_management_relay_icon_url">URL do Ícone do Relay</string>
|
<string name="relay_management_relay_icon_url">URL do Ícone do Relay</string>
|
||||||
<string name="relay_management_button">Gerenciar Relay</string>
|
<string name="relay_management_button">Gerenciar Relay</string>
|
||||||
<string name="manage">Gerenciar</string>
|
<string name="manage">Gerenciar</string>
|
||||||
|
<string name="relay_members">Membros</string>
|
||||||
|
<string name="relay_members_title">Membros de %1$s</string>
|
||||||
|
<string name="relay_members_count">%1$d membros</string>
|
||||||
|
<string name="relay_members_loading">Carregando membros…</string>
|
||||||
|
<string name="relay_members_empty">Nenhum membro encontrado</string>
|
||||||
|
<string name="relay_members_request_join">Solicitar Entrada</string>
|
||||||
|
<string name="relay_members_request_leave">Solicitar Saída</string>
|
||||||
|
<string name="relay_members_join_sent">Solicitação de entrada enviada</string>
|
||||||
|
<string name="relay_members_leave_sent">Solicitação de saída enviada</string>
|
||||||
|
<string name="relay_members_you_are_member">Você é membro</string>
|
||||||
|
<string name="relay_membership_list">Lista de membros do relay</string>
|
||||||
|
<string name="relay_member_added">Membro adicionado ao relay</string>
|
||||||
|
<string name="relay_members_added">%1$d membros adicionados ao relay</string>
|
||||||
|
<string name="relay_member_removed">Membro removido do relay</string>
|
||||||
|
<string name="relay_members_removed">%1$d membros removidos do relay</string>
|
||||||
|
<string name="relay_join_request">Solicitação de entrada no relay</string>
|
||||||
|
<string name="relay_leave_request">Solicitação de saída do relay</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -729,6 +729,8 @@
|
|||||||
<string name="relay_monitor_requirements">Krav</string>
|
<string name="relay_monitor_requirements">Krav</string>
|
||||||
<string name="relay_monitor_last_check">Senaste kontroll</string>
|
<string name="relay_monitor_last_check">Senaste kontroll</string>
|
||||||
<string name="relay_monitor_ms">%1$d ms</string>
|
<string name="relay_monitor_ms">%1$d ms</string>
|
||||||
|
<string name="relay_discovery_accepted_kinds">Accepterade typer</string>
|
||||||
|
<string name="relay_discovery_geohash">Plats</string>
|
||||||
<string name="relay_active_subscriptions">Aktiva prenumerationer</string>
|
<string name="relay_active_subscriptions">Aktiva prenumerationer</string>
|
||||||
<string name="relay_active_outbox">Väntande utgående händelser</string>
|
<string name="relay_active_outbox">Väntande utgående händelser</string>
|
||||||
<string name="relay_req_subscriptions">REQ-prenumerationer (%1$d)</string>
|
<string name="relay_req_subscriptions">REQ-prenumerationer (%1$d)</string>
|
||||||
@@ -1262,6 +1264,10 @@
|
|||||||
<string name="git_repository">Git Repository: %1$s</string>
|
<string name="git_repository">Git Repository: %1$s</string>
|
||||||
<string name="git_web_address">Webbplats:</string>
|
<string name="git_web_address">Webbplats:</string>
|
||||||
<string name="git_clone_address">Klona:</string>
|
<string name="git_clone_address">Klona:</string>
|
||||||
|
<string name="nsite_title">Statisk webbplats: %1$s</string>
|
||||||
|
<string name="nsite_root_site">Webbplats rot</string>
|
||||||
|
<string name="nsite_source">Källa:</string>
|
||||||
|
<string name="nsite_servers">Servrar:</string>
|
||||||
<string name="existed_since">OTS: %1$s</string>
|
<string name="existed_since">OTS: %1$s</string>
|
||||||
<string name="ots_info_title">Tidsstämpel Bevis</string>
|
<string name="ots_info_title">Tidsstämpel Bevis</string>
|
||||||
<string name="ots_info_description">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.</string>
|
<string name="ots_info_description">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.</string>
|
||||||
@@ -1824,4 +1830,21 @@
|
|||||||
<string name="relay_management_relay_icon_url">URL för reläikon</string>
|
<string name="relay_management_relay_icon_url">URL för reläikon</string>
|
||||||
<string name="relay_management_button">Hantera relä</string>
|
<string name="relay_management_button">Hantera relä</string>
|
||||||
<string name="manage">Hantera</string>
|
<string name="manage">Hantera</string>
|
||||||
|
<string name="relay_members">Medlemmar</string>
|
||||||
|
<string name="relay_members_title">Medlemmar i %1$s</string>
|
||||||
|
<string name="relay_members_count">%1$d medlemmar</string>
|
||||||
|
<string name="relay_members_loading">Laddar medlemmar…</string>
|
||||||
|
<string name="relay_members_empty">Inga medlemmar hittades</string>
|
||||||
|
<string name="relay_members_request_join">Begär att gå med</string>
|
||||||
|
<string name="relay_members_request_leave">Begär att lämna</string>
|
||||||
|
<string name="relay_members_join_sent">Begäran om att gå med skickad</string>
|
||||||
|
<string name="relay_members_leave_sent">Begäran om att lämna skickad</string>
|
||||||
|
<string name="relay_members_you_are_member">Du är medlem</string>
|
||||||
|
<string name="relay_membership_list">Relämedlemslista</string>
|
||||||
|
<string name="relay_member_added">Medlem tillagd i relä</string>
|
||||||
|
<string name="relay_members_added">%1$d medlemmar tillagda i relä</string>
|
||||||
|
<string name="relay_member_removed">Medlem borttagen från relä</string>
|
||||||
|
<string name="relay_members_removed">%1$d medlemmar borttagna från relä</string>
|
||||||
|
<string name="relay_join_request">Begäran om att gå med i relä</string>
|
||||||
|
<string name="relay_leave_request">Begäran om att lämna relä</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1270,6 +1270,10 @@
|
|||||||
<string name="git_repository">Git 仓库: %1$s</string>
|
<string name="git_repository">Git 仓库: %1$s</string>
|
||||||
<string name="git_web_address">网址</string>
|
<string name="git_web_address">网址</string>
|
||||||
<string name="git_clone_address">克隆:</string>
|
<string name="git_clone_address">克隆:</string>
|
||||||
|
<string name="nsite_title">静态网站: %1$s</string>
|
||||||
|
<string name="nsite_root_site">根站点</string>
|
||||||
|
<string name="nsite_source">源:</string>
|
||||||
|
<string name="nsite_servers">服务器:</string>
|
||||||
<string name="existed_since">OTS:%1$s</string>
|
<string name="existed_since">OTS:%1$s</string>
|
||||||
<string name="ots_info_title">OpenTimestamps 证明</string>
|
<string name="ots_info_title">OpenTimestamps 证明</string>
|
||||||
<string name="ots_info_description">%1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。</string>
|
<string name="ots_info_description">%1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。</string>
|
||||||
@@ -1832,4 +1836,21 @@
|
|||||||
<string name="relay_management_relay_icon_url">中继图标 URL</string>
|
<string name="relay_management_relay_icon_url">中继图标 URL</string>
|
||||||
<string name="relay_management_button">管理中继</string>
|
<string name="relay_management_button">管理中继</string>
|
||||||
<string name="manage">管理</string>
|
<string name="manage">管理</string>
|
||||||
|
<string name="relay_members">成员</string>
|
||||||
|
<string name="relay_members_title">%1$s 的成员</string>
|
||||||
|
<string name="relay_members_count">%1$d 位成员</string>
|
||||||
|
<string name="relay_members_loading">加载成员…</string>
|
||||||
|
<string name="relay_members_empty">未找到成员</string>
|
||||||
|
<string name="relay_members_request_join">申请加入</string>
|
||||||
|
<string name="relay_members_request_leave">请求退出</string>
|
||||||
|
<string name="relay_members_join_sent">加入请求已发送</string>
|
||||||
|
<string name="relay_members_leave_sent">已发送离开请求</string>
|
||||||
|
<string name="relay_members_you_are_member">您是成员</string>
|
||||||
|
<string name="relay_membership_list">中继成员列表</string>
|
||||||
|
<string name="relay_member_added">已添加成员到中继</string>
|
||||||
|
<string name="relay_members_added">添加了 %1$d 位成员到中继</string>
|
||||||
|
<string name="relay_member_removed">从中继删除了成员</string>
|
||||||
|
<string name="relay_members_removed">从中继删除了 %1$d 位成员</string>
|
||||||
|
<string name="relay_join_request">中继加入请求</string>
|
||||||
|
<string name="relay_leave_request">中继离开请求</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+40
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
+74
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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.client.single.newSubId
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
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 com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
||||||
@@ -66,6 +69,7 @@ class FeedMetadataCoordinator(
|
|||||||
// Track what we've already queued to avoid duplicates
|
// Track what we've already queued to avoid duplicates
|
||||||
private val queuedPubkeys = mutableSetOf<HexKey>()
|
private val queuedPubkeys = mutableSetOf<HexKey>()
|
||||||
private val queuedNoteIds = mutableSetOf<HexKey>()
|
private val queuedNoteIds = mutableSetOf<HexKey>()
|
||||||
|
private val queuedBoostedIds = mutableSetOf<HexKey>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start processing the subscription queue.
|
* Start processing the subscription queue.
|
||||||
@@ -110,6 +114,37 @@ class FeedMetadataCoordinator(
|
|||||||
fun loadMetadataForNotes(notes: List<Note>) {
|
fun loadMetadataForNotes(notes: List<Note>) {
|
||||||
if (notes.isEmpty()) return
|
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
|
// Extract unique authors that we haven't already queued
|
||||||
val authors =
|
val authors =
|
||||||
notes
|
notes
|
||||||
|
|||||||
Vendored
+60
-3
@@ -36,8 +36,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
|||||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
|
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
|
||||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
|
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
|
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
|
||||||
@@ -148,6 +150,13 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
val newUserMetadata = event.contactMetaData()
|
val newUserMetadata = event.contactMetaData()
|
||||||
if (newUserMetadata != null) {
|
if (newUserMetadata != null) {
|
||||||
user.updateUserInfo(newUserMetadata, event)
|
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) {
|
when (event) {
|
||||||
is MetadataEvent -> {
|
is MetadataEvent -> {
|
||||||
consumeMetadata(event)
|
consumeMetadata(event)
|
||||||
true
|
false // metadata updates User, not Note — skip event stream
|
||||||
}
|
}
|
||||||
|
|
||||||
is TextNoteEvent -> {
|
is TextNoteEvent -> {
|
||||||
@@ -188,6 +197,10 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
consumeRepost(event, relay)
|
consumeRepost(event, relay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is GenericRepostEvent -> {
|
||||||
|
consumeGenericRepost(event, relay)
|
||||||
|
}
|
||||||
|
|
||||||
is ContactListEvent -> {
|
is ContactListEvent -> {
|
||||||
consumeContactList(event)
|
consumeContactList(event)
|
||||||
}
|
}
|
||||||
@@ -200,6 +213,10 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
consumeBookmarkList(event)
|
consumeBookmarkList(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is CommentEvent -> {
|
||||||
|
consumeComment(event, relay)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -216,7 +233,25 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
val note = getOrCreateNote(event.id)
|
val note = getOrCreateNote(event.id)
|
||||||
if (note.event != null) return false
|
if (note.event != null) return false
|
||||||
val author = getOrCreateUser(event.pubKey)
|
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)
|
note.loadEvent(event, author, repliesTo)
|
||||||
relay?.let { note.addRelay(it) }
|
relay?.let { note.addRelay(it) }
|
||||||
repliesTo.forEach { it.addReply(note) }
|
repliesTo.forEach { it.addReply(note) }
|
||||||
@@ -299,6 +334,8 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
/**
|
/**
|
||||||
* Consumes a kind 6 repost event.
|
* Consumes a kind 6 repost event.
|
||||||
* Links repost to target note via e-tag.
|
* 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(
|
private fun consumeRepost(
|
||||||
event: RepostEvent,
|
event: RepostEvent,
|
||||||
@@ -307,7 +344,27 @@ class DesktopLocalCache : ICacheProvider {
|
|||||||
val note = getOrCreateNote(event.id)
|
val note = getOrCreateNote(event.id)
|
||||||
if (note.event != null) return false
|
if (note.event != null) return false
|
||||||
val author = getOrCreateUser(event.pubKey)
|
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)
|
val repliesTo = listOfNotNull(boostedNote)
|
||||||
note.loadEvent(event, author, repliesTo)
|
note.loadEvent(event, author, repliesTo)
|
||||||
relay?.let { note.addRelay(it) }
|
relay?.let { note.addRelay(it) }
|
||||||
|
|||||||
+38
-17
@@ -28,12 +28,29 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
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.nip23LongContent.LongTextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
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<Note>.deduplicateReposts(): List<Note> =
|
||||||
|
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(
|
class DesktopGlobalFeedFilter(
|
||||||
private val cache: DesktopLocalCache,
|
private val cache: DesktopLocalCache,
|
||||||
@@ -42,19 +59,20 @@ class DesktopGlobalFeedFilter(
|
|||||||
|
|
||||||
override fun feed(): List<Note> =
|
override fun feed(): List<Note> =
|
||||||
cache.notes
|
cache.notes
|
||||||
.filterIntoSet { _, note -> note.event is TextNoteEvent }
|
.filterIntoSet { _, note -> isFeedNote(note.event) }
|
||||||
.sortedWith(DefaultFeedOrder)
|
.sortedWith(DefaultFeedOrder)
|
||||||
|
.deduplicateReposts()
|
||||||
.take(limit())
|
.take(limit())
|
||||||
|
|
||||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { it.event is TextNoteEvent }
|
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { isFeedNote(it.event) }
|
||||||
|
|
||||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
|
||||||
|
|
||||||
override fun limit(): Int = 2500
|
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(
|
class DesktopFollowingFeedFilter(
|
||||||
private val cache: DesktopLocalCache,
|
private val cache: DesktopLocalCache,
|
||||||
@@ -66,19 +84,20 @@ class DesktopFollowingFeedFilter(
|
|||||||
val follows = followedPubkeys()
|
val follows = followedPubkeys()
|
||||||
return cache.notes
|
return cache.notes
|
||||||
.filterIntoSet { _, note ->
|
.filterIntoSet { _, note ->
|
||||||
note.event is TextNoteEvent && note.author?.pubkeyHex in follows
|
isFeedNote(note.event) && note.author?.pubkeyHex in follows
|
||||||
}.sortedWith(DefaultFeedOrder)
|
}.sortedWith(DefaultFeedOrder)
|
||||||
|
.deduplicateReposts()
|
||||||
.take(limit())
|
.take(limit())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun applyFilter(newItems: Set<Note>): Set<Note> {
|
override fun applyFilter(newItems: Set<Note>): Set<Note> {
|
||||||
val follows = followedPubkeys()
|
val follows = followedPubkeys()
|
||||||
return newItems.filterTo(HashSet()) {
|
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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
|
||||||
|
|
||||||
override fun limit(): Int = 2500
|
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(
|
class DesktopProfileFeedFilter(
|
||||||
private val pubkey: HexKey,
|
private val pubkey: HexKey,
|
||||||
@@ -124,19 +143,21 @@ class DesktopProfileFeedFilter(
|
|||||||
) : AdditiveFeedFilter<Note>() {
|
) : AdditiveFeedFilter<Note>() {
|
||||||
override fun feedKey(): String = "profile-$pubkey"
|
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<Note> =
|
override fun feed(): List<Note> =
|
||||||
cache.notes
|
cache.notes
|
||||||
.filterIntoSet { _, note ->
|
.filterIntoSet { _, note -> isProfileNote(note) }
|
||||||
note.event is TextNoteEvent && note.author?.pubkeyHex == pubkey
|
.sortedWith(DefaultFeedOrder)
|
||||||
}.sortedWith(DefaultFeedOrder)
|
.deduplicateReposts()
|
||||||
.take(limit())
|
.take(limit())
|
||||||
|
|
||||||
override fun applyFilter(newItems: Set<Note>): Set<Note> =
|
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { isProfileNote(it) }
|
||||||
newItems.filterTo(HashSet()) {
|
|
||||||
it.event is TextNoteEvent && it.author?.pubkeyHex == pubkey
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
|
||||||
|
|
||||||
override fun limit(): Int = 1000
|
override fun limit(): Int = 1000
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-7
@@ -29,7 +29,6 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
|||||||
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
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.INostrClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
@@ -91,11 +90,9 @@ class DesktopRelaySubscriptionsCoordinator(
|
|||||||
scope = scope,
|
scope = scope,
|
||||||
indexRelays = indexRelays,
|
indexRelays = indexRelays,
|
||||||
preloader = preloader,
|
preloader = preloader,
|
||||||
onEvent = { event, _ ->
|
onEvent = { event, relay ->
|
||||||
// Consume metadata events into local cache
|
// Route all fetched events (metadata, boosted notes, etc.) into cache
|
||||||
if (event is MetadataEvent) {
|
localCache.consume(event, relay)
|
||||||
localCache.consumeMetadata(event)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -221,7 +218,7 @@ class DesktopRelaySubscriptionsCoordinator(
|
|||||||
filters =
|
filters =
|
||||||
indexRelays.associateWith {
|
indexRelays.associateWith {
|
||||||
listOf(
|
listOf(
|
||||||
com.vitorpamplona.quartz.nip01Core.relay.filters.Filter(
|
Filter(
|
||||||
kinds = listOf(com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent.KIND),
|
kinds = listOf(com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent.KIND),
|
||||||
authors = listOf(pubkey),
|
authors = listOf(pubkey),
|
||||||
limit = 1,
|
limit = 1,
|
||||||
|
|||||||
+15
-3
@@ -37,7 +37,7 @@ enum class FeedMode {
|
|||||||
*/
|
*/
|
||||||
fun createGlobalFeedSubscription(
|
fun createGlobalFeedSubscription(
|
||||||
relays: Set<NormalizedRelayUrl>,
|
relays: Set<NormalizedRelayUrl>,
|
||||||
limit: Int = 50,
|
limit: Int = 200,
|
||||||
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||||
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||||
): SubscriptionConfig =
|
): SubscriptionConfig =
|
||||||
@@ -55,7 +55,7 @@ fun createGlobalFeedSubscription(
|
|||||||
fun createFollowingFeedSubscription(
|
fun createFollowingFeedSubscription(
|
||||||
relays: Set<NormalizedRelayUrl>,
|
relays: Set<NormalizedRelayUrl>,
|
||||||
followedUsers: List<String>,
|
followedUsers: List<String>,
|
||||||
limit: Int = 50,
|
limit: Int = 200,
|
||||||
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||||
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||||
): SubscriptionConfig? {
|
): SubscriptionConfig? {
|
||||||
@@ -121,9 +121,21 @@ fun createThreadRepliesSubscription(
|
|||||||
subId = generateSubId("thread-${noteId.take(8)}"),
|
subId = generateSubId("thread-${noteId.take(8)}"),
|
||||||
filters =
|
filters =
|
||||||
listOf(
|
listOf(
|
||||||
|
// Kind 1 replies via lowercase e-tag (NIP-10)
|
||||||
FilterBuilders.byETags(
|
FilterBuilders.byETags(
|
||||||
eventIds = listOf(noteId),
|
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,
|
limit = limit,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+4
-2
@@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
|||||||
* Provides convenience functions for creating relay subscription filters.
|
* Provides convenience functions for creating relay subscription filters.
|
||||||
*/
|
*/
|
||||||
object FilterBuilders {
|
object FilterBuilders {
|
||||||
|
private val FEED_KINDS = listOf(1, 6, 16) // TextNoteEvent, RepostEvent, GenericRepostEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a filter for text notes (kind 1) from all authors.
|
* Creates a filter for text notes (kind 1) from all authors.
|
||||||
*
|
*
|
||||||
@@ -41,7 +43,7 @@ object FilterBuilders {
|
|||||||
until: Long? = null,
|
until: Long? = null,
|
||||||
): Filter =
|
): Filter =
|
||||||
Filter(
|
Filter(
|
||||||
kinds = listOf(1), // TextNoteEvent.KIND
|
kinds = FEED_KINDS,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
since = since,
|
since = since,
|
||||||
until = until,
|
until = until,
|
||||||
@@ -63,7 +65,7 @@ object FilterBuilders {
|
|||||||
until: Long? = null,
|
until: Long? = null,
|
||||||
): Filter =
|
): Filter =
|
||||||
Filter(
|
Filter(
|
||||||
kinds = listOf(1), // TextNoteEvent.KIND
|
kinds = FEED_KINDS,
|
||||||
authors = authors,
|
authors = authors,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
since = since,
|
since = since,
|
||||||
|
|||||||
+242
-41
@@ -55,9 +55,13 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
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.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
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.commons.ui.feeds.FeedState
|
||||||
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
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.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
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.createContactListSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
|
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.subscriptions.rememberSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||||
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
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(
|
data class LightboxState(
|
||||||
val urls: List<String>,
|
val urls: List<String>,
|
||||||
@@ -86,6 +98,8 @@ data class LightboxState(
|
|||||||
/**
|
/**
|
||||||
* Note card that reads counts from the Note model (cache-backed).
|
* Note card that reads counts from the Note model (cache-backed).
|
||||||
* Event is extracted from Note for signing operations in NoteActionsRow.
|
* 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
|
@Composable
|
||||||
fun FeedNoteCard(
|
fun FeedNoteCard(
|
||||||
@@ -102,53 +116,141 @@ fun FeedNoteCard(
|
|||||||
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
|
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val event = note.event ?: return
|
val event = note.event ?: return
|
||||||
|
val isRepost = event is RepostEvent || event is GenericRepostEvent
|
||||||
|
|
||||||
// Observe Note.flowSet for live count updates
|
if (isRepost) {
|
||||||
val flowSet = remember(note) { note.flow() }
|
val originalNote = note.replyTo?.lastOrNull()
|
||||||
val reactionsState by flowSet.reactions.stateFlow.collectAsState()
|
if (originalNote == null) {
|
||||||
val repliesState by flowSet.replies.stateFlow.collectAsState()
|
return
|
||||||
val zapsState by flowSet.zaps.stateFlow.collectAsState()
|
}
|
||||||
|
|
||||||
// Read counts from Note model (re-read on each stateFlow emission)
|
// Observe original note's flowSet — MUST happen before reading .event
|
||||||
val reactionCount = note.countReactions()
|
// so we recompose when the async fetch fills in the event
|
||||||
val replyCount = note.replies.size
|
val flowSet = remember(originalNote) { originalNote.flow() }
|
||||||
val repostCount = note.boosts.size
|
val metadataState by flowSet.metadata.stateFlow.collectAsState()
|
||||||
val zapAmount = note.zapsAmount
|
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(originalNote) {
|
||||||
DisposableEffect(note) {
|
onDispose { originalNote.clearFlow() }
|
||||||
onDispose { note.clearFlow() }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
// Now read event — recomposition will re-read this when metadata invalidates
|
||||||
NoteCard(
|
val originalEvent = originalNote.event
|
||||||
note = event.toNoteDisplayData(localCache),
|
if (originalEvent == null) {
|
||||||
localCache = localCache,
|
return
|
||||||
onClick = { onNavigateToThread(event.id) },
|
}
|
||||||
onAuthorClick = onNavigateToProfile,
|
|
||||||
onMentionClick = onNavigateToProfile,
|
|
||||||
onImageClick = onImageClick,
|
|
||||||
onMediaClick = onMediaClick,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Action buttons (only if logged in)
|
val reactionCount = originalNote.countReactions()
|
||||||
if (account != null) {
|
val replyCount = originalNote.replies.size
|
||||||
NoteActionsRow(
|
val repostCount = originalNote.boosts.size
|
||||||
event = event,
|
val zapAmount = originalNote.zapsAmount
|
||||||
relayManager = relayManager,
|
|
||||||
localCache = localCache,
|
val reposterUser = localCache.getUserIfExists(event.pubKey)
|
||||||
account = account,
|
val originalUser = localCache.getUserIfExists(originalEvent.pubKey)
|
||||||
nwcConnection = nwcConnection,
|
|
||||||
onReplyClick = onReply,
|
Column {
|
||||||
onZapFeedback = onZapFeedback,
|
// Repost header: overlapping avatars + "Boosted" label
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
zapCount = note.zaps.size,
|
) {
|
||||||
zapAmountSats = zapAmount.toLong(),
|
GenericRepostLayout(
|
||||||
zapReceipts = emptyList(), // TODO: extract ZapReceipts from Note.zaps
|
baseAuthorPicture = {
|
||||||
reactionCount = reactionCount,
|
UserAvatar(
|
||||||
replyCount = replyCount,
|
userHex = originalEvent.pubKey,
|
||||||
repostCount = repostCount,
|
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()
|
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) {
|
LaunchedEffect(feedState, subscriptionsCoordinator) {
|
||||||
if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) {
|
if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) {
|
||||||
val notes = viewModel.feedState.visibleNotes()
|
val notes = viewModel.feedState.visibleNotes()
|
||||||
if (notes.isNotEmpty()) {
|
if (notes.isNotEmpty()) {
|
||||||
subscriptionsCoordinator.loadMetadataForNotes(notes)
|
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<String>()
|
||||||
|
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<String>()
|
||||||
|
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)
|
// Request interaction subscriptions — keyed on feedMode (stable), not feedState (changes every 250ms)
|
||||||
DisposableEffect(feedMode, subscriptionsCoordinator) {
|
DisposableEffect(feedMode, subscriptionsCoordinator) {
|
||||||
val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {}
|
val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {}
|
||||||
|
|||||||
@@ -204,7 +204,8 @@ fun ReadsScreen(
|
|||||||
val events by eventState.items.collectAsState()
|
val events by eventState.items.collectAsState()
|
||||||
|
|
||||||
var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) }
|
var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) }
|
||||||
var followedUsers by remember { mutableStateOf<Set<String>>(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) }
|
var eoseReceivedCount by remember { mutableStateOf(0) }
|
||||||
val initialLoadComplete = eoseReceivedCount > 0
|
val initialLoadComplete = eoseReceivedCount > 0
|
||||||
|
|
||||||
@@ -272,7 +273,8 @@ fun ReadsScreen(
|
|||||||
createFollowingLongFormFeedSubscription(
|
createFollowingLongFormFeedSubscription(
|
||||||
relays = connectedRelays,
|
relays = connectedRelays,
|
||||||
followedUsers = followedUsers.toList(),
|
followedUsers = followedUsers.toList(),
|
||||||
onEvent = { event, _, _, _ ->
|
onEvent = { event, _, relay, _ ->
|
||||||
|
subscriptionsCoordinator?.consumeEvent(event, relay)
|
||||||
if (event is LongTextNoteEvent) {
|
if (event is LongTextNoteEvent) {
|
||||||
eventState.addItem(event)
|
eventState.addItem(event)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -121,6 +121,7 @@ fun UserProfileScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onCompose: () -> Unit = {},
|
onCompose: () -> Unit = {},
|
||||||
onNavigateToProfile: (String) -> Unit = {},
|
onNavigateToProfile: (String) -> Unit = {},
|
||||||
|
onNavigateToThread: (String) -> Unit = {},
|
||||||
onNavigateToArticle: (String) -> Unit = {},
|
onNavigateToArticle: (String) -> Unit = {},
|
||||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
@@ -796,6 +797,7 @@ fun UserProfileScreen(
|
|||||||
onReply = onCompose,
|
onReply = onCompose,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
onImageClick = { urls, index ->
|
onImageClick = { urls, index ->
|
||||||
lightboxState = LightboxState(urls, index)
|
lightboxState = LightboxState(urls, index)
|
||||||
},
|
},
|
||||||
|
|||||||
+3
@@ -296,6 +296,7 @@ internal fun RootContent(
|
|||||||
onBack = {},
|
onBack = {},
|
||||||
onCompose = onShowComposeDialog,
|
onCompose = onShowComposeDialog,
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
onNavigateToArticle = onNavigateToArticle,
|
onNavigateToArticle = onNavigateToArticle,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
)
|
)
|
||||||
@@ -325,6 +326,7 @@ internal fun RootContent(
|
|||||||
onBack = {},
|
onBack = {},
|
||||||
onCompose = onShowComposeDialog,
|
onCompose = onShowComposeDialog,
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -426,6 +428,7 @@ internal fun OverlayContent(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onCompose = onShowComposeDialog,
|
onCompose = onShowComposeDialog,
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
onNavigateToArticle = onNavigateToArticle,
|
onNavigateToArticle = onNavigateToArticle,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
)
|
)
|
||||||
|
|||||||
+180
-71
@@ -38,6 +38,9 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.DesktopVideoPlayer
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl
|
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.Nip19Parser
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
|
||||||
@@ -324,6 +328,7 @@ fun NoteCard(
|
|||||||
private data class ResolvedMention(
|
private data class ResolvedMention(
|
||||||
val displayText: String,
|
val displayText: String,
|
||||||
val pubKeyHex: String? = null,
|
val pubKeyHex: String? = null,
|
||||||
|
val noteIdHex: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -354,11 +359,11 @@ private fun resolveBech32(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is NNote -> {
|
is NNote -> {
|
||||||
ResolvedMention("note:${entity.hex.take(8)}...")
|
ResolvedMention(displayText = "note:${entity.hex.take(8)}...", noteIdHex = entity.hex)
|
||||||
}
|
}
|
||||||
|
|
||||||
is NEvent -> {
|
is NEvent -> {
|
||||||
ResolvedMention("note:${entity.hex.take(8)}...")
|
ResolvedMention(displayText = "note:${entity.hex.take(8)}...", noteIdHex = entity.hex)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
@@ -386,12 +391,37 @@ fun extractMentionedPubkeys(bech32s: Set<String>): List<String> =
|
|||||||
* URLs are underlined in primary color; bech32 mentions show as @displayName in primary color
|
* URLs are underlined in primary color; bech32 mentions show as @displayName in primary color
|
||||||
* and navigate to profile on click.
|
* 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<String>,
|
||||||
|
bech32s: Collection<String>,
|
||||||
|
): List<ContentSegment> {
|
||||||
|
val segments = mutableListOf<ContentSegment>()
|
||||||
|
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
|
@Composable
|
||||||
fun RichTextContent(
|
fun RichTextContent(
|
||||||
content: String,
|
content: String,
|
||||||
urls: Urls,
|
urls: Urls,
|
||||||
localCache: DesktopLocalCache? = null,
|
localCache: DesktopLocalCache? = null,
|
||||||
onMentionClick: ((String) -> Unit)? = null,
|
onMentionClick: ((String) -> Unit)? = null,
|
||||||
|
onNavigateToThread: ((String) -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val defaultColor = MaterialTheme.colorScheme.onSurface
|
val defaultColor = MaterialTheme.colorScheme.onSurface
|
||||||
@@ -404,80 +434,159 @@ fun RichTextContent(
|
|||||||
color = defaultColor,
|
color = defaultColor,
|
||||||
modifier = modifier,
|
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 {
|
} else {
|
||||||
data class Segment(
|
// Has quoted notes — render text + embedded note cards
|
||||||
val start: Int,
|
Column(modifier = modifier) {
|
||||||
val raw: String,
|
// Strip quoted note bech32 references from text
|
||||||
val isUrl: Boolean,
|
val strippedText =
|
||||||
)
|
remember(content, quotedBech32s) {
|
||||||
|
var text = content
|
||||||
val segments = mutableListOf<Segment>()
|
for (bech32 in quotedBech32s) {
|
||||||
for (url in urls.withScheme) {
|
text = text.replace(bech32, "").trim()
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
text
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add remaining text
|
if (strippedText.isNotBlank()) {
|
||||||
if (lastIndex < content.length) {
|
val inlineBech32s = urls.bech32s - quotedBech32s
|
||||||
append(content.substring(lastIndex))
|
val segments = remember(strippedText, urls, inlineBech32s) { buildSegments(strippedText, urls.withScheme, inlineBech32s) }
|
||||||
}
|
RichAnnotatedText(strippedText, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
// Render quoted notes as embedded cards
|
||||||
text = annotatedText,
|
for (noteId in quotedNoteIds) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Spacer(Modifier.height(8.dp))
|
||||||
color = defaultColor,
|
QuotedNoteEmbed(noteId, localCache, onMentionClick, onNavigateToThread)
|
||||||
modifier = modifier,
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ContentSegment>,
|
||||||
|
resolvedBech32s: Map<String, ResolvedMention>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+7
-7
@@ -37,7 +37,7 @@ class FilterBuildersTest {
|
|||||||
fun testTextNotesGlobal() {
|
fun testTextNotesGlobal() {
|
||||||
val filter = FilterBuilders.textNotesGlobal(limit = 50)
|
val filter = FilterBuilders.textNotesGlobal(limit = 50)
|
||||||
|
|
||||||
assertEquals(listOf(1), filter.kinds)
|
assertEquals(listOf(1, 6, 16), filter.kinds)
|
||||||
assertEquals(50, filter.limit)
|
assertEquals(50, filter.limit)
|
||||||
assertNull(filter.authors)
|
assertNull(filter.authors)
|
||||||
assertNull(filter.tags)
|
assertNull(filter.tags)
|
||||||
@@ -51,7 +51,7 @@ class FilterBuildersTest {
|
|||||||
val until = 1640995200L // 2022-01-01
|
val until = 1640995200L // 2022-01-01
|
||||||
val filter = FilterBuilders.textNotesGlobal(limit = 100, since = since, until = until)
|
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(100, filter.limit)
|
||||||
assertEquals(since, filter.since)
|
assertEquals(since, filter.since)
|
||||||
assertEquals(until, filter.until)
|
assertEquals(until, filter.until)
|
||||||
@@ -62,7 +62,7 @@ class FilterBuildersTest {
|
|||||||
val authors = listOf(testPubKey, testPubKey2)
|
val authors = listOf(testPubKey, testPubKey2)
|
||||||
val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 25)
|
val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 25)
|
||||||
|
|
||||||
assertEquals(listOf(1), filter.kinds)
|
assertEquals(listOf(1, 6, 16), filter.kinds)
|
||||||
assertEquals(authors, filter.authors)
|
assertEquals(authors, filter.authors)
|
||||||
assertEquals(25, filter.limit)
|
assertEquals(25, filter.limit)
|
||||||
assertNull(filter.tags)
|
assertNull(filter.tags)
|
||||||
@@ -74,7 +74,7 @@ class FilterBuildersTest {
|
|||||||
val since = 1609459200L
|
val since = 1609459200L
|
||||||
val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 10, since = since)
|
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(authors, filter.authors)
|
||||||
assertEquals(10, filter.limit)
|
assertEquals(10, filter.limit)
|
||||||
assertEquals(since, filter.since)
|
assertEquals(since, filter.since)
|
||||||
@@ -421,7 +421,7 @@ class FilterBuildersTest {
|
|||||||
val filter = FilterBuilders.textNotesGlobal(limit = 50)
|
val filter = FilterBuilders.textNotesGlobal(limit = 50)
|
||||||
|
|
||||||
assertTrue(!filter.isEmpty())
|
assertTrue(!filter.isEmpty())
|
||||||
assertEquals(listOf(1), filter.kinds)
|
assertEquals(listOf(1, 6, 16), filter.kinds)
|
||||||
assertEquals(50, filter.limit)
|
assertEquals(50, filter.limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +431,7 @@ class FilterBuildersTest {
|
|||||||
val filter = FilterBuilders.textNotesFromAuthors(followedUsers, limit = 50)
|
val filter = FilterBuilders.textNotesFromAuthors(followedUsers, limit = 50)
|
||||||
|
|
||||||
assertTrue(!filter.isEmpty())
|
assertTrue(!filter.isEmpty())
|
||||||
assertEquals(listOf(1), filter.kinds)
|
assertEquals(listOf(1, 6, 16), filter.kinds)
|
||||||
assertEquals(followedUsers, filter.authors)
|
assertEquals(followedUsers, filter.authors)
|
||||||
assertEquals(50, filter.limit)
|
assertEquals(50, filter.limit)
|
||||||
}
|
}
|
||||||
@@ -447,7 +447,7 @@ class FilterBuildersTest {
|
|||||||
assertTrue(!contactListFilter.isEmpty())
|
assertTrue(!contactListFilter.isEmpty())
|
||||||
|
|
||||||
assertEquals(listOf(0), metadataFilter.kinds)
|
assertEquals(listOf(0), metadataFilter.kinds)
|
||||||
assertEquals(listOf(1), postsFilter.kinds)
|
assertEquals(listOf(1, 6, 16), postsFilter.kinds)
|
||||||
assertEquals(listOf(3), contactListFilter.kinds)
|
assertEquals(listOf(3), contactListFilter.kinds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<List<Event>>` 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<Filter>?,
|
||||||
|
) {
|
||||||
|
val label = if (isLive) "LIVE" else "STORED"
|
||||||
|
println("[$label] ${event.pubKey.take(8)}...: ${event.content.take(80)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEose(relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
|
||||||
|
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", <filter>...]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Relay responds with:
|
||||||
|
["EVENT", "my-feed", <event>] → listener.onEvent(event, isLive=false)
|
||||||
|
["EVENT", "my-feed", <event>] → listener.onEvent(event, isLive=false)
|
||||||
|
["EOSE", "my-feed"] → listener.onEose(relay)
|
||||||
|
["EVENT", "my-feed", <event>] → 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<HexKey>?` | Match specific event IDs (64-char hex) |
|
||||||
|
| `authors` | `List<HexKey>?` | Match specific author pubkeys (64-char hex) |
|
||||||
|
| `kinds` | `List<Int>?` | Match event kinds (0=metadata, 1=text note, 3=contacts, etc.) |
|
||||||
|
| `tags` | `Map<String, List<String>>?` | 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
|
||||||
|
```
|
||||||
+212
@@ -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<Int>,
|
||||||
|
) : IRelayPolicy {
|
||||||
|
|
||||||
|
override fun onConnect(send: (Message) -> Unit) { }
|
||||||
|
|
||||||
|
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> =
|
||||||
|
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<String>()
|
||||||
|
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
|
||||||
|
```
|
||||||
+2
-1
@@ -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.CountCmd
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyStack
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines custom behavior for this relay.
|
* Defines custom behavior for this relay.
|
||||||
@@ -76,7 +77,7 @@ interface IRelayPolicy {
|
|||||||
*/
|
*/
|
||||||
fun canSendToSession(event: Event): Boolean = true
|
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<T : Command> {
|
sealed interface PolicyResult<T : Command> {
|
||||||
|
|||||||
+30
-3
@@ -41,7 +41,7 @@ class NostrServer(
|
|||||||
private val store: IEventStore,
|
private val store: IEventStore,
|
||||||
private val policyBuilder: () -> IRelayPolicy = { VerifyPolicy },
|
private val policyBuilder: () -> IRelayPolicy = { VerifyPolicy },
|
||||||
private val parentContext: CoroutineContext = SupervisorJob(),
|
private val parentContext: CoroutineContext = SupervisorJob(),
|
||||||
) {
|
) : AutoCloseable {
|
||||||
private val subStore = LiveEventStore(store)
|
private val subStore = LiveEventStore(store)
|
||||||
|
|
||||||
/** Scope for all subscriptions. */
|
/** 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.forEach { _, session -> session.cancelAllSubscriptions() }
|
||||||
connections.clear()
|
connections.clear()
|
||||||
scope.cancel()
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.store
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
|
||||||
interface IEventStore {
|
interface IEventStore : AutoCloseable {
|
||||||
fun insert(event: Event)
|
fun insert(event: Event)
|
||||||
|
|
||||||
interface ITransaction {
|
interface ITransaction {
|
||||||
@@ -56,5 +56,5 @@ interface IEventStore {
|
|||||||
|
|
||||||
fun deleteExpiredEvents()
|
fun deleteExpiredEvents()
|
||||||
|
|
||||||
fun close()
|
override fun close()
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -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()
|
||||||
+26
@@ -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)
|
||||||
+1
-1
@@ -41,7 +41,7 @@ class PlatformLinkTag(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun parse(tag: Tag): 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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
@@ -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)
|
||||||
+14
-14
@@ -142,7 +142,7 @@ class NostrServerAuthTest {
|
|||||||
assertEquals(1, collector.messages.size)
|
assertEquals(1, collector.messages.size)
|
||||||
assertTrue(collector.messages[0].contains("\"AUTH\""))
|
assertTrue(collector.messages[0].contains("\"AUTH\""))
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -167,7 +167,7 @@ class NostrServerAuthTest {
|
|||||||
assertTrue((session.policy as FullAuthPolicy).isAuthenticated())
|
assertTrue((session.policy as FullAuthPolicy).isAuthenticated())
|
||||||
assertTrue(session.policy.authenticatedUsers.contains(pubkey))
|
assertTrue(session.policy.authenticatedUsers.contains(pubkey))
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -188,7 +188,7 @@ class NostrServerAuthTest {
|
|||||||
assertTrue(okMessages[0].contains("challenge"))
|
assertTrue(okMessages[0].contains("challenge"))
|
||||||
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
|
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -213,7 +213,7 @@ class NostrServerAuthTest {
|
|||||||
assertTrue(okMessages[0].contains("relay url"))
|
assertTrue(okMessages[0].contains("relay url"))
|
||||||
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
|
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -242,7 +242,7 @@ class NostrServerAuthTest {
|
|||||||
assertTrue(okMessages[0].contains("created_at"))
|
assertTrue(okMessages[0].contains("created_at"))
|
||||||
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
|
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -281,7 +281,7 @@ class NostrServerAuthTest {
|
|||||||
assertTrue(okMessages[0].contains("could not parse message"))
|
assertTrue(okMessages[0].contains("could not parse message"))
|
||||||
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
|
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -315,7 +315,7 @@ class NostrServerAuthTest {
|
|||||||
assertTrue(authedPubkeys.contains(pubkey))
|
assertTrue(authedPubkeys.contains(pubkey))
|
||||||
assertTrue(authedPubkeys.contains(pubkey2))
|
assertTrue(authedPubkeys.contains(pubkey2))
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- NIP-42: requireAuth ---------------------------------------------------
|
// -- NIP-42: requireAuth ---------------------------------------------------
|
||||||
@@ -337,7 +337,7 @@ class NostrServerAuthTest {
|
|||||||
assertTrue(okMessages[0].contains("\"false\""))
|
assertTrue(okMessages[0].contains("\"false\""))
|
||||||
assertTrue(okMessages[0].contains("auth-required:"))
|
assertTrue(okMessages[0].contains("auth-required:"))
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -354,7 +354,7 @@ class NostrServerAuthTest {
|
|||||||
assertEquals(1, closedMessages.size)
|
assertEquals(1, closedMessages.size)
|
||||||
assertTrue(closedMessages[0].contains("auth-required:"))
|
assertTrue(closedMessages[0].contains("auth-required:"))
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -371,7 +371,7 @@ class NostrServerAuthTest {
|
|||||||
assertEquals(1, closedMessages.size)
|
assertEquals(1, closedMessages.size)
|
||||||
assertTrue(closedMessages[0].contains("auth-required:"))
|
assertTrue(closedMessages[0].contains("auth-required:"))
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -410,7 +410,7 @@ class NostrServerAuthTest {
|
|||||||
val eoseMessages = collector.rawMessagesContaining("EOSE")
|
val eoseMessages = collector.rawMessagesContaining("EOSE")
|
||||||
assertTrue(eoseMessages.isNotEmpty())
|
assertTrue(eoseMessages.isNotEmpty())
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -430,7 +430,7 @@ class NostrServerAuthTest {
|
|||||||
assertEquals(1, okMessages.size)
|
assertEquals(1, okMessages.size)
|
||||||
assertTrue(okMessages[0].contains("\"true\""))
|
assertTrue(okMessages[0].contains("\"true\""))
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Custom AuthPolicy tests -----------------------------------------------
|
// -- Custom AuthPolicy tests -----------------------------------------------
|
||||||
@@ -471,7 +471,7 @@ class NostrServerAuthTest {
|
|||||||
assertTrue(okMessages[1].contains("\"false\""))
|
assertTrue(okMessages[1].contains("\"false\""))
|
||||||
assertTrue(okMessages[1].contains("auth-required:"))
|
assertTrue(okMessages[1].contains("auth-required:"))
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -531,6 +531,6 @@ class NostrServerAuthTest {
|
|||||||
assertEquals(1, events.size)
|
assertEquals(1, events.size)
|
||||||
assertEquals(pubkey, events[0].event.pubKey)
|
assertEquals(pubkey, events[0].event.pubKey)
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -115,7 +115,7 @@ class NostrServerTest {
|
|||||||
val stored = store.query<Event>(Filter(ids = listOf(event.id)))
|
val stored = store.query<Event>(Filter(ids = listOf(event.id)))
|
||||||
assertEquals(1, stored.size)
|
assertEquals(1, stored.size)
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -138,7 +138,7 @@ class NostrServerTest {
|
|||||||
assertTrue(okMessages[0].contains("\"true\""))
|
assertTrue(okMessages[0].contains("\"true\""))
|
||||||
assertTrue(okMessages[1].contains("\"false\""))
|
assertTrue(okMessages[1].contains("\"false\""))
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- REQ command -----------------------------------------------------------
|
// -- REQ command -----------------------------------------------------------
|
||||||
@@ -172,7 +172,7 @@ class NostrServerTest {
|
|||||||
// Events should be newest first
|
// Events should be newest first
|
||||||
assertTrue(events[0].event.createdAt >= events[1].event.createdAt)
|
assertTrue(events[0].event.createdAt >= events[1].event.createdAt)
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -195,7 +195,7 @@ class NostrServerTest {
|
|||||||
val events = collector.parsedEventMessages().filterIsInstance<EventMessage>()
|
val events = collector.parsedEventMessages().filterIsInstance<EventMessage>()
|
||||||
assertEquals(3, events.size)
|
assertEquals(3, events.size)
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Live subscription -----------------------------------------------------
|
// -- Live subscription -----------------------------------------------------
|
||||||
@@ -226,7 +226,7 @@ class NostrServerTest {
|
|||||||
assertTrue(newMessages.isNotEmpty())
|
assertTrue(newMessages.isNotEmpty())
|
||||||
assertTrue(newMessages[0].contains("\"EVENT\""))
|
assertTrue(newMessages[0].contains("\"EVENT\""))
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -251,7 +251,7 @@ class NostrServerTest {
|
|||||||
|
|
||||||
assertEquals(countAfterEose, collector1.messages.size)
|
assertEquals(countAfterEose, collector1.messages.size)
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- CLOSE command ---------------------------------------------------------
|
// -- CLOSE command ---------------------------------------------------------
|
||||||
@@ -282,7 +282,7 @@ class NostrServerTest {
|
|||||||
|
|
||||||
assertEquals(countAfterClose, collector1.messages.size)
|
assertEquals(countAfterClose, collector1.messages.size)
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -318,7 +318,7 @@ class NostrServerTest {
|
|||||||
assertTrue(newMessages[0].contains("\"EVENT\""))
|
assertTrue(newMessages[0].contains("\"EVENT\""))
|
||||||
assertTrue(newMessages[0].contains(hexId(2)))
|
assertTrue(newMessages[0].contains(hexId(2)))
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- COUNT command (NIP-45) ------------------------------------------------
|
// -- COUNT command (NIP-45) ------------------------------------------------
|
||||||
@@ -344,7 +344,7 @@ class NostrServerTest {
|
|||||||
assertEquals(1, countMessages.size)
|
assertEquals(1, countMessages.size)
|
||||||
assertTrue(countMessages[0].contains("\"count\":2"))
|
assertTrue(countMessages[0].contains("\"count\":2"))
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Disconnect ------------------------------------------------------------
|
// -- Disconnect ------------------------------------------------------------
|
||||||
@@ -374,7 +374,7 @@ class NostrServerTest {
|
|||||||
assertEquals(countAfterDisconnect, collector1.messages.size)
|
assertEquals(countAfterDisconnect, collector1.messages.size)
|
||||||
assertEquals(2, collector2.messages.size)
|
assertEquals(2, collector2.messages.size)
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Invalid messages ------------------------------------------------------
|
// -- Invalid messages ------------------------------------------------------
|
||||||
@@ -392,6 +392,6 @@ class NostrServerTest {
|
|||||||
assertEquals(1, collector.messages.size)
|
assertEquals(1, collector.messages.size)
|
||||||
assertTrue(collector.messages[0].contains("NOTICE"))
|
assertTrue(collector.messages[0].contains("NOTICE"))
|
||||||
|
|
||||||
server.shutdown()
|
server.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
@@ -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/<bech32>", "nevent")
|
||||||
|
val result = PlatformLinkTag.parse(tag)
|
||||||
|
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals("web", result.platform)
|
||||||
|
assertEquals("https://example.com/a/<bech32>", result.uri)
|
||||||
|
assertEquals("nevent", result.entityType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesAndroidPlatformLink() {
|
||||||
|
val tag = arrayOf("android", "amethyst://note/<bech32>", "note")
|
||||||
|
val result = PlatformLinkTag.parse(tag)
|
||||||
|
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals("android", result.platform)
|
||||||
|
assertEquals("amethyst://note/<bech32>", result.uri)
|
||||||
|
assertEquals("note", result.entityType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesIosPlatformLink() {
|
||||||
|
val tag = arrayOf("ios", "damus://note/<bech32>", "naddr")
|
||||||
|
val result = PlatformLinkTag.parse(tag)
|
||||||
|
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals("ios", result.platform)
|
||||||
|
assertEquals("damus://note/<bech32>", 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/<bech32>", "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/<bech32>", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user