Merge branch 'main-upstream' into chess-enhancements-and-bug-fixes

This commit is contained in:
davotoula
2026-03-30 17:40:40 +02:00
34 changed files with 2045 additions and 192 deletions
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
open class DiscoverNIP89FeedFilter(
val account: Account,
val targetKind: Int = 5300,
) : AdditiveFeedFilter<Note>() {
val lastAnnounced = TimeUtils.oneYearAgo()
@@ -61,7 +62,7 @@ open class DiscoverNIP89FeedFilter(
override fun feed(): List<Note> {
val notes =
LocalCache.addressables.filterIntoSet(AppDefinitionEvent.KIND) { _, it ->
acceptDVM(it)
acceptApp(it)
}
return sort(notes)
@@ -75,29 +76,29 @@ open class DiscoverNIP89FeedFilter(
account.hiddenUsers.flow.value,
)
fun acceptDVM(note: Note): Boolean {
fun acceptApp(note: Note): Boolean {
val noteEvent = note.event
return if (noteEvent is AppDefinitionEvent) {
acceptDVM(noteEvent, note.relays)
acceptApp(noteEvent, note.relays)
} else {
false
}
}
fun acceptDVM(
open fun acceptApp(
noteEvent: AppDefinitionEvent,
relays: List<NormalizedRelayUrl>,
): Boolean {
val filterParams = buildFilterParams(account)
return noteEvent.appMetaData()?.subscription != true &&
filterParams.match(noteEvent, relays) &&
noteEvent.includeKind(5300) &&
noteEvent.createdAt > lastAnnounced // && params.match(noteEvent)
noteEvent.includeKind(targetKind) &&
noteEvent.createdAt > lastAnnounced
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> =
collection.filterTo(HashSet()) {
acceptDVM(it)
acceptApp(it)
}
override fun sort(items: Set<Note>): List<Note> {
@@ -730,6 +730,8 @@
<string name="relay_monitor_requirements">Požadavky</string>
<string name="relay_monitor_last_check">Poslední kontrola</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_outbox">Čekající události k odeslání</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_web_address">Internet:</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="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>
@@ -1825,4 +1831,21 @@
<string name="relay_management_relay_icon_url">URL ikony relé</string>
<string name="relay_management_button">Spravovat relé</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>
@@ -735,6 +735,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="relay_monitor_requirements">Anforderungen</string>
<string name="relay_monitor_last_check">Letzte Prüfung</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_outbox">Ausstehende Outbox-Ereignisse</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_web_address">Internet:</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="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>
@@ -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_button">Relay 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>
@@ -61,6 +61,8 @@
<string name="unauthorized_exception_description">हस्ताक्षरकर्ता ने इस कार्य करने के लिए अपरहस्यीकरण का अधिकार नहीं दिया। आपके हस्ताक्षर क्रमक में निप॰-४४ अपरहस्यीकरण सक्रिय करें तथा पुनः प्रयास करें</string>
<string name="signer_not_found_exception">हस्ताक्षरकर्ता अप्राप्त</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="view_count">दृष्ट संख्या</string>
<string name="boost">उद्धृत करें</string>
@@ -290,6 +292,7 @@
<string name="nip_05">नोस्ट्र पता</string>
<string name="never">कभी नहीं</string>
<string name="now">अभी</string>
<string name="seconds">सेकण्ड</string>
<string name="h"> घं॰</string>
<string name="m"> मि॰</string>
<string name="d"> दि॰</string>
@@ -378,12 +381,16 @@
<string name="bookmarks_title">मूलविकल्प स्मर्त्तव्य सूची</string>
<string name="bookmarks_explainer">आपका मूलविकल्प स्मर्त्तव्य सूची जिसका अवलम्बन अनेक ग्राहक करते हैं</string>
<string name="drafts">पाण्डुलिपियाँ</string>
<string name="polls">मतदान</string>
<string name="private_bookmarks">निजी स्मर्तव्य</string>
<string name="public_bookmarks">सार्वजनिक स्मर्तव्य</string>
<string name="add_to_private_bookmarks">निजी स्मर्तव्य सूची में जोडें</string>
<string name="add_to_public_bookmarks">सार्वजनिक स्मर्तव्य सूची में जोडें</string>
<string name="remove_from_private_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_list_icon_label">स्मर्त्तव्य सूची चिह्न</string>
<string name="bookmark_list_creation_screen_title">नव्य स्मर्त्तव्य सूची</string>
@@ -447,6 +454,8 @@
<string name="poll_zap_value_max">ज्साप अधिकतम</string>
<string name="poll_consensus_threshold">सर्वसम्मति</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_in">मतदान %1$s में समाप्त होगा</string>
<string name="poll_closing_time">के पश्चात समाप्त करें</string>
@@ -486,6 +495,9 @@
<string name="zap_type_anonymous_explainer">ग्राहक तथा जनता को पता नहीं किसने भुगतान भेजा</string>
<string name="zap_type_nonzap">ज्साप अतिरिक्त</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_description">इस अभिलेख का आरोहण करने के लिए सेवासंगणक का चयन करें</string>
<string name="zap_forward_lnAddress">लै॰जाल पता अथवा @उपयोगकर्ता</string>
@@ -711,6 +723,19 @@
<string name="terms_and_conditions">नियम तथा अवस्था</string>
<string name="not_available_acronym">अनुपलब्ध</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_outbox">अपूर्ण निर्गतपेटिका घटनाएँ</string>
<string name="relay_req_subscriptions">अनुरोध ग्राहकताएँ (%1$d)</string>
@@ -1104,6 +1129,13 @@
<string name="new_community_note">नया सामुदायिक टीका</string>
<string name="new_product">नया उत्पाद</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="close_all_reactions_to_this_post">इस पत्र प्रकाशन के सभी प्रतिक्रियाओं को अवरोधित करें</string>
<string name="reply_description">उत्तर</string>
@@ -1238,9 +1270,14 @@
<string name="git_repository">गिट क्रमलेखकोश : %1$s</string>
<string name="git_web_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="ots_info_title">समयांकन प्रमाण</string>
<string name="ots_info_description">प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया द्व्यंकरूप्य खण्डश्रृंखला में उस समय उस दिन पर।</string>
<string name="edit_article">लेख सम्पादित करें</string>
<string name="edit_post">पत्र का सम्पादन करें</string>
<string name="proposal_to_edit">पत्र शोधन के लिए प्रस्ताव</string>
<string name="message_to_author">परिवर्तनों का साराम्श</string>
@@ -1320,6 +1357,7 @@
<string name="feed_group_hashtags">विषयसूचक</string>
<string name="feed_group_communities">समुदाय</string>
<string name="feed_group_lists">सूचियाँ</string>
<string name="feed_group_relays">पुनःप्रसारक</string>
<string name="temporary_account">यन्त्र ताला लगने पर निर्गमनांकन करें</string>
<string name="private_message">निजी सन्देश</string>
<string name="public_message">सार्वजनिक संदेश</string>
@@ -1556,6 +1594,7 @@
<string name="kind_shorts">छोटे</string>
<string name="kind_voice_msg">ध्वनि सन्देश</string>
<string name="kind_voice_reply">ध्वनि उत्तर</string>
<string name="kind_web_bookmark">जाल स्मर्त्तव्यचिह्न</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="import_follow_list">अनुचरित सूची आयात</string>
@@ -1584,6 +1623,21 @@
<string name="select_all">सभी चुनें</string>
<string name="uptime">%1$d%% समय निरन्तर उपलब्ध</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_section">पुनःप्रसारक समचरणीकरण</string>
<string name="event_sync_section_explainer">अपने घटनाओं को पुनःप्रकाशित करें सभी ज्ञात पुनःप्रसारकों तक अपने निर्गतपेटिका आगतपेटिका तथा सीधासन्देश पुनःप्रसारकों का अद्यतन करने के लिए। वैफै॰ आवश्यक। यह सम्भाव्यतः बहुत जानकारी भेज सकता है।</string>
@@ -1622,8 +1676,12 @@
<string name="event_sync_no_events">कोई घटना नहीं</string>
<string name="ots_explorer_settings">द्व्यंकरूप्य समन्वेषक (ओटीएस॰)</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="profiles">परिचय</string>
<string name="relay_settings_lower2">निर्गतपेटिका सूचियाँ</string>
<string name="relay_settings_lower">पुनःप्रसारक स्थापना विकल्प</string>
<string name="last_seen">पूर्व दृष्ट %1$s पहले</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
@@ -1664,6 +1722,135 @@
<string name="attestor_proficiency_for_kinds">प्रकारों के सत्यापन में निपुण : %1$s</string>
<string name="attestation_attests_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 -->
<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 -->
<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>
@@ -1270,6 +1270,10 @@
<string name="git_repository">Git tároló: %1$s</string>
<string name="git_web_address">Weboldal:</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="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>
@@ -1832,4 +1836,21 @@
<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="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>
@@ -730,6 +730,8 @@
<string name="relay_monitor_requirements">Requisitos</string>
<string name="relay_monitor_last_check">Última verificação</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_outbox">Eventos Pendentes na Outbox</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_web_address">Site:</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="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>
@@ -1825,4 +1831,21 @@
<string name="relay_management_relay_icon_url">URL do Ícone do Relay</string>
<string name="relay_management_button">Gerenciar Relay</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>
@@ -729,6 +729,8 @@
<string name="relay_monitor_requirements">Krav</string>
<string name="relay_monitor_last_check">Senaste kontroll</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_outbox">Väntande utgående händelser</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_web_address">Webbplats:</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="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>
@@ -1824,4 +1830,21 @@
<string name="relay_management_relay_icon_url">URL för reläikon</string>
<string name="relay_management_button">Hantera relä</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>
@@ -1270,6 +1270,10 @@
<string name="git_repository">Git 仓库: %1$s</string>
<string name="git_web_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="ots_info_title">OpenTimestamps 证明</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_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>
@@ -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),
)
}
@@ -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()
}
}
}
@@ -32,6 +32,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlinx.coroutines.CoroutineScope
@@ -66,6 +69,7 @@ class FeedMetadataCoordinator(
// Track what we've already queued to avoid duplicates
private val queuedPubkeys = mutableSetOf<HexKey>()
private val queuedNoteIds = mutableSetOf<HexKey>()
private val queuedBoostedIds = mutableSetOf<HexKey>()
/**
* Start processing the subscription queue.
@@ -110,6 +114,37 @@ class FeedMetadataCoordinator(
fun loadMetadataForNotes(notes: List<Note>) {
if (notes.isEmpty()) return
// Fetch referenced note content: reposts (via replyTo) + quoted notes (via e-tags)
val repostBoostedIds =
notes
.filter { it.event is RepostEvent || it.event is GenericRepostEvent }
.mapNotNull { it.replyTo?.lastOrNull() }
.filter { it.event == null }
.map { it.idHex }
val quotedNoteIds =
notes
.mapNotNull { it.event }
.flatMap { event -> event.tags.mapNotNull { ETag.parseId(it) } }
val allReferencedIds =
(repostBoostedIds + quotedNoteIds)
.filter { it !in queuedBoostedIds }
.distinct()
if (allReferencedIds.isNotEmpty()) {
queuedBoostedIds.addAll(allReferencedIds)
val referencedFilter =
Filter(
ids = allReferencedIds,
)
priorityQueue.enqueue(
SubscriptionPriority.METADATA,
referencedFilter,
tag = "feed-referenced-notes",
)
}
// Extract unique authors that we haven't already queued
val authors =
notes
@@ -36,8 +36,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
@@ -148,6 +150,13 @@ class DesktopLocalCache : ICacheProvider {
val newUserMetadata = event.contactMetaData()
if (newUserMetadata != null) {
user.updateUserInfo(newUserMetadata, event)
// Invalidate metadata flows on notes by this author that have observers
// so QuotedNoteEmbed/FeedNoteCard recompose with updated avatar/name
notes.forEach { _, note ->
if (note.author?.pubkeyHex == event.pubKey && note.flowSet?.metadata?.hasObservers() == true) {
note.flowSet?.metadata?.invalidateData()
}
}
}
}
}
@@ -165,7 +174,7 @@ class DesktopLocalCache : ICacheProvider {
when (event) {
is MetadataEvent -> {
consumeMetadata(event)
true
false // metadata updates User, not Note — skip event stream
}
is TextNoteEvent -> {
@@ -188,6 +197,10 @@ class DesktopLocalCache : ICacheProvider {
consumeRepost(event, relay)
}
is GenericRepostEvent -> {
consumeGenericRepost(event, relay)
}
is ContactListEvent -> {
consumeContactList(event)
}
@@ -200,6 +213,10 @@ class DesktopLocalCache : ICacheProvider {
consumeBookmarkList(event)
}
is CommentEvent -> {
consumeComment(event, relay)
}
else -> {
false
}
@@ -216,7 +233,25 @@ class DesktopLocalCache : ICacheProvider {
val note = getOrCreateNote(event.id)
if (note.event != null) return false
val author = getOrCreateUser(event.pubKey)
val repliesTo = event.tagsWithoutCitations().mapNotNull { getNoteIfExists(it) }
val repliesTo = event.tagsWithoutCitations().map { getOrCreateNote(it) }
note.loadEvent(event, author, repliesTo)
relay?.let { note.addRelay(it) }
repliesTo.forEach { it.addReply(note) }
return true
}
/**
* Consumes a kind 1111 comment event (NIP-22).
* Like text notes but uses BaseThreadedEvent reply structure.
*/
private fun consumeComment(
event: CommentEvent,
relay: NormalizedRelayUrl?,
): Boolean {
val note = getOrCreateNote(event.id)
if (note.event != null) return false
val author = getOrCreateUser(event.pubKey)
val repliesTo = event.tagsWithoutCitations().map { getOrCreateNote(it) }
note.loadEvent(event, author, repliesTo)
relay?.let { note.addRelay(it) }
repliesTo.forEach { it.addReply(note) }
@@ -299,6 +334,8 @@ class DesktopLocalCache : ICacheProvider {
/**
* Consumes a kind 6 repost event.
* Links repost to target note via e-tag.
* Uses getOrCreateNote for the boosted note so the link exists even if
* the original note hasn't arrived yet (it will be filled in later).
*/
private fun consumeRepost(
event: RepostEvent,
@@ -307,7 +344,27 @@ class DesktopLocalCache : ICacheProvider {
val note = getOrCreateNote(event.id)
if (note.event != null) return false
val author = getOrCreateUser(event.pubKey)
val boostedNote = event.boostedEventId()?.let { getNoteIfExists(it) }
val boostedId = event.boostedEventId()
val boostedNote = boostedId?.let { getOrCreateNote(it) }
val repliesTo = listOfNotNull(boostedNote)
note.loadEvent(event, author, repliesTo)
relay?.let { note.addRelay(it) }
boostedNote?.addBoost(note)
return true
}
/**
* Consumes a kind 16 generic repost event.
* Links repost to target note via e-tag.
*/
private fun consumeGenericRepost(
event: GenericRepostEvent,
relay: NormalizedRelayUrl?,
): Boolean {
val note = getOrCreateNote(event.id)
if (note.event != null) return false
val author = getOrCreateUser(event.pubKey)
val boostedNote = event.boostedEventId()?.let { getOrCreateNote(it) }
val repliesTo = listOfNotNull(boostedNote)
note.loadEvent(event, author, repliesTo)
relay?.let { note.addRelay(it) }
@@ -28,12 +28,29 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
private fun isFeedNote(event: com.vitorpamplona.quartz.nip01Core.core.Event?): Boolean =
event is TextNoteEvent ||
event is RepostEvent ||
event is GenericRepostEvent
private fun List<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(
private val cache: DesktopLocalCache,
@@ -42,19 +59,20 @@ class DesktopGlobalFeedFilter(
override fun feed(): List<Note> =
cache.notes
.filterIntoSet { _, note -> note.event is TextNoteEvent }
.filterIntoSet { _, note -> isFeedNote(note.event) }
.sortedWith(DefaultFeedOrder)
.deduplicateReposts()
.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
}
/**
* Following feed: kind 1 text notes from followed pubkeys.
* Following feed: kind 1 text notes + kind 6/16 reposts from followed pubkeys.
*/
class DesktopFollowingFeedFilter(
private val cache: DesktopLocalCache,
@@ -66,19 +84,20 @@ class DesktopFollowingFeedFilter(
val follows = followedPubkeys()
return cache.notes
.filterIntoSet { _, note ->
note.event is TextNoteEvent && note.author?.pubkeyHex in follows
isFeedNote(note.event) && note.author?.pubkeyHex in follows
}.sortedWith(DefaultFeedOrder)
.deduplicateReposts()
.take(limit())
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
val follows = followedPubkeys()
return newItems.filterTo(HashSet()) {
it.event is TextNoteEvent && it.author?.pubkeyHex in follows
isFeedNote(it.event) && it.author?.pubkeyHex in follows
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
override fun limit(): Int = 2500
}
@@ -116,7 +135,7 @@ class DesktopThreadFilter(
}
/**
* Profile feed: all kind 1 notes by a specific pubkey.
* Profile feed: text notes + reposts by a specific pubkey.
*/
class DesktopProfileFeedFilter(
private val pubkey: HexKey,
@@ -124,19 +143,21 @@ class DesktopProfileFeedFilter(
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "profile-$pubkey"
override fun feed(): List<Note> =
cache.notes
.filterIntoSet { _, note ->
note.event is TextNoteEvent && note.author?.pubkeyHex == pubkey
}.sortedWith(DefaultFeedOrder)
.take(limit())
override fun applyFilter(newItems: Set<Note>): Set<Note> =
newItems.filterTo(HashSet()) {
it.event is TextNoteEvent && it.author?.pubkeyHex == pubkey
private fun isProfileNote(note: Note): Boolean {
val event = note.event ?: return false
return note.author?.pubkeyHex == pubkey && isFeedNote(event)
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
override fun feed(): List<Note> =
cache.notes
.filterIntoSet { _, note -> isProfileNote(note) }
.sortedWith(DefaultFeedOrder)
.deduplicateReposts()
.take(limit())
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { isProfileNote(it) }
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
override fun limit(): Int = 1000
}
@@ -29,7 +29,6 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -91,11 +90,9 @@ class DesktopRelaySubscriptionsCoordinator(
scope = scope,
indexRelays = indexRelays,
preloader = preloader,
onEvent = { event, _ ->
// Consume metadata events into local cache
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
onEvent = { event, relay ->
// Route all fetched events (metadata, boosted notes, etc.) into cache
localCache.consume(event, relay)
},
)
@@ -221,7 +218,7 @@ class DesktopRelaySubscriptionsCoordinator(
filters =
indexRelays.associateWith {
listOf(
com.vitorpamplona.quartz.nip01Core.relay.filters.Filter(
Filter(
kinds = listOf(com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent.KIND),
authors = listOf(pubkey),
limit = 1,
@@ -37,7 +37,7 @@ enum class FeedMode {
*/
fun createGlobalFeedSubscription(
relays: Set<NormalizedRelayUrl>,
limit: Int = 50,
limit: Int = 200,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
@@ -55,7 +55,7 @@ fun createGlobalFeedSubscription(
fun createFollowingFeedSubscription(
relays: Set<NormalizedRelayUrl>,
followedUsers: List<String>,
limit: Int = 50,
limit: Int = 200,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
@@ -121,9 +121,21 @@ fun createThreadRepliesSubscription(
subId = generateSubId("thread-${noteId.take(8)}"),
filters =
listOf(
// Kind 1 replies via lowercase e-tag (NIP-10)
FilterBuilders.byETags(
eventIds = listOf(noteId),
kinds = listOf(1), // TextNoteEvent
kinds = listOf(1),
limit = limit,
),
// Kind 1111 comments via lowercase e-tag (reply parent) or uppercase E-tag (root)
FilterBuilders.byTags(
tags = mapOf("e" to listOf(noteId)),
kinds = listOf(1111),
limit = limit,
),
FilterBuilders.byTags(
tags = mapOf("E" to listOf(noteId)),
kinds = listOf(1111),
limit = limit,
),
),
@@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
* Provides convenience functions for creating relay subscription filters.
*/
object FilterBuilders {
private val FEED_KINDS = listOf(1, 6, 16) // TextNoteEvent, RepostEvent, GenericRepostEvent
/**
* Creates a filter for text notes (kind 1) from all authors.
*
@@ -41,7 +43,7 @@ object FilterBuilders {
until: Long? = null,
): Filter =
Filter(
kinds = listOf(1), // TextNoteEvent.KIND
kinds = FEED_KINDS,
limit = limit,
since = since,
until = until,
@@ -63,7 +65,7 @@ object FilterBuilders {
until: Long? = null,
): Filter =
Filter(
kinds = listOf(1), // TextNoteEvent.KIND
kinds = FEED_KINDS,
authors = authors,
limit = limit,
since = since,
@@ -55,9 +55,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.compose.elements.BoostedMark
import com.vitorpamplona.amethyst.commons.compose.layouts.GenericRepostLayout
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState
@@ -67,14 +71,22 @@ import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
data class LightboxState(
val urls: List<String>,
@@ -86,6 +98,8 @@ data class LightboxState(
/**
* Note card that reads counts from the Note model (cache-backed).
* Event is extracted from Note for signing operations in NoteActionsRow.
* Handles reposts (kind 6/16) by showing overlapping avatars + "Boosted" label
* and rendering the original note content.
*/
@Composable
fun FeedNoteCard(
@@ -102,20 +116,108 @@ fun FeedNoteCard(
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
) {
val event = note.event ?: return
val isRepost = event is RepostEvent || event is GenericRepostEvent
// Observe Note.flowSet for live count updates
if (isRepost) {
val originalNote = note.replyTo?.lastOrNull()
if (originalNote == null) {
return
}
// Observe original note's flowSet — MUST happen before reading .event
// so we recompose when the async fetch fills in the event
val flowSet = remember(originalNote) { originalNote.flow() }
val metadataState by flowSet.metadata.stateFlow.collectAsState()
val reactionsState by flowSet.reactions.stateFlow.collectAsState()
val repliesState by flowSet.replies.stateFlow.collectAsState()
val zapsState by flowSet.zaps.stateFlow.collectAsState()
DisposableEffect(originalNote) {
onDispose { originalNote.clearFlow() }
}
// Now read event — recomposition will re-read this when metadata invalidates
val originalEvent = originalNote.event
if (originalEvent == null) {
return
}
val reactionCount = originalNote.countReactions()
val replyCount = originalNote.replies.size
val repostCount = originalNote.boosts.size
val zapAmount = originalNote.zapsAmount
val reposterUser = localCache.getUserIfExists(event.pubKey)
val originalUser = localCache.getUserIfExists(originalEvent.pubKey)
Column {
// Repost header: overlapping avatars + "Boosted" label
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) {
GenericRepostLayout(
baseAuthorPicture = {
UserAvatar(
userHex = originalEvent.pubKey,
pictureUrl = originalUser?.profilePicture(),
size = 35.dp,
)
},
repostAuthorPicture = {
UserAvatar(
userHex = event.pubKey,
pictureUrl = reposterUser?.profilePicture(),
size = 35.dp,
)
},
)
BoostedMark()
}
// Original note content
NoteCard(
note = originalEvent.toNoteDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(originalEvent.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
onImageClick = onImageClick,
onMediaClick = onMediaClick,
)
// Action buttons for original note
if (account != null) {
NoteActionsRow(
event = originalEvent,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReplyClick = onReply,
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = originalNote.zaps.size,
zapAmountSats = zapAmount.toLong(),
zapReceipts = emptyList(),
reactionCount = reactionCount,
replyCount = replyCount,
repostCount = repostCount,
)
}
}
} else {
// Regular note rendering (unchanged)
val flowSet = remember(note) { note.flow() }
val reactionsState by flowSet.reactions.stateFlow.collectAsState()
val repliesState by flowSet.replies.stateFlow.collectAsState()
val zapsState by flowSet.zaps.stateFlow.collectAsState()
// Read counts from Note model (re-read on each stateFlow emission)
val reactionCount = note.countReactions()
val replyCount = note.replies.size
val repostCount = note.boosts.size
val zapAmount = note.zapsAmount
// Clean up flowSet when card leaves composition
DisposableEffect(note) {
onDispose { note.clearFlow() }
}
@@ -131,7 +233,6 @@ fun FeedNoteCard(
onMediaClick = onMediaClick,
)
// Action buttons (only if logged in)
if (account != null) {
NoteActionsRow(
event = event,
@@ -144,13 +245,14 @@ fun FeedNoteCard(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = note.zaps.size,
zapAmountSats = zapAmount.toLong(),
zapReceipts = emptyList(), // TODO: extract ZapReceipts from Note.zaps
zapReceipts = emptyList(),
reactionCount = reactionCount,
replyCount = replyCount,
repostCount = repostCount,
)
}
}
}
}
@Composable
@@ -248,16 +350,115 @@ fun FeedScreen(
val feedState by viewModel.feedState.feedContent.collectAsState()
// Load metadata for visible notes via Coordinator (rate-limited)
// Load metadata for visible notes + repost/quoted note authors via Coordinator
LaunchedEffect(feedState, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null && feedState is FeedState.Loaded) {
val notes = viewModel.feedState.visibleNotes()
if (notes.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForNotes(notes)
// Also load metadata for repost original + quoted note authors
val referencedAuthors =
notes
.filter { it.event is RepostEvent || it.event is GenericRepostEvent }
.mapNotNull {
it.replyTo
?.lastOrNull()
?.author
?.pubkeyHex
}
subscriptionsCoordinator.loadMetadataForPubkeys(referencedAuthors)
}
}
}
// Fetch missing referenced notes (repost originals + quoted notes via e-tags)
// Uses a direct relay subscription — bypasses the coordinator pipeline
val missingNoteIds =
remember(feedState) {
if (feedState !is FeedState.Loaded) return@remember emptyList<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)
DisposableEffect(feedMode, subscriptionsCoordinator) {
val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {}
@@ -204,7 +204,8 @@ fun ReadsScreen(
val events by eventState.items.collectAsState()
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) }
val initialLoadComplete = eoseReceivedCount > 0
@@ -272,7 +273,8 @@ fun ReadsScreen(
createFollowingLongFormFeedSubscription(
relays = connectedRelays,
followedUsers = followedUsers.toList(),
onEvent = { event, _, _, _ ->
onEvent = { event, _, relay, _ ->
subscriptionsCoordinator?.consumeEvent(event, relay)
if (event is LongTextNoteEvent) {
eventState.addItem(event)
}
@@ -121,6 +121,7 @@ fun UserProfileScreen(
onBack: () -> Unit,
onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
onNavigateToArticle: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
) {
@@ -796,6 +797,7 @@ fun UserProfileScreen(
onReply = onCompose,
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index ->
lightboxState = LightboxState(urls, index)
},
@@ -296,6 +296,7 @@ internal fun RootContent(
onBack = {},
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback,
)
@@ -325,6 +326,7 @@ internal fun RootContent(
onBack = {},
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
)
}
@@ -426,6 +428,7 @@ internal fun OverlayContent(
onBack = onBack,
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onNavigateToArticle = onNavigateToArticle,
onZapFeedback = onZapFeedback,
)
@@ -38,6 +38,9 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -63,6 +66,7 @@ import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl
import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
@@ -324,6 +328,7 @@ fun NoteCard(
private data class ResolvedMention(
val displayText: String,
val pubKeyHex: String? = null,
val noteIdHex: String? = null,
)
/**
@@ -354,11 +359,11 @@ private fun resolveBech32(
}
is NNote -> {
ResolvedMention("note:${entity.hex.take(8)}...")
ResolvedMention(displayText = "note:${entity.hex.take(8)}...", noteIdHex = entity.hex)
}
is NEvent -> {
ResolvedMention("note:${entity.hex.take(8)}...")
ResolvedMention(displayText = "note:${entity.hex.take(8)}...", noteIdHex = entity.hex)
}
else -> {
@@ -386,12 +391,37 @@ fun extractMentionedPubkeys(bech32s: Set<String>): List<String> =
* URLs are underlined in primary color; bech32 mentions show as @displayName in primary color
* and navigate to profile on click.
*/
private data class ContentSegment(
val start: Int,
val raw: String,
val isUrl: Boolean,
)
private fun buildSegments(
content: String,
schemeUrls: Collection<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
fun RichTextContent(
content: String,
urls: Urls,
localCache: DesktopLocalCache? = null,
onMentionClick: ((String) -> Unit)? = null,
onNavigateToThread: ((String) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val defaultColor = MaterialTheme.colorScheme.onSurface
@@ -404,47 +434,130 @@ fun RichTextContent(
color = defaultColor,
modifier = modifier,
)
return
}
// Resolve bech32s to find quoted notes vs inline mentions
val resolvedBech32s =
remember(urls.bech32s, localCache) {
urls.bech32s.associateWith { resolveBech32(it, localCache) }
}
// Collect quoted note IDs (nevent/note references)
val quotedBech32s = remember(resolvedBech32s) { resolvedBech32s.filter { it.value.noteIdHex != null }.keys }
val quotedNoteIds = remember(resolvedBech32s) { resolvedBech32s.values.mapNotNull { it.noteIdHex }.toSet() }
if (quotedNoteIds.isEmpty()) {
// No quoted notes — render everything as annotated text
val segments = remember(content, urls) { buildSegments(content, urls.withScheme, urls.bech32s) }
RichAnnotatedText(content, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick, modifier)
} else {
data class Segment(
val start: Int,
val raw: String,
val isUrl: Boolean,
// Has quoted notes — render text + embedded note cards
Column(modifier = modifier) {
// Strip quoted note bech32 references from text
val strippedText =
remember(content, quotedBech32s) {
var text = content
for (bech32 in quotedBech32s) {
text = text.replace(bech32, "").trim()
}
text
}
if (strippedText.isNotBlank()) {
val inlineBech32s = urls.bech32s - quotedBech32s
val segments = remember(strippedText, urls, inlineBech32s) { buildSegments(strippedText, urls.withScheme, inlineBech32s) }
RichAnnotatedText(strippedText, segments, resolvedBech32s, defaultColor, primaryColor, onMentionClick)
}
// Render quoted notes as embedded cards
for (noteId in quotedNoteIds) {
Spacer(Modifier.height(8.dp))
QuotedNoteEmbed(noteId, localCache, onMentionClick, onNavigateToThread)
}
}
}
}
/**
* Renders a quoted note by ID. Observes the note's metadata flow so it
* recomposes when the event arrives asynchronously from a relay fetch.
* Also observes the author's metadata for display name / avatar updates.
*/
@Composable
fun QuotedNoteEmbed(
noteId: String,
localCache: DesktopLocalCache?,
onMentionClick: ((String) -> Unit)? = null,
onNavigateToThread: ((String) -> Unit)? = null,
) {
if (localCache == null) return
// getOrCreateNote ensures a placeholder exists so subscriptions can find it
val note = remember(noteId) { localCache.getOrCreateNote(noteId) }
// Observe note metadata flow — recomposes when loadEvent() is called
val flowSet = remember(note) { note.flow() }
val metadataState by flowSet.metadata.stateFlow.collectAsState()
DisposableEffect(note) {
onDispose { note.clearFlow() }
}
val event = note.event
if (event != null) {
// Recompute on every recomposition — picks up user metadata changes
val displayData = event.toNoteDisplayData(localCache)
NoteCard(
note = displayData,
localCache = localCache,
onClick = onNavigateToThread?.let { nav -> { nav(event.id) } },
onAuthorClick = onMentionClick,
onMentionClick = onMentionClick,
)
} else {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
) {
Text(
"Loading quoted note...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(12.dp),
)
val segments = mutableListOf<Segment>()
for (url in urls.withScheme) {
val idx = content.indexOf(url)
if (idx != -1) segments.add(Segment(idx, url, true))
}
for (bech32 in urls.bech32s) {
val idx = content.indexOf(bech32)
if (idx != -1) segments.add(Segment(idx, bech32, false))
}
segments.sortBy { it.start }
}
@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 (segment in segments) {
if (segment.start < lastIndex) continue
// Add text before segment
if (segment.start > lastIndex) {
append(content.substring(lastIndex, segment.start))
for (seg in segments) {
if (seg.start < lastIndex) continue
if (seg.start > lastIndex) {
append(content.substring(lastIndex, seg.start))
}
if (segment.isUrl) {
withStyle(
SpanStyle(
color = primaryColor,
textDecoration = TextDecoration.Underline,
),
) {
append(segment.raw)
if (seg.isUrl) {
withStyle(SpanStyle(color = primaryColor, textDecoration = TextDecoration.Underline)) {
append(seg.raw)
}
} else {
val resolved = resolveBech32(segment.raw, localCache)
val resolved = resolvedBech32s[seg.raw] ?: ResolvedMention(seg.raw)
if (resolved.pubKeyHex != null && onMentionClick != null) {
val pubKey = resolved.pubKeyHex
withLink(
@@ -463,11 +576,8 @@ fun RichTextContent(
}
}
}
lastIndex = segment.start + segment.raw.length
lastIndex = seg.start + seg.raw.length
}
// Add remaining text
if (lastIndex < content.length) {
append(content.substring(lastIndex))
}
@@ -479,5 +589,4 @@ fun RichTextContent(
color = defaultColor,
modifier = modifier,
)
}
}
@@ -37,7 +37,7 @@ class FilterBuildersTest {
fun testTextNotesGlobal() {
val filter = FilterBuilders.textNotesGlobal(limit = 50)
assertEquals(listOf(1), filter.kinds)
assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(50, filter.limit)
assertNull(filter.authors)
assertNull(filter.tags)
@@ -51,7 +51,7 @@ class FilterBuildersTest {
val until = 1640995200L // 2022-01-01
val filter = FilterBuilders.textNotesGlobal(limit = 100, since = since, until = until)
assertEquals(listOf(1), filter.kinds)
assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(100, filter.limit)
assertEquals(since, filter.since)
assertEquals(until, filter.until)
@@ -62,7 +62,7 @@ class FilterBuildersTest {
val authors = listOf(testPubKey, testPubKey2)
val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 25)
assertEquals(listOf(1), filter.kinds)
assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(authors, filter.authors)
assertEquals(25, filter.limit)
assertNull(filter.tags)
@@ -74,7 +74,7 @@ class FilterBuildersTest {
val since = 1609459200L
val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 10, since = since)
assertEquals(listOf(1), filter.kinds)
assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(authors, filter.authors)
assertEquals(10, filter.limit)
assertEquals(since, filter.since)
@@ -421,7 +421,7 @@ class FilterBuildersTest {
val filter = FilterBuilders.textNotesGlobal(limit = 50)
assertTrue(!filter.isEmpty())
assertEquals(listOf(1), filter.kinds)
assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(50, filter.limit)
}
@@ -431,7 +431,7 @@ class FilterBuildersTest {
val filter = FilterBuilders.textNotesFromAuthors(followedUsers, limit = 50)
assertTrue(!filter.isEmpty())
assertEquals(listOf(1), filter.kinds)
assertEquals(listOf(1, 6, 16), filter.kinds)
assertEquals(followedUsers, filter.authors)
assertEquals(50, filter.limit)
}
@@ -447,7 +447,7 @@ class FilterBuildersTest {
assertTrue(!contactListFilter.isEmpty())
assertEquals(listOf(0), metadataFilter.kinds)
assertEquals(listOf(1), postsFilter.kinds)
assertEquals(listOf(1, 6, 16), postsFilter.kinds)
assertEquals(listOf(3), contactListFilter.kinds)
}
+562
View File
@@ -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
View File
@@ -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
```
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyStack
/**
* Defines custom behavior for this relay.
@@ -76,7 +77,7 @@ interface IRelayPolicy {
*/
fun canSendToSession(event: Event): Boolean = true
operator fun plus(other: IRelayPolicy) = listOf(this, other)
operator fun plus(other: IRelayPolicy): IRelayPolicy = PolicyStack(this, other)
}
sealed interface PolicyResult<T : Command> {
@@ -41,7 +41,7 @@ class NostrServer(
private val store: IEventStore,
private val policyBuilder: () -> IRelayPolicy = { VerifyPolicy },
private val parentContext: CoroutineContext = SupervisorJob(),
) {
) : AutoCloseable {
private val subStore = LiveEventStore(store)
/** Scope for all subscriptions. */
@@ -70,11 +70,38 @@ class NostrServer(
}
/**
* Shuts down the server, cancelling all subscriptions and sessions.
* Registers a new client connection and serves it for the duration of
* [incoming]. The session is automatically closed when [incoming] returns.
*
* @param send Callback the server uses to send JSON messages to this client.
* @param incoming Suspend block that yields raw JSON strings from the client
* (e.g., reading WebSocket text frames in a loop).
*/
fun shutdown() {
suspend fun serve(
send: (String) -> Unit,
incoming: suspend (RelaySession) -> Unit,
) {
val session = connect(send)
try {
incoming(session)
} finally {
session.close()
}
}
/**
* Shuts down the server, cancelling all subscriptions and closing the store.
*/
override fun close() {
connections.forEach { _, session -> session.cancelAllSubscriptions() }
connections.clear()
scope.cancel()
store.close()
}
/**
* Shuts down the server, cancelling all subscriptions and closing the store.
*/
@Deprecated("Use close() instead", replaceWith = ReplaceWith("close()"))
fun shutdown() = close()
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.store
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
interface IEventStore {
interface IEventStore : AutoCloseable {
fun insert(event: Event)
interface ITransaction {
@@ -56,5 +56,5 @@ interface IEventStore {
fun deleteExpiredEvents()
fun close()
override fun close()
}
@@ -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()
@@ -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)
@@ -41,7 +41,7 @@ class PlatformLinkTag(
}
fun parse(tag: Tag): PlatformLinkTag? {
if (match(tag)) return PlatformLinkTag(tag[1], tag[2], tag.getOrNull(3))
if (match(tag)) return PlatformLinkTag(tag[0], tag[1], tag.getOrNull(2))
return null
}
@@ -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)
@@ -142,7 +142,7 @@ class NostrServerAuthTest {
assertEquals(1, collector.messages.size)
assertTrue(collector.messages[0].contains("\"AUTH\""))
server.shutdown()
server.close()
}
@Test
@@ -167,7 +167,7 @@ class NostrServerAuthTest {
assertTrue((session.policy as FullAuthPolicy).isAuthenticated())
assertTrue(session.policy.authenticatedUsers.contains(pubkey))
server.shutdown()
server.close()
}
@Test
@@ -188,7 +188,7 @@ class NostrServerAuthTest {
assertTrue(okMessages[0].contains("challenge"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
server.shutdown()
server.close()
}
@Test
@@ -213,7 +213,7 @@ class NostrServerAuthTest {
assertTrue(okMessages[0].contains("relay url"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
server.shutdown()
server.close()
}
@Test
@@ -242,7 +242,7 @@ class NostrServerAuthTest {
assertTrue(okMessages[0].contains("created_at"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
server.shutdown()
server.close()
}
@Test
@@ -281,7 +281,7 @@ class NostrServerAuthTest {
assertTrue(okMessages[0].contains("could not parse message"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
server.shutdown()
server.close()
}
@Test
@@ -315,7 +315,7 @@ class NostrServerAuthTest {
assertTrue(authedPubkeys.contains(pubkey))
assertTrue(authedPubkeys.contains(pubkey2))
server.shutdown()
server.close()
}
// -- NIP-42: requireAuth ---------------------------------------------------
@@ -337,7 +337,7 @@ class NostrServerAuthTest {
assertTrue(okMessages[0].contains("\"false\""))
assertTrue(okMessages[0].contains("auth-required:"))
server.shutdown()
server.close()
}
@Test
@@ -354,7 +354,7 @@ class NostrServerAuthTest {
assertEquals(1, closedMessages.size)
assertTrue(closedMessages[0].contains("auth-required:"))
server.shutdown()
server.close()
}
@Test
@@ -371,7 +371,7 @@ class NostrServerAuthTest {
assertEquals(1, closedMessages.size)
assertTrue(closedMessages[0].contains("auth-required:"))
server.shutdown()
server.close()
}
@Test
@@ -410,7 +410,7 @@ class NostrServerAuthTest {
val eoseMessages = collector.rawMessagesContaining("EOSE")
assertTrue(eoseMessages.isNotEmpty())
server.shutdown()
server.close()
}
@Test
@@ -430,7 +430,7 @@ class NostrServerAuthTest {
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"true\""))
server.shutdown()
server.close()
}
// -- Custom AuthPolicy tests -----------------------------------------------
@@ -471,7 +471,7 @@ class NostrServerAuthTest {
assertTrue(okMessages[1].contains("\"false\""))
assertTrue(okMessages[1].contains("auth-required:"))
server.shutdown()
server.close()
}
@Test
@@ -531,6 +531,6 @@ class NostrServerAuthTest {
assertEquals(1, events.size)
assertEquals(pubkey, events[0].event.pubKey)
server.shutdown()
server.close()
}
}
@@ -115,7 +115,7 @@ class NostrServerTest {
val stored = store.query<Event>(Filter(ids = listOf(event.id)))
assertEquals(1, stored.size)
server.shutdown()
server.close()
}
@Test
@@ -138,7 +138,7 @@ class NostrServerTest {
assertTrue(okMessages[0].contains("\"true\""))
assertTrue(okMessages[1].contains("\"false\""))
server.shutdown()
server.close()
}
// -- REQ command -----------------------------------------------------------
@@ -172,7 +172,7 @@ class NostrServerTest {
// Events should be newest first
assertTrue(events[0].event.createdAt >= events[1].event.createdAt)
server.shutdown()
server.close()
}
@Test
@@ -195,7 +195,7 @@ class NostrServerTest {
val events = collector.parsedEventMessages().filterIsInstance<EventMessage>()
assertEquals(3, events.size)
server.shutdown()
server.close()
}
// -- Live subscription -----------------------------------------------------
@@ -226,7 +226,7 @@ class NostrServerTest {
assertTrue(newMessages.isNotEmpty())
assertTrue(newMessages[0].contains("\"EVENT\""))
server.shutdown()
server.close()
}
@Test
@@ -251,7 +251,7 @@ class NostrServerTest {
assertEquals(countAfterEose, collector1.messages.size)
server.shutdown()
server.close()
}
// -- CLOSE command ---------------------------------------------------------
@@ -282,7 +282,7 @@ class NostrServerTest {
assertEquals(countAfterClose, collector1.messages.size)
server.shutdown()
server.close()
}
@Test
@@ -318,7 +318,7 @@ class NostrServerTest {
assertTrue(newMessages[0].contains("\"EVENT\""))
assertTrue(newMessages[0].contains(hexId(2)))
server.shutdown()
server.close()
}
// -- COUNT command (NIP-45) ------------------------------------------------
@@ -344,7 +344,7 @@ class NostrServerTest {
assertEquals(1, countMessages.size)
assertTrue(countMessages[0].contains("\"count\":2"))
server.shutdown()
server.close()
}
// -- Disconnect ------------------------------------------------------------
@@ -374,7 +374,7 @@ class NostrServerTest {
assertEquals(countAfterDisconnect, collector1.messages.size)
assertEquals(2, collector2.messages.size)
server.shutdown()
server.close()
}
// -- Invalid messages ------------------------------------------------------
@@ -392,6 +392,6 @@ class NostrServerTest {
assertEquals(1, collector.messages.size)
assertTrue(collector.messages[0].contains("NOTICE"))
server.shutdown()
server.close()
}
}
@@ -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)
}
}