diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index 7754d5802..fc9693e92 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -745,8 +745,8 @@ android { applicationId = "com.vitorpamplona.amethyst" minSdk = 26 // Android 8.0 (Oreo) targetSdk = 36 // Android 15 - versionCode = 430 - versionName = "1.04.2" + versionCode = 435 + versionName = "1.06.3" vectorDrawables { useSupportLibrary = true diff --git a/.claude/skills/find-missing-translations/SKILL.md b/.claude/skills/find-missing-translations/SKILL.md index 36bf02c91..fe0163c5d 100644 --- a/.claude/skills/find-missing-translations/SKILL.md +++ b/.claude/skills/find-missing-translations/SKILL.md @@ -7,7 +7,7 @@ description: Use when comparing Android strings.xml locale files to find untrans ## Overview -Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs a table ready for translation. +Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them. ## When to Use @@ -15,6 +15,17 @@ Extract string resource keys from the default `values/strings.xml` that are abse - Preparing a batch of strings for a translator - Checking translation coverage after adding new features +## Target Locales + +The default set of locales (unless the user specifies otherwise): + +| Locale | Language | Directory | +|--------|----------|-----------| +| `cs-rCZ` | Czech | `values-cs-rCZ` | +| `pt-rBR` | Brazilian Portuguese | `values-pt-rBR` | +| `sv-rSE` | Swedish | `values-sv-rSE` | +| `de-rDE` | German | `values-de-rDE` | + ## Technique ### 1. Identify files @@ -24,11 +35,9 @@ Default: amethyst/src/main/res/values/strings.xml Target: amethyst/src/main/res/values-/strings.xml ``` -Default locale: `cs-rCZ` if none specified. User may override (e.g., `pt-rBR`, `ja`). +### 2. Find missing keys using cs-rCZ as reference -### 2. Extract and diff keys - -Use a single bash pipeline to extract translatable keys from both files and diff them: +Always diff against `cs-rCZ` first — it is the most complete locale and serves as the reference. Any keys missing in `cs-rCZ` will also be missing in the other target locales. ```bash # Extract translatable keys from default (exclude translatable="false") @@ -36,11 +45,11 @@ comm -23 \ <(grep 'Valid @@ -70,8 +79,18 @@ Output the missing entries as raw XML resource lines (copy-paste ready for the l Also check `` and `` tags using the same approach if the project uses them. +**Then ask the user:** "Would you like me to translate these missing strings into [list of target locales]?" + +### 5. Adding translations (if approved) + +When adding translated strings to locale files: + +- **Append new strings at the bottom** of the file, just before the closing `` tag. +- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering. + ## Common Mistakes - **Forgetting `translatable="false"`** — these should never appear in locale files - **Not checking string-arrays/plurals** — only checking `` misses other resource types -- **Modifying files** — this is a read-only research task unless the user asks to add entries \ No newline at end of file +- **Diffing each locale separately** — only diff against `cs-rCZ`; assume the same keys are missing everywhere +- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately \ No newline at end of file diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index 98164fa9e..e0d193b0f 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects. -**Published artifact**: `com.vitorpamplona.quartz:quartz:1.05.1` (Maven Central) +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.3` (Maven Central) **Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`) **License**: MIT @@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr ```toml [versions] -quartz = "1.05.1" +quartz = "1.06.3" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -41,7 +41,7 @@ kotlin { ```kotlin dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.05.1") + implementation("com.vitorpamplona.quartz:quartz:1.06.3") } ``` diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md index e1d989521..eae28c616 100644 --- a/.claude/skills/quartz-integration/references/gradle-setup.md +++ b/.claude/skills/quartz-integration/references/gradle-setup.md @@ -3,7 +3,7 @@ ## Current version ``` -com.vitorpamplona.quartz:quartz:1.05.1 +com.vitorpamplona.quartz:quartz:1.06.3 ``` Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz @@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua ```toml [versions] -quartz = "1.05.1" +quartz = "1.06.3" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -55,7 +55,7 @@ kotlin { ```kotlin // build.gradle.kts (app module) dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.05.1") + implementation("com.vitorpamplona.quartz:quartz:1.06.3") } ``` @@ -70,7 +70,7 @@ plugins { } dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.05.1") + implementation("com.vitorpamplona.quartz:quartz:1.06.3") // JNA needed for libsodium (NIP-44) on JVM implementation("net.java.dev.jna:jna:5.18.1") } diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md index 246c840e3..3ebfdd9b7 100644 --- a/.claude/skills/quartz-kmp.md +++ b/.claude/skills/quartz-kmp.md @@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp ## Current artifact ``` -com.vitorpamplona.quartz:quartz:1.05.1 +com.vitorpamplona.quartz:quartz:1.06.3 ``` See `.claude/skills/quartz-integration/SKILL.md` for full integration guide. \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 513727d82..fddb100a7 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -7,6 +7,7 @@ Nostr-Adresse nie jetzt + Sekunden s m t @@ -542,7 +543,7 @@ anz der Bedingungen ist erforderlich Neu Autor zur Follower-Liste hinzufügen Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen. - Symbol für %1$s-Liste + Symbol für Liste %1$s ist ein öffentliches Mitglied %1$s ist ein privates Mitglied Als öffentliches Mitglied hinzufügen @@ -1104,6 +1105,13 @@ anz der Bedingungen ist erforderlich Neue Community-Notiz Neues Produkt Neuer Geo-Exklusiver Beitrag + Neuer Artikel + Titel + Zusammenfassung (optional) + Cover-Bild-URL (optional) + Schreib deinen Artikel in Markdown… + Vorschau + Bearbeiten Alle Reaktionen auf diesen Beitrag öffnen Alle Reaktionen auf diesen Beitrag schließen Antworten @@ -1241,6 +1249,7 @@ anz der Bedingungen ist erforderlich OTS: %1$s Zeitstempel Beweis Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt. + Artikel bearbeiten Beitrag bearbeiten Vorschlag zur Verbesserung Ihres Beitrags Zusammenfassung der Änderungen @@ -1664,4 +1673,11 @@ anz der Bedingungen ist erforderlich Kompetent für die Verifizierung von Arten: %1$s Bestätigt Beantragt Attestierung für + Zeitraum + Von + Bis + Jetzt + Gesamter Zeitraum + Letzte Synchronisierung: %1$s + Seit letzter Synchronisierung diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index 7e962d5e0..e30acbafd 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -1001,7 +1001,7 @@ anz der Bedingungen ist erforderlich Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen. Autor zum Folge-Set hinzufügen Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen. - Symbol für %1$s-Liste + Symbol für Liste %1$s ist nicht in dieser Liste Deine Folge-Sets Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen. diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index db98abc09..3d7172fb0 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -290,6 +290,7 @@ Nostr-cím soha most + másodperc ó p n @@ -447,6 +448,8 @@ Maximum Zap Együttműködés (0–100)% + Egyetlen lehetőség + Több lehetőség Szavazás lezárásának dátuma és ideje A szavazás lezárul %1$s múlva Szavazás lezárása @@ -1180,7 +1183,7 @@ Átjátszók a kimenő üzenetkhez Állítsa be a nyilvános kimenő üzenetek átjátszóit a bejegyzéshez A tartalom fogadására kifejezetten kialakított átjátszólista létrehozása elengedhetetlen a Nostr élményhez, és ez az egyetlen módja annak, hogy a követői megtalálják Önt. - Adjon meg 1-3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért + Adjon meg 1–3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social Átjátszók a bejövő üzenetkhez Állítsa be a nyilvános bejövő üzenetek átjátszóit az értesítések fogadásához diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index eea0aa7ad..5d0dd475a 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -115,7 +115,7 @@ Sekot saraksts - Ikona %1$s sarakstam + Ikona sarakstam Izveidot jaunu sarakstu Jaunais %1$s saraksts Kolekcijas nosaukums diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 5f2c6efd6..e0790763f 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -71,7 +71,7 @@ Zacytuj Sklonuj Zaproponuj zmianę - Nowa kwota w Satsach + Nowa kwota w satoszach Dodaj "odpowiadając do " " i " @@ -92,7 +92,7 @@ Lightning transfer Wiadomość dla odbiorcy Dziękuję bardzo! - Kwota w Satsach + Kwota w satoszach Wyślij Kreator tajnych emoji Dodaj emoji z ukrytą wiadomością do wpisu @@ -181,7 +181,7 @@ Neutralna Anonimowy Zmienia barwę głosu. Uwaga: podstawowe zmiany barwy głosu mogą zostać wykryte przez uważnych słuchaczy. - Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satsy + Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satosze "odpowiedz tutaj.. " Kopiuje ID wpisu do schowka w celu udostępnienia w Nostr Kopiuj ID kanału (wpisu) do schowka @@ -287,6 +287,7 @@ Adres Nostr nigdy teraz + sekund godz. m d @@ -431,7 +432,7 @@ Kontroluje sposób wyświetlania Twojej tożsamości podczas wysyłania zapa. Podłącz portfel Przejrzyj kanał transmitera - Kwota zobowiązania w Satach + Kwota zobowiązania w Satoszach Wyślij Ankietę Wymagane pola: Odbiorcy zap @@ -444,6 +445,8 @@ Maksymalny Zap Konsensus (0–100)% + Pojedynczy wybór + Wielokrotny wybór Data & godzina zakończenia ankiety Ankieta zostanie zamknięta za %1$s Zamknij po @@ -537,7 +540,7 @@ Nowa Dodaj autora do listy obserwowanych Dodaj lub usuń użytkownika z list, lub utwórz nową listę z tym użytkownikiem. - Ikona dla listy %1$s + Ikona dla listy %1$s jest uczestnikiem publicznym %1$s jest uczestnikiem prywatnym Dodaj jako uczestnika publicznego @@ -633,7 +636,7 @@ Powiadamia Cię, gdy nadejdzie prywatna wiadomość Otrzymano Zapy Powiadamia Cię, gdy ktoś prześle ci zapy - %1$s Satsów + %1$s Satoszy Od %1$s dla %1$s Odpowiedz @@ -668,9 +671,9 @@ Nowy Symbol Odzewu Brak wstępnie wybranych typów reakcji dla tego użytkownika. Przytrzymaj przycisk serce, aby zmienić Zapraiser - Dodaje docelową liczbę satsów do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn - Docelowa kwota w Satach - Zapraiser przy: %1$s. %2$s satach do celu + Dodaje docelową liczbę satoszy do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn + Docelowa kwota w Satoszach + Zapraiser przy: %1$s. %2$s satoszach do celu Odczytaj z Transmitera Zapisz do Transmitera Ilość w bajtach, która została wysłana do tego transmitera, w tym filtry i wydarzenia @@ -878,7 +881,7 @@ Kopiuj do schowka Skopiuj nprofile do schowka Kopiuj npub do schowka - Udostępnij lub Zapisz + Udostępnij lub zapisz Kopiuj adres URL do schowka Kopiuj ID wpisu do schowka Dodaj pliki do Galerii @@ -916,7 +919,7 @@ Szukaj i dodaj użytkownika Nick lub Login Brakująca konfiguracja LN - Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satsy + Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satosze Procentowo 25 Podziel zapsy z @@ -947,7 +950,7 @@ Mint dostarczył następujący komunikat błędu: %1$s Tokeny Cashu zostały już wydane. Cashu odebrano - %1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satsów) + %1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satoszy) W systemie nie znaleziono kompatybilnego portfela Cashu Nie można pobrać faktury z serwerów odbiorcy Twój dostawca połączenia z portfelem zwrócił następujący błąd: %1$s @@ -965,7 +968,7 @@ Nie znaleziono zwrotnego adresu URL z odpowiedzi %1$s Wystąpił błąd podczas analizowania JSON z pobierania faktury z Lightning Adresu. Sprawdź konfigurację lightning użytkownika Błąd przetwarzania pliku JSON z pobierania faktury %1$s. Sprawdź konfigurację lightning użytkownika - Nieprawidłowa kwota faktury (%1$s satsów) od %2$s. Powinieno być %3$s + Nieprawidłowa kwota faktury (%1$s satoszy) od %2$s. Powinieno być %3$s Nie można utworzyć faktury przed wysłaniem zapa. Portfel odbiorcy wysłał następujący błąd: %1$s Nie można utworzyć faktury. Wiadomość od %1$s: %2$s Nie można utworzyć faktury przed wysłaniem zapa. Element pr nie został znaleziony w powstałym JSON. @@ -992,7 +995,7 @@ iPhone 13 Stan Kategoria - Cena (w Satach) + Cena (w Satoszach) 1000 Lokalizacja Miasto, Województwo, Kraj @@ -1101,6 +1104,13 @@ Nowy wpis w społeczności Nowy produkt Nowy GEO-ekskluzywny Wpis + Nowy artykuł + Tytuł + Podsumowanie (opcjonalnie) + Adres URL miniaturki (opcjonalnie) + Napisz artykuł w formacie markdown… + Podgląd + Edytuj Otwórz wszystkie odzewy na ten post Zamknij wszystkie odzewy na ten post Odpowiedź @@ -1238,6 +1248,7 @@ OTS: %1$s Potwierdzenie znacznika czasu Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie. + Redaguj artykuł Edytuj wpis Propozycja ulepszenia wpisu Podsumowanie zmian @@ -1397,7 +1408,7 @@ %1$d/%2$d Przekaz Przekaz %1$s - Liczba przekazów: %1$d... + Liczba przekazów: %1$d… Wysłanych przekazów: %1$d Niektóre akcje nie powiodły się Wszystkie akcje udane @@ -1560,6 +1571,7 @@ wyszukaj, npub1…, alicja@domena.pl Obsługuje npub, nprofile, NIP-05, hex, i namecoin (.bit, d/, id/) Sprawdź listę obserwowanych + Porada Znaleziono %1$d kont(a) Wybrano: %1$d Rozwiązane przez Namecoin @@ -1616,7 +1628,7 @@ nowa %1$s brak wydarzeń Eksplorator Bitcoin (OTS) - wydarzenia + wydarzeń DMs profile ustawienia transmiterów @@ -1659,4 +1671,11 @@ Biegłość w sprawdzaniu typów: %1$s Certyfikat dla Żądanie certyfikatu do + Przedział czasu + Od + Do + Teraz + Cały czas + Ostatnia synchronizacja %1$s + Od ostatniej synchronizacji diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 810d910b2..e22277af2 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -290,6 +290,7 @@ Endereço Nostr nunca agora + segundos h m d @@ -536,7 +537,7 @@ Novo Adicionar autor à lista de seguidores Adicionar ou remover usuário de listas, ou criar uma nova lista com este usuário. - Ícone da lista %1$s + Ícone da lista %1$s é um membro público %1$s é um membro privado Adicionar como membro público @@ -1099,6 +1100,13 @@ Nova Nota da Comunidade Produto Novo Nova Postagem Geo-Exclusiva + Novo artigo + Título + Resumo (opcional) + URL da imagem de capa (opcional) + Escreva seu artigo em markdown… + Pré-visualização + Editar Abrir todas as reações a esta postagem Fechar todas as reações a esta postagem Responder @@ -1236,6 +1244,7 @@ OTS: %1$s Prova de Carimbo de data/hora Há prova de que esta postagem foi assinada antes de %1$s. A prova foi carimbada no blockchain do Bitcoin naquela data e hora. + Editar artigo Editar postagem Proposta para melhorar sua postagem Resumo das alterações @@ -1659,4 +1668,11 @@ Competente na verificação de tipos: %1$s Atesta Solicita atestação para + Intervalo de datas + De + Até + Agora + Todo o período + Última sincronização: %1$s + Desde a última sincronização diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 755b4832f..da5af6739 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -1419,7 +1419,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem %1$d/%2$d Oddajam Oddajam %1$s - Oddajam %1$d dogodkov... + Oddajam %1$d dogodkov… Poslano %1$d dogodkov Nekaterim dogodkom ni uspelo Vsem dogodkom je uspelo diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 185019205..777f8fa18 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -290,6 +290,7 @@ Nostr-adress aldrig nu + sekunder t m d @@ -536,7 +537,7 @@ Ny Lägg till författare i följelista Lägg till eller ta bort användare från listor, eller skapa en ny lista med denna användare. - Ikon för %1$s-lista + Ikon för lista %1$s är en offentlig medlem %1$s är en privat medlem Lägg till som offentlig medlem @@ -1098,6 +1099,13 @@ Nytt Community-meddelande Ny produkt Nytt Geo-Exklusivt inlägg + Ny artikel + Titel + Sammanfattning (valfritt) + URL för omslagsbild (valfritt) + Skriv din artikel i markdown… + Förhandsgranskning + Redigera Öppna alla reaktioner på detta inlägg Stäng alla reaktioner på detta inlägg Svara @@ -1235,6 +1243,7 @@ OTS: %1$s Tidsstämpel Bevis Det finns bevis på att detta inlägg signerades någon gång före %1$s. Beviset stämplades i Bitcoin-blockchainen vid det datumet och den tiden. + Redigera artikel Redigera inlägg Förslag till att förbättra ditt inlägg Sammanfattning av ändringar @@ -1658,4 +1667,11 @@ Kompetent att verifiera typer: %1$s Intygar Begär attestering till + Datumintervall + Från + Till + Nu + All tid + Senaste synkronisering: %1$s + Sedan senaste synkronisering diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 0d45efe82..38d929d10 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -290,6 +290,7 @@ Nostr 地址 从不 现在 + @@ -447,6 +448,8 @@ 打闪最高金额 共识 (0–100)% + 单选 + 多选 投票结束日期 & 时间 投票结束于 %1$s 后关闭 @@ -486,6 +489,9 @@ 接收方和公众不知道谁发送了付款 非打闪 Nostr 上没有痕迹,仅在闪电上 + 匿名 + 使用新的一次性身份发布。您的帐户将不会被链接到这个回复。 + 此回复将从新的匿名身份发布 文件服务器 选择上传文件时使用的服务器 闪电地址或 @User @@ -785,7 +791,7 @@ 偏好设置 用户首选项 翻译 - 反应 + 回应 设置 账户设置 应用程序设置 @@ -1104,6 +1110,13 @@ 新社区笔记 新产品 新建地理位置限定帖文 + 新文章 + 标题 + 摘要(选填) + 封面图片URL (可选) + 用 markdown 格式撰写文章… + 预览 + 编辑 展开对此帖子的所有回应 收起对此帖子的所有回应 回复 @@ -1111,8 +1124,8 @@ 点赞 打闪 修改快速回应 - 反应设置 - 配置显示的反应按钮、它们的顺序以及是否显示计数器。 + 回应设置 + 配置显示的回应按钮、按钮顺序及是否显示回应计数。 已启用 显示计数 调整顺序 @@ -1121,7 +1134,7 @@ Boost 转发或引用此笔记 点赞 - 用表情符号对此笔记进行反应 + 使用表情符号回应笔记 打闪 给作者发送 Lightning 网络付款 分享 @@ -1173,7 +1186,7 @@ 发件箱中继 设置您的公共发件箱中继来发布内容 创建专为接收您的内容而设计的中继列表对于您的Nostr体验至关重要,也是您的关注者找到您的唯一途径。 - 插入 1-3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入 + 插入 1–3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入 好的选项是:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social 收件箱中继 设置您的公共收件箱中继来接收通知 @@ -1241,6 +1254,7 @@ OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 + 编辑文章 编辑帖子 提议改进帖子 变动摘要 @@ -1532,7 +1546,7 @@ 私密中继 代理中继 公开消息 - 反应 + 回应 名片 中继认证 中继发现 @@ -1664,4 +1678,11 @@ 熟练验证类型:%1$s 证明 请求证明 + 日期范围 + + + 刚刚 + 全部时间 + 上次同步: %1$s + 自上次同步后 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bf05100bf..216f20f77 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -68,7 +68,8 @@ Signer not found Was the Signer app uninstalled? Check if the signer is installed and has this account. Log off and Log in again of the signer app has changed. - + Signer misbehaved + External signer returned a payload that is strange for the request. There might be a bug on either Amethyst or the Signer. Zaps View count @@ -309,6 +310,7 @@ LNURL… never now + seconds h m d @@ -482,6 +484,8 @@ Zap maximum Consensus (0–100)% + Single choice + Multiple choice Poll Closing Date & Time Poll closes in %1$s Close after @@ -538,6 +542,10 @@ No trace in Nostr, only in Lightning + Anonymous + Post as a new throwaway identity. Your account will not be linked to this reply. + This reply will be posted from a new anonymous identity + File Server Choose a server to upload this file to diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt index 2f39dcc55..c6a198378 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt @@ -27,9 +27,12 @@ import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20 import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.mac.FixedKey import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec @RunWith(AndroidJUnit4::class) class ChaCha20Benchmark { @@ -64,4 +67,38 @@ class ChaCha20Benchmark { chaCha.decrypt(padded, messageKeys.chachaNonce, messageKeys.chachaKey) } } + + @Test + fun encryptNative() { + benchmarkRule.measureRepeated { + encryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey) + } + } + + @Test + fun decryptNative() { + benchmarkRule.measureRepeated { + decryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey) + } + } + + fun encryptNative( + message: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + val cipher = Cipher.getInstance("ChaCha20") + cipher.init(Cipher.ENCRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce)) + return cipher.doFinal(message) + } + + fun decryptNative( + message: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + val cipher = Cipher.getInstance("ChaCha20") + cipher.init(Cipher.DECRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce)) + return cipher.doFinal(message) + } } diff --git a/build.gradle b/build.gradle index d1d34260f..e9bc0cb9b 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,6 @@ plugins { alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false alias(libs.plugins.serialization) - alias(libs.plugins.stability.analyzer) apply false } allprojects { diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index c55f9e409..72d905ec8 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -72,6 +72,11 @@ kotlin { // Compose Multiplatform Resources implementation(libs.jetbrains.compose.components.resources) + + // Markdown rendering (richtext-commonmark) + implementation(libs.markdown.commonmark) + implementation(libs.markdown.ui) + implementation(libs.markdown.ui.material3) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt new file mode 100644 index 000000000..7e978be46 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/ArticleHeader.kt @@ -0,0 +1,120 @@ +/* + * 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.article + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage + +@Composable +fun ArticleHeader( + title: String, + authorName: String?, + authorPicture: String?, + publishedAt: String?, + readingTimeMinutes: Int?, + bannerUrl: String?, + modifier: Modifier = Modifier, + onAuthorClick: (() -> Unit)? = null, +) { + Column(modifier = modifier.fillMaxWidth()) { + // Banner image + if (!bannerUrl.isNullOrBlank() && + (bannerUrl.startsWith("https://") || bannerUrl.startsWith("http://")) + ) { + AsyncImage( + model = bannerUrl, + contentDescription = "Article banner", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().height(300.dp), + ) + Spacer(Modifier.height(24.dp)) + } + + // Title + Text( + text = title, + style = + MaterialTheme.typography.headlineLarge.copy( + fontSize = 34.sp, + fontWeight = FontWeight.Bold, + lineHeight = 40.sp, + letterSpacing = (-0.5).sp, + ), + ) + + Spacer(Modifier.height(16.dp)) + + // Author + metadata row + Row(verticalAlignment = Alignment.CenterVertically) { + if (!authorPicture.isNullOrBlank() && + (authorPicture.startsWith("https://") || authorPicture.startsWith("http://")) + ) { + AsyncImage( + model = authorPicture, + contentDescription = "Author", + modifier = Modifier.size(40.dp).clip(CircleShape), + contentScale = ContentScale.Crop, + ) + Spacer(Modifier.width(12.dp)) + } + + Column { + if (!authorName.isNullOrBlank()) { + Text( + text = authorName, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + ) + } + + val metaParts = mutableListOf() + readingTimeMinutes?.let { metaParts.add("$it min read") } + publishedAt?.let { metaParts.add(it) } + + if (metaParts.isNotEmpty()) { + Text( + text = metaParts.joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(Modifier.height(24.dp)) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt new file mode 100644 index 000000000..292fb6729 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/article/TableOfContents.kt @@ -0,0 +1,143 @@ +/* + * 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.article + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val HEADING_REGEX = Regex("^(#{1,6})\\s+(.+)") +private val TRAILING_HASHES_REGEX = Regex("#+$") + +data class TocEntry( + val level: Int, + val text: String, + val index: Int, +) + +/** + * Extracts table of contents entries from markdown content. + * Parses ATX headings (# H1, ## H2, etc.), skipping code blocks. + */ +fun extractTableOfContents(markdown: String): List { + val entries = mutableListOf() + var inCodeBlock = false + var headingIndex = 0 + + markdown.lines().forEach { line -> + val trimmed = line.trim() + if (trimmed.startsWith("```")) { + inCodeBlock = !inCodeBlock + return@forEach + } + if (inCodeBlock) return@forEach + + val match = HEADING_REGEX.find(trimmed) + if (match != null) { + val level = match.groupValues[1].length + val text = + match.groupValues[2] + .trim() + .replace(TRAILING_HASHES_REGEX, "") + .trim() + if (text.isNotEmpty() && level <= 3) { + entries.add(TocEntry(level = level, text = text, index = headingIndex)) + } + headingIndex++ + } + } + return entries +} + +@Composable +fun TableOfContents( + entries: List, + activeEntryIndex: Int?, + onEntryClick: (TocEntry) -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + + Column( + modifier = + modifier + .width(240.dp) + .verticalScroll(scrollState) + .padding(vertical = 16.dp), + ) { + entries.forEach { entry -> + val isActive = entry.index == activeEntryIndex + val accentColor = MaterialTheme.colorScheme.primary + + Text( + text = entry.text, + style = + MaterialTheme.typography.bodySmall.copy( + fontSize = 13.sp, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + color = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .clickable { onEntryClick(entry) } + .padding( + start = ((entry.level - 1) * 16).dp, + top = 4.dp, + bottom = 4.dp, + end = 8.dp, + ).then( + if (isActive) { + Modifier.drawBehind { + drawLine( + color = accentColor, + start = Offset(0f, 0f), + end = Offset(0f, size.height), + strokeWidth = 3.dp.toPx(), + ) + } + } else { + Modifier + }, + ), + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownEditorState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownEditorState.kt new file mode 100644 index 000000000..7c69d6fdb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownEditorState.kt @@ -0,0 +1,363 @@ +/* + * 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.editor + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue + +/** + * State holder for a markdown editor with selection-aware formatting. + * + * Solves the focus/selection bug: toolbar buttons steal focus from TextField, + * collapsing the selection. We cache the last known selection on every value + * change, and toolbar operations use the cached selection. + */ +class MarkdownEditorState( + initial: String = "", +) { + var value by mutableStateOf(TextFieldValue(initial)) + private set + + /** Cached selection — updated on every onValueChange, survives focus loss. */ + var lastSelection: TextRange = TextRange.Zero + private set + + fun onValueChange(newValue: TextFieldValue) { + value = newValue + // Only cache non-zero selections (focus loss sends collapsed range) + if (newValue.selection.length > 0 || lastSelection == TextRange.Zero) { + lastSelection = newValue.selection + } + // Also cache cursor position when no selection + if (newValue.selection.collapsed) { + lastSelection = newValue.selection + } + } + + fun loadContent(content: String) { + value = TextFieldValue(content) + lastSelection = TextRange.Zero + } + + val text: String get() = value.text + + // --- Active state detection (uses current value.selection for display) --- + + val isBold: Boolean + get() = isWrapped("**", "**") + + val isItalic: Boolean + get() = isWrappedItalic() + + val isStrikethrough: Boolean + get() = isWrapped("~~", "~~") + + val isInlineCode: Boolean + get() = isWrapped("`", "`") + + val isBlockquote: Boolean + get() = isLinePrefix("> ") + + val isUnorderedList: Boolean + get() = isLinePrefix("- ") + + val isOrderedList: Boolean + get() { + val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1 + val line = text.substring(lineStart) + return line.matches(Regex("^\\d+\\.\\s.*")) + } + + val isTaskList: Boolean + get() = isLinePrefix("- [ ] ") || isLinePrefix("- [x] ") + + val headingLevel: Int? + get() { + val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1 + val line = text.substring(lineStart) + return when { + line.startsWith("### ") -> 3 + line.startsWith("## ") -> 2 + line.startsWith("# ") -> 1 + else -> null + } + } + + // --- Formatting operations (use lastSelection to survive focus loss) --- + + fun toggleBold() { + applyToggleWrap("**", "**") + } + + fun toggleItalic() { + applyToggleWrapItalic() + } + + fun toggleStrikethrough() { + applyToggleWrap("~~", "~~") + } + + fun toggleInlineCode() { + applyToggleWrap("`", "`") + } + + fun setHeading(level: Int?) { + val sel = lastSelection + val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1 + val line = text.substring(lineStart) + + // Remove existing heading prefix + val stripped = + when { + line.startsWith("### ") -> line.removePrefix("### ") + line.startsWith("## ") -> line.removePrefix("## ") + line.startsWith("# ") -> line.removePrefix("# ") + else -> line + } + val oldPrefixLen = + when { + line.startsWith("### ") -> 4 + line.startsWith("## ") -> 3 + line.startsWith("# ") -> 2 + else -> 0 + } + + val newPrefix = + when (level) { + 1 -> "# " + 2 -> "## " + 3 -> "### " + else -> "" + } + + val lineEnd = text.indexOf('\n', lineStart).let { if (it == -1) text.length else it } + val newText = text.substring(0, lineStart) + newPrefix + stripped + text.substring(lineEnd) + val shift = newPrefix.length - oldPrefixLen + + value = + TextFieldValue( + text = newText, + selection = TextRange((sel.min + shift).coerceAtLeast(lineStart), (sel.max + shift).coerceAtLeast(lineStart)), + ) + lastSelection = value.selection + } + + fun toggleBlockquote() { + applyToggleLinePrefix("> ") + } + + fun toggleUnorderedList() { + applyToggleLinePrefix("- ") + } + + fun toggleOrderedList() { + val sel = lastSelection + val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1 + val line = text.substring(lineStart) + + if (line.matches(Regex("^\\d+\\.\\s.*"))) { + // Remove ordered list prefix + val prefixEnd = line.indexOf(". ") + 2 + val newText = text.substring(0, lineStart) + line.substring(prefixEnd) + text.substring(lineStart + line.indexOf('\n').let { if (it == -1) line.length else it }) + value = + TextFieldValue( + text = text.substring(0, lineStart) + line.substring(prefixEnd), + selection = TextRange((sel.min - prefixEnd).coerceAtLeast(lineStart)), + ) + } else { + applyToggleLinePrefix("1. ") + } + lastSelection = value.selection + } + + fun toggleTaskList() { + val sel = lastSelection + val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1 + + when { + text.startsWith("- [ ] ", lineStart) -> { + // Remove task list prefix + val newText = text.substring(0, lineStart) + text.substring(lineStart + 6) + val shift = 6 + value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart))) + lastSelection = value.selection + } + + text.startsWith("- [x] ", lineStart) -> { + val newText = text.substring(0, lineStart) + text.substring(lineStart + 6) + val shift = 6 + value = TextFieldValue(text = newText, selection = TextRange((sel.min - shift).coerceAtLeast(lineStart))) + lastSelection = value.selection + } + + else -> { + applyToggleLinePrefix("- [ ] ") + } + } + } + + fun toggleCodeBlock() { + applyToggleWrap("```\n", "\n```") + } + + fun insertHorizontalRule() { + val sel = lastSelection + val insert = "\n---\n" + val newText = text.substring(0, sel.min) + insert + text.substring(sel.max) + value = TextFieldValue(text = newText, selection = TextRange(sel.min + insert.length)) + lastSelection = value.selection + } + + fun insertLink() { + val sel = lastSelection + val selected = text.substring(sel.min, sel.max) + + if (selected.isNotEmpty()) { + val newText = text.substring(0, sel.min) + "[$selected](url)" + text.substring(sel.max) + val urlStart = sel.min + selected.length + 3 + value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3)) + } else { + val newText = text.substring(0, sel.min) + "[](url)" + text.substring(sel.min) + value = TextFieldValue(text = newText, selection = TextRange(sel.min + 1)) + } + lastSelection = value.selection + } + + fun insertImage() { + val sel = lastSelection + val selected = text.substring(sel.min, sel.max) + + if (selected.isNotEmpty()) { + val newText = text.substring(0, sel.min) + "![$selected](url)" + text.substring(sel.max) + val urlStart = sel.min + selected.length + 4 + value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3)) + } else { + val newText = text.substring(0, sel.min) + "![alt](url)" + text.substring(sel.min) + val urlStart = sel.min + 7 + value = TextFieldValue(text = newText, selection = TextRange(urlStart, urlStart + 3)) + } + lastSelection = value.selection + } + + // --- Private helpers --- + + private fun isWrapped( + prefix: String, + suffix: String, + ): Boolean { + val sel = value.selection + val start = sel.min + val end = sel.max + return start >= prefix.length && + end + suffix.length <= text.length && + text.substring(start - prefix.length, start) == prefix && + text.substring(end, end + suffix.length) == suffix + } + + private fun isWrappedItalic(): Boolean { + val sel = value.selection + val start = sel.min + val end = sel.max + if (start < 1 || end + 1 > text.length) return false + if (text[start - 1] != '*' || text[end] != '*') return false + val hasBoldBefore = start >= 2 && text[start - 2] == '*' + val hasBoldAfter = end + 1 < text.length && text[end + 1] == '*' + return !hasBoldBefore && !hasBoldAfter + } + + private fun isLinePrefix(prefix: String): Boolean { + val lineStart = text.lastIndexOf('\n', value.selection.min - 1) + 1 + return text.startsWith(prefix, lineStart) + } + + private fun applyToggleWrap( + prefix: String, + suffix: String, + ) { + val sel = lastSelection + val start = sel.min + val end = sel.max + + val wrapped = + start >= prefix.length && + end + suffix.length <= text.length && + text.substring(start - prefix.length, start) == prefix && + text.substring(end, end + suffix.length) == suffix + + value = + if (wrapped) { + val newText = + text.substring(0, start - prefix.length) + + text.substring(start, end) + + text.substring(end + suffix.length) + TextFieldValue(newText, TextRange(start - prefix.length, end - prefix.length)) + } else if (start == end) { + val newText = text.substring(0, start) + prefix + suffix + text.substring(start) + TextFieldValue(newText, TextRange(start + prefix.length)) + } else { + val newText = text.substring(0, start) + prefix + text.substring(start, end) + suffix + text.substring(end) + TextFieldValue(newText, TextRange(start + prefix.length, end + prefix.length)) + } + lastSelection = value.selection + } + + private fun applyToggleWrapItalic() { + val sel = lastSelection + val start = sel.min + val end = sel.max + + val isItalic = + start >= 1 && + end + 1 <= text.length && + text[start - 1] == '*' && + text[end] == '*' && + !(start >= 2 && text[start - 2] == '*') && + !(end + 1 < text.length && text[end + 1] == '*') + + if (isItalic) { + val newText = text.substring(0, start - 1) + text.substring(start, end) + text.substring(end + 1) + value = TextFieldValue(newText, TextRange(start - 1, end - 1)) + } else { + applyToggleWrap("*", "*") + return + } + lastSelection = value.selection + } + + private fun applyToggleLinePrefix(prefix: String) { + val sel = lastSelection + val lineStart = text.lastIndexOf('\n', sel.min - 1) + 1 + + value = + if (text.startsWith(prefix, lineStart)) { + val newText = text.substring(0, lineStart) + text.substring(lineStart + prefix.length) + val shift = prefix.length + TextFieldValue(newText, TextRange((sel.min - shift).coerceAtLeast(lineStart), (sel.max - shift).coerceAtLeast(lineStart))) + } else { + val newText = text.substring(0, lineStart) + prefix + text.substring(lineStart) + TextFieldValue(newText, TextRange(sel.min + prefix.length, sel.max + prefix.length)) + } + lastSelection = value.selection + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt new file mode 100644 index 000000000..4709625b1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MarkdownToolbar.kt @@ -0,0 +1,184 @@ +/* + * 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.editor + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.FormatBold +import androidx.compose.material.icons.filled.FormatItalic +import androidx.compose.material.icons.filled.FormatListBulleted +import androidx.compose.material.icons.filled.FormatListNumbered +import androidx.compose.material.icons.filled.FormatQuote +import androidx.compose.material.icons.filled.FormatStrikethrough +import androidx.compose.material.icons.filled.HorizontalRule +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Link +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Markdown toolbar with Material icons, grouped formatting buttons, and active state. + * Uses [MarkdownEditorState] for selection-aware toggle behavior. + * + * Buttons use `focusProperties { canFocus = false }` to prevent stealing focus + * from the editor TextField, preserving the user's text selection. + */ +@Composable +fun MarkdownToolbar( + state: MarkdownEditorState, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // --- Headings --- + ToolbarButton(label = "H1", active = state.headingLevel == 1) { state.setHeading(if (state.headingLevel == 1) null else 1) } + ToolbarButton(label = "H2", active = state.headingLevel == 2) { state.setHeading(if (state.headingLevel == 2) null else 2) } + ToolbarButton(label = "H3", active = state.headingLevel == 3) { state.setHeading(if (state.headingLevel == 3) null else 3) } + + Separator() + + // --- Inline formatting --- + ToolbarIconButton(Icons.Default.FormatBold, "Bold", state.isBold) { state.toggleBold() } + ToolbarIconButton(Icons.Default.FormatItalic, "Italic", state.isItalic) { state.toggleItalic() } + ToolbarIconButton(Icons.Default.FormatStrikethrough, "Strikethrough", state.isStrikethrough) { state.toggleStrikethrough() } + ToolbarIconButton(Icons.Default.Code, "Inline code", state.isInlineCode) { state.toggleInlineCode() } + + Separator() + + // --- Lists --- + ToolbarIconButton(Icons.Default.FormatListBulleted, "Bullet list", state.isUnorderedList) { state.toggleUnorderedList() } + ToolbarIconButton(Icons.Default.FormatListNumbered, "Numbered list", state.isOrderedList) { state.toggleOrderedList() } + ToolbarIconButton(Icons.Default.Checklist, "Task list", state.isTaskList) { state.toggleTaskList() } + + Separator() + + // --- Block elements --- + ToolbarIconButton(Icons.Default.FormatQuote, "Blockquote", state.isBlockquote) { state.toggleBlockquote() } + ToolbarButton(label = "```", active = false) { state.toggleCodeBlock() } + ToolbarIconButton(Icons.Default.HorizontalRule, "Horizontal rule", false) { state.insertHorizontalRule() } + + Separator() + + // --- Insert --- + ToolbarIconButton(Icons.Default.Link, "Link", false) { state.insertLink() } + ToolbarIconButton(Icons.Default.Image, "Image", false) { state.insertImage() } + } +} + +@Composable +private fun Separator() { + VerticalDivider( + modifier = Modifier.height(24.dp).padding(horizontal = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) +} + +@Composable +private fun ToolbarIconButton( + icon: ImageVector, + contentDescription: String, + active: Boolean, + onClick: () -> Unit, +) { + val containerColor = + if (active) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val contentColor = + if (active) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + SmallFloatingActionButton( + onClick = onClick, + containerColor = containerColor, + contentColor = contentColor, + modifier = + Modifier + .size(32.dp) + .focusProperties { canFocus = false }, + ) { + Icon( + icon, + contentDescription = contentDescription, + modifier = Modifier.size(18.dp), + ) + } +} + +@Composable +private fun ToolbarButton( + label: String, + active: Boolean, + onClick: () -> Unit, +) { + val containerColor = + if (active) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val contentColor = + if (active) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + SmallFloatingActionButton( + onClick = onClick, + containerColor = containerColor, + contentColor = contentColor, + modifier = + Modifier + .size(32.dp) + .focusProperties { canFocus = false }, + ) { + Text( + text = label, + fontSize = 11.sp, + color = contentColor, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt new file mode 100644 index 000000000..28a22e080 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/editor/MetadataPanel.kt @@ -0,0 +1,160 @@ +/* + * 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.editor + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.unit.dp + +/** + * Form fields for article metadata: title, summary, banner, tags, slug. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MetadataPanel( + title: String, + onTitleChange: (String) -> Unit, + summary: String, + onSummaryChange: (String) -> Unit, + bannerUrl: String, + onBannerUrlChange: (String) -> Unit, + tags: List, + onTagsChange: (List) -> Unit, + slug: String, + onSlugChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var tagInput by remember { mutableStateOf("") } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = title, + onValueChange = { if (it.length <= 256) onTitleChange(it) }, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + supportingText = { Text("${title.length}/256") }, + ) + + OutlinedTextField( + value = summary, + onValueChange = { if (it.length <= 1024) onSummaryChange(it) }, + label = { Text("Summary") }, + maxLines = 3, + modifier = Modifier.fillMaxWidth(), + supportingText = { Text("${summary.length}/1024") }, + ) + + OutlinedTextField( + value = bannerUrl, + onValueChange = onBannerUrlChange, + label = { Text("Banner Image URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // Tags chip input + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = tagInput, + onValueChange = { tagInput = it }, + label = { Text("Add tag (Enter to add)") }, + singleLine = true, + modifier = + Modifier.weight(1f).onKeyEvent { event -> + if (event.key == Key.Enter && tagInput.isNotBlank()) { + val newTag = tagInput.trim().lowercase() + if (newTag !in tags) { + onTagsChange(tags + newTag) + } + tagInput = "" + true + } else { + false + } + }, + ) + } + + if (tags.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) { + tags.forEach { tag -> + AssistChip( + onClick = { onTagsChange(tags - tag) }, + label = { Text(tag) }, + trailingIcon = { + Icon( + Icons.Default.Close, + contentDescription = "Remove $tag", + modifier = Modifier.size(16.dp), + ) + }, + ) + } + } + } + } + + OutlinedTextField( + value = slug, + onValueChange = onSlugChange, + label = { Text("Slug (d-tag)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + supportingText = { Text("Used as the unique identifier for this article") }, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt new file mode 100644 index 000000000..c31109d4a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/markdown/RenderMarkdown.kt @@ -0,0 +1,116 @@ +/* + * 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.markdown + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.unit.Density +import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions +import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser +import com.halilibo.richtext.markdown.BasicMarkdown +import com.halilibo.richtext.ui.RichTextStyle +import com.halilibo.richtext.ui.material3.RichText + +private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning", "highlight") + +/** + * Escapes markdown special characters inside highlighted text so it doesn't + * break the markdown parser when wrapped in a link. + */ +private fun escapeMarkdownInLink(text: String): String = + text + .replace("[", "\\[") + .replace("]", "\\]") + .replace("(", "\\(") + .replace(")", "\\)") + +@Composable +fun RenderMarkdown( + content: String, + onLinkClick: (String) -> Unit, + modifier: Modifier = Modifier, + fontScale: Float = 1.0f, + highlightedTexts: List = emptyList(), +) { + val processedContent = + remember(content, highlightedTexts) { + if (highlightedTexts.isEmpty()) { + content + } else { + var result = content + highlightedTexts.sortedByDescending { it.length }.forEachIndexed { index, text -> + val idx = result.indexOf(text) + if (idx >= 0) { + val escaped = escapeMarkdownInLink(text) + result = result.replaceFirst(text, "[$escaped](highlight://$index)") + } + } + result + } + } + + val astNode = + remember(processedContent) { + CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(processedContent) + } + + val uriHandler = + remember(onLinkClick) { + object : UriHandler { + override fun openUri(uri: String) { + val scheme = uri.substringBefore(":").lowercase() + if (scheme in ALLOWED_SCHEMES) { + onLinkClick(uri) + } + } + } + } + + val currentDensity = LocalDensity.current + val scaledDensity = + remember(fontScale, currentDensity) { + if (fontScale == 1.0f) { + currentDensity + } else { + Density( + density = currentDensity.density * fontScale, + fontScale = currentDensity.fontScale, + ) + } + } + + CompositionLocalProvider( + LocalUriHandler provides uriHandler, + LocalDensity provides scaledDensity, + ) { + RichText( + modifier = modifier, + style = RichTextStyle(), + ) { + BasicMarkdown(astNode) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 98e327fe4..4edd15882 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -90,8 +90,7 @@ class AddressableNote( override fun address() = address override fun createdAt(): Long? { - val currentEvent = event - if (currentEvent == null) return null + val currentEvent = event ?: return null if (currentEvent is PublishedAtProvider) return currentEvent.publishedAt() ?: currentEvent.createdAt return currentEvent.createdAt } @@ -204,7 +203,7 @@ open class Note( is IsInPublicChatChannel -> { inGatherers?.forEach { - if (it is com.vitorpamplona.amethyst.commons.model.Channel) { + if (it is Channel) { it.relays().firstOrNull()?.let { return it } } } @@ -216,7 +215,7 @@ open class Note( is LiveActivitiesChatMessageEvent -> { inGatherers?.forEach { - if (it is com.vitorpamplona.amethyst.commons.model.Channel) { + if (it is Channel) { it.relays().firstOrNull()?.let { return it } } } @@ -230,14 +229,14 @@ open class Note( val currentOutbox = author?.outboxRelays()?.toSet() return if (relays.isNotEmpty()) { - if (currentOutbox != null && currentOutbox.isNotEmpty()) { + if (!currentOutbox.isNullOrEmpty()) { val relayMatchesOutbox = relays.firstOrNull { it in currentOutbox } if (relayMatchesOutbox != null) { return relayMatchesOutbox } } - return relays.firstOrNull() + relays.firstOrNull() } else { currentOutbox?.firstOrNull() ?: author?.mostUsedNonLocalRelay() } @@ -313,14 +312,14 @@ open class Note( zapPayments.keys + zapPayments.values.filterNotNull() - replies = listOf() - reactions = mapOf>() - boosts = listOf() - reports = mapOf>() - zaps = mapOf() - zapPayments = mapOf() + replies = listOf() + reactions = mapOf() + boosts = listOf() + reports = mapOf() + zaps = mapOf() + zapPayments = mapOf() zapsAmount = BigDecimal.ZERO - relays = listOf() + relays = listOf() if (repliesChanged) flowSet?.replies?.invalidateData() if (reactionsChanged) flowSet?.reactions?.invalidateData() @@ -990,11 +989,11 @@ class NoteState( fun List.eventIdSet() = mapNotNullTo(mutableSetOf()) { it.event?.id } -fun Array.events() = mapNotNull { it.note.event as? T } +inline fun Array.events() = mapNotNull { it.note.event as? T } -fun List.events() = mapNotNull { it.event as? T } +inline fun List.events() = mapNotNull { it.event as? T } -fun List.updateFlow(): Flow> = +inline fun List.updateFlow(): Flow> = if (this.isEmpty()) { MutableStateFlow(emptyList()) } else { @@ -1005,7 +1004,7 @@ fun List.updateFlow(): Flow> = } } -public inline fun Iterable.anyEvent(predicate: (T) -> Boolean): Boolean { +inline fun Iterable.anyEvent(predicate: (T) -> Boolean): Boolean { if (this is Collection && isEmpty()) return false for (note in this) { val noteEvent = note.event as? T @@ -1014,7 +1013,7 @@ public inline fun Iterable.anyEvent(predicate: (T) -> Boolean): Boolea return false } -public inline fun Iterable.filterEvents(predicate: (T) -> Boolean): List { +inline fun Iterable.filterEvents(predicate: (T) -> Boolean): List { if (this is Collection && isEmpty()) return emptyList() val dest = ArrayList() @@ -1027,7 +1026,7 @@ public inline fun Iterable.filterEvents(predicate: (T) -> Boolean): Li return dest } -public fun Iterable.filterAuthoredEvents(pubkey: HexKey): List { +inline fun Iterable.filterAuthoredEvents(pubkey: HexKey): List { if (this is Collection && isEmpty()) return emptyList() val dest = ArrayList() @@ -1042,7 +1041,7 @@ public fun Iterable.filterAuthoredEvents(pubkey: HexKey): List { return dest } -public inline fun Iterable.anyNotNullEvent(predicate: (Event) -> Boolean): Boolean { +inline fun Iterable.anyNotNullEvent(predicate: (Event) -> Boolean): Boolean { if (this is Collection && isEmpty()) return false for (note in this) { val noteEvent = note.event @@ -1051,7 +1050,7 @@ public inline fun Iterable.anyNotNullEvent(predicate: (Event) -> Boolean): return false } -fun List.latestByAuthor(): Map { +inline fun List.latestByAuthor(): Map { val oneResponsePerUser = mutableMapOf() forEach { note -> diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Urls.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt similarity index 77% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Urls.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt index fe9785bb2..4e1d86628 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Urls.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightData.kt @@ -18,6 +18,15 @@ * 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.utils +package com.vitorpamplona.amethyst.commons.model.highlights -expect fun fastFindURLs(text: String): List +data class HighlightData( + val id: String, + val text: String, + val note: String? = null, + val articleAddressTag: String, + val articleTitle: String? = null, + val createdAt: Long, + val published: Boolean = false, + val eventId: String? = null, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt new file mode 100644 index 000000000..d9c151b01 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip23LongContent + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Shared action for publishing long-form content (NIP-23 kind 30023). + * Handles title, summary, image, tags, and d-tag for addressable events. + */ +object LongFormPublishAction { + private const val MAX_CONTENT_BYTES = 100_000 + + /** + * Publishes a long-form text note (NIP-23 kind 30023). + * + * @param title The article title + * @param content The markdown body content + * @param summary Optional article summary + * @param image Optional banner image URL + * @param tags List of hashtag topics + * @param dTag Unique identifier for this addressable event (slug) + * @param signer The NostrSigner to sign the event + * @return Signed LongTextNoteEvent ready to broadcast + * @throws IllegalStateException if signer is not writeable + */ + suspend fun publish( + title: String, + content: String, + summary: String?, + image: String?, + tags: List, + dTag: String, + signer: NostrSigner, + ): LongTextNoteEvent { + if (!signer.isWriteable()) { + throw IllegalStateException("Cannot publish: signer is not writeable") + } + + if (content.toByteArray().size > MAX_CONTENT_BYTES) { + throw IllegalArgumentException("Content exceeds maximum size of $MAX_CONTENT_BYTES bytes") + } + + val template = + LongTextNoteEvent.build( + description = content, + title = title, + summary = summary, + image = image, + publishedAt = TimeUtils.now(), + dTag = dTag, + ) { + tags.forEach { hashtag(it) } + } + + return signer.sign(template) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt new file mode 100644 index 000000000..b326186bd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/ReadingTimeCalculator.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip23LongContent + +import kotlin.math.ceil +import kotlin.math.max + +/** + * Calculates estimated reading time for markdown content. + * Uses 238 WPM for prose (Brysbaert 2019), 80 WPM for code blocks, + * and Medium's image decay formula (12 sec first, -1 each, min 3). + */ +object ReadingTimeCalculator { + private const val PROSE_WPM = 238.0 + private const val CODE_WPM = 80.0 + + private val WHITESPACE_REGEX = "\\s+".toRegex() + private val IMAGE_REGEX = Regex("!\\[.*?]\\(.*?\\)") + private val IMAGE_STRIP_REGEX = Regex("!\\[.*?]\\(.*?\\)") + private val LINK_REGEX = Regex("\\[([^]]*)]\\([^)]*\\)") + private val FORMATTING_REGEX = Regex("[*_~`#>]") + private val LIST_MARKER_REGEX = Regex("^-\\s+|^\\d+\\.\\s+") + private val HORIZONTAL_RULE_REGEX = Regex("^---+$|^\\*\\*\\*+$") + + fun calculate(markdownContent: String): Int { + var proseWords = 0 + var codeWords = 0 + var imageCount = 0 + var inCodeBlock = false + + markdownContent.lines().forEach { line -> + val trimmed = line.trim() + + if (trimmed.startsWith("```")) { + inCodeBlock = !inCodeBlock + return@forEach + } + + if (inCodeBlock) { + codeWords += trimmed.split(WHITESPACE_REGEX).count { it.isNotBlank() } + return@forEach + } + + // Count images + val imageMatches = IMAGE_REGEX.findAll(trimmed) + imageCount += imageMatches.count() + + // Strip markdown syntax for word counting + val stripped = + trimmed + .replace(IMAGE_STRIP_REGEX, "") // images + .replace(LINK_REGEX, "$1") // links -> text only + .replace(FORMATTING_REGEX, "") // formatting + .replace(LIST_MARKER_REGEX, "") // list markers + .replace(HORIZONTAL_RULE_REGEX, "") // horizontal rules + + proseWords += stripped.split(WHITESPACE_REGEX).count { it.isNotBlank() } + } + + // Medium's image time decay: 12 sec first, -1 each, min 3 + val imageSeconds = (0 until imageCount).sumOf { max(12 - it, 3) } + + val totalMinutes = (proseWords / PROSE_WPM) + (codeWords / CODE_WPM) + (imageSeconds / 60.0) + return max(1, ceil(totalMinutes).toInt()) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt index 9c6c6007b..6356b2e37 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt @@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.startsWithAny import com.vitorpamplona.quartz.utils.urldetector.Url import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector +import kotlinx.coroutines.CancellationException @Stable class Urls( @@ -77,30 +79,35 @@ class UrlParser { val blossom = mutableSetOf() urls.forEach { url -> - if (url.isValidTopLevelDomain()) { - if (url.wroteWithSchema()) { - if (url.originalUrl.startsWithAny(httpScheme)) { - // quick exit - completeUrls.add(url.originalUrl) - } else if (url.originalUrl.startsWithAny(nostrScheme)) { - bech32.add(url.originalUrl) - } else if (url.originalUrl.startsWithAny(websocketScheme)) { - relays.add(url.originalUrl) - } else if (url.originalUrl.startsWithAny(blossomScheme)) { - blossom.add(url.originalUrl) - } else { - completeUrls.add(url.originalUrl) - } - } else { - // emails are understood as urls from the detector. - if (url.isEmail()) { - Patterns.EMAIL_ADDRESS.findAll(url.originalUrl).forEach { - emails.add(it.value) + try { + if (url.isValidTopLevelDomain()) { + if (url.wroteWithSchema()) { + if (url.originalUrl.startsWithAny(httpScheme)) { + // quick exit + completeUrls.add(url.originalUrl) + } else if (url.originalUrl.startsWithAny(nostrScheme)) { + bech32.add(url.originalUrl) + } else if (url.originalUrl.startsWithAny(websocketScheme)) { + relays.add(url.originalUrl) + } else if (url.originalUrl.startsWithAny(blossomScheme)) { + blossom.add(url.originalUrl) + } else { + completeUrls.add(url.originalUrl) } } else { - urlsWithoutScheme.add(url.originalUrl) + // emails are understood as urls from the detector. + if (url.isEmail()) { + Patterns.EMAIL_ADDRESS.findAll(url.originalUrl).forEach { + emails.add(it.value) + } + } else { + urlsWithoutScheme.add(url.originalUrl) + } } } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("UrlParser", "Trying to parse url `${url.originalUrl}` from `$content`", e) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt index b9613c5cd..600c0f27f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt @@ -281,6 +281,18 @@ class UrlParserTest { Urls(withScheme = emptySet()), ) + /** + * Regression test for PR #1907: parsing a note whose content is only the Japanese phrase + * "今北産業" (a common internet abbreviation) must not throw a StringIndexOutOfBoundsException + * from Url.getPart() and must produce no detected URLs. + */ + @Test + fun testImakitaSangyo() = + test( + "今北産業", + Urls(), + ) + @Test fun testHour() = test( diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index f17e8090e..eab43327e 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -63,7 +63,7 @@ dependencies { implementation(libs.androidx.collection) // SLF4J no-op — silence "No SLF4J providers" warnings from transitive deps - implementation("org.slf4j:slf4j-nop:2.0.16") + implementation(libs.slf4j.nop) // QR code generation (ZXing core) implementation(libs.zxing) @@ -115,3 +115,7 @@ vlcSetup { pathToCopyVlcMacosFilesTo.set(file("src/jvmMain/appResources/macos/vlc")) pathToCopyVlcWindowsFilesTo.set(file("src/jvmMain/appResources/windows/vlc")) } + +tasks.named("spotlessKotlin") { + inputs.files(tasks.named("vlcSetup")) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 3d106a9e4..dcc369138 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DefaultRelays import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator @@ -143,6 +144,16 @@ sealed class DesktopScreen { val noteId: String, ) : DesktopScreen() + data class Article( + val addressTag: String, + ) : DesktopScreen() + + data class Editor( + val draftSlug: String? = null, + ) : DesktopScreen() + + data object Drafts : DesktopScreen() + data object Settings : DesktopScreen() } @@ -369,6 +380,8 @@ fun main() { Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) }) Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) }) Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) }) + Item("Drafts", onClick = { deckState.addColumn(DeckColumnType.Drafts) }) + Item("Highlights", onClick = { deckState.addColumn(DeckColumnType.MyHighlights) }) Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) }) Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) }) Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) @@ -597,6 +610,13 @@ fun MainContent( DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope) } + val highlightStore = remember { DesktopHighlightStore(appScope) } + val draftStore = + remember { + com.vitorpamplona.amethyst.desktop.service.drafts + .DesktopDraftStore(appScope) + } + // Subscribe to incoming DMs and process into chatroomList LaunchedEffect(account) { relayManager.connectedRelays.first { it.isNotEmpty() } @@ -716,6 +736,8 @@ fun MainContent( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, @@ -753,6 +775,8 @@ fun MainContent( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt new file mode 100644 index 000000000..ac0dbaade --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt @@ -0,0 +1,289 @@ +/* + * 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.desktop.service.drafts + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermission +import java.time.Instant + +data class DraftMetadata( + val title: String = "", + val summary: String? = null, + val image: String? = null, + val tags: List = emptyList(), + val createdAt: String = Instant.now().toString(), + val updatedAt: String = Instant.now().toString(), + val published: Boolean = false, +) + +data class DraftEntry( + val slug: String, + val metadata: DraftMetadata, +) + +/** + * Local draft storage for long-form articles. + * Stores markdown content as .md files and metadata in index.json. + * Uses atomic writes and restrictive file permissions. + */ +class DesktopDraftStore( + private val scope: CoroutineScope, +) { + private val mapper = jacksonObjectMapper() + private val mutex = Mutex() + private var cachedIndex: MutableMap? = null + + private val _drafts = MutableStateFlow>(emptyList()) + val drafts: StateFlow> = _drafts.asStateFlow() + + private val draftsDir: File by lazy { + val dir = File(System.getProperty("user.home"), ".amethyst/drafts") + if (!dir.exists()) { + dir.mkdirs() + setDirPermissions(dir) + } + dir + } + + private val indexFile: File get() = File(draftsDir, "index.json") + + init { + scope.launch(Dispatchers.IO) { + cachedIndex = null + loadIndex() + } + } + + /** + * Sanitizes a slug to prevent path traversal and ensure safe filenames. + */ + private fun sanitizeSlug(slug: String): String { + val sanitized = + slug + .replace("/", "") + .replace("\\", "") + .replace("\u0000", "") + .trim() + .lowercase() + .replace(Regex("[^a-z0-9_-]"), "-") + .replace(Regex("-+"), "-") + .trimStart('-') + .trimEnd('-') + .take(128) + + require(sanitized.isNotEmpty()) { "Slug cannot be empty after sanitization" } + + // Validate canonical path stays within drafts dir + val resolved = File(draftsDir, "$sanitized.md").canonicalPath + require(resolved.startsWith(draftsDir.canonicalPath)) { + "Slug resolves outside drafts directory" + } + + return sanitized + } + + /** + * Generates a slug from a title. Falls back to timestamp if title is blank. + */ + fun slugFromTitle(title: String): String { + if (title.isBlank()) return "untitled-${Instant.now().epochSecond}" + return sanitizeSlug(title) + } + + /** + * Saves or updates a draft. Creates content file and updates index atomically. + */ + suspend fun saveDraft( + slug: String, + content: String, + metadata: DraftMetadata, + ) { + val safeSlug = sanitizeSlug(slug) + + mutex.withLock { + // Write content file atomically + val contentFile = File(draftsDir, "$safeSlug.md") + atomicWrite(contentFile, content) + + // Update index + val index = loadIndexMap() + index[safeSlug] = metadata.copy(updatedAt = Instant.now().toString()) + atomicWriteIndex(index) + cachedIndex = index + + // Refresh state + _drafts.value = + index.entries + .map { DraftEntry(it.key, it.value) } + .sortedByDescending { it.metadata.updatedAt } + } + } + + /** + * Loads a draft's content by slug. + */ + suspend fun loadContent(slug: String): String? { + val safeSlug = sanitizeSlug(slug) + val file = File(draftsDir, "$safeSlug.md") + return if (file.exists()) file.readText() else null + } + + /** + * Loads a draft's metadata by slug. + */ + suspend fun loadMetadata(slug: String): DraftMetadata? { + val safeSlug = sanitizeSlug(slug) + return mutex.withLock { + loadIndexMap()[safeSlug] + } + } + + /** + * Deletes a draft by slug. + */ + suspend fun deleteDraft(slug: String) { + val safeSlug = sanitizeSlug(slug) + mutex.withLock { + File(draftsDir, "$safeSlug.md").delete() + + val index = loadIndexMap() + index.remove(safeSlug) + atomicWriteIndex(index) + cachedIndex = index + + _drafts.value = + index.entries + .map { DraftEntry(it.key, it.value) } + .sortedByDescending { it.metadata.updatedAt } + } + } + + /** + * Marks a draft as published. + */ + suspend fun markPublished(slug: String) { + val safeSlug = sanitizeSlug(slug) + mutex.withLock { + val index = loadIndexMap() + val existing = index[safeSlug] ?: return + index[safeSlug] = existing.copy(published = true, updatedAt = Instant.now().toString()) + atomicWriteIndex(index) + cachedIndex = index + + _drafts.value = + index.entries + .map { DraftEntry(it.key, it.value) } + .sortedByDescending { it.metadata.updatedAt } + } + } + + private fun loadIndexMap(): MutableMap { + cachedIndex?.let { return it } + val loaded: MutableMap = + if (!indexFile.exists()) { + mutableMapOf() + } else { + try { + mapper.readValue>(indexFile) + } catch (e: Exception) { + System.err.println("Failed to read drafts index: ${e.message}") + mutableMapOf() + } + } + cachedIndex = loaded + return loaded + } + + private suspend fun loadIndex() { + mutex.withLock { + _drafts.value = + loadIndexMap() + .entries + .map { DraftEntry(it.key, it.value) } + .sortedByDescending { it.metadata.updatedAt } + } + } + + private fun atomicWrite( + file: File, + content: String, + ) { + val tempFile = File(file.parentFile, "${file.name}.tmp") + try { + tempFile.writeText(content) + setFilePermissions(tempFile) + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } finally { + if (tempFile.exists()) tempFile.delete() + } + } + + private fun atomicWriteIndex(index: Map) { + val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(index) + atomicWrite(indexFile, json) + } + + private fun setDirPermissions(dir: File) { + try { + Files.setPosixFilePermissions( + dir.toPath(), + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ), + ) + } catch (_: UnsupportedOperationException) { + // Windows + } + } + + private fun setFilePermissions(file: File) { + try { + Files.setPosixFilePermissions( + file.toPath(), + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + ), + ) + } catch (_: UnsupportedOperationException) { + // Windows + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt new file mode 100644 index 000000000..16f238ffd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/highlights/DesktopHighlightStore.kt @@ -0,0 +1,193 @@ +/* + * 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.desktop.service.highlights + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.time.Instant +import java.util.UUID + +/** + * Local highlight storage for article annotations. + * Stores highlights as JSON in ~/.amethyst/highlights/index.json. + * Uses atomic writes following the same pattern as DesktopDraftStore. + */ +class DesktopHighlightStore( + private val scope: CoroutineScope, +) { + private val mapper = jacksonObjectMapper() + private val mutex = Mutex() + + private val _highlights = MutableStateFlow>>(emptyMap()) + val highlights: StateFlow>> = _highlights.asStateFlow() + + private val highlightsDir: File by lazy { + val dir = File(System.getProperty("user.home"), ".amethyst/highlights") + if (!dir.exists()) { + dir.mkdirs() + } + dir + } + + private val indexFile: File get() = File(highlightsDir, "index.json") + + init { + scope.launch(Dispatchers.IO) { + loadIndex() + } + } + + suspend fun addHighlight( + articleAddressTag: String, + text: String, + note: String?, + articleTitle: String?, + ) { + mutex.withLock { + val current = _highlights.value.toMutableMap() + val articleHighlights = current.getOrDefault(articleAddressTag, emptyList()).toMutableList() + + // Avoid duplicate highlights of the same text + if (articleHighlights.any { it.text == text }) return + + articleHighlights.add( + HighlightData( + id = UUID.randomUUID().toString(), + text = text, + note = note, + articleAddressTag = articleAddressTag, + articleTitle = articleTitle, + createdAt = Instant.now().epochSecond, + ), + ) + current[articleAddressTag] = articleHighlights + _highlights.value = current + saveIndex(current) + } + } + + suspend fun updateNote( + highlightId: String, + note: String, + ) { + mutex.withLock { + val current = _highlights.value.toMutableMap() + for ((key, list) in current) { + val idx = list.indexOfFirst { it.id == highlightId } + if (idx >= 0) { + current[key] = + list.toMutableList().apply { + set(idx, get(idx).copy(note = note)) + } + _highlights.value = current + saveIndex(current) + return + } + } + } + } + + suspend fun removeHighlight(highlightId: String) { + mutex.withLock { + val current = _highlights.value.toMutableMap() + for ((key, list) in current) { + val filtered = list.filter { it.id != highlightId } + if (filtered.size != list.size) { + if (filtered.isEmpty()) { + current.remove(key) + } else { + current[key] = filtered + } + _highlights.value = current + saveIndex(current) + return + } + } + } + } + + suspend fun markPublished( + highlightId: String, + eventId: String, + ) { + mutex.withLock { + val current = _highlights.value.toMutableMap() + for ((key, list) in current) { + val idx = list.indexOfFirst { it.id == highlightId } + if (idx >= 0) { + current[key] = + list.toMutableList().apply { + set(idx, get(idx).copy(published = true, eventId = eventId)) + } + _highlights.value = current + saveIndex(current) + return + } + } + } + } + + fun getHighlightsForArticle(addressTag: String): List = _highlights.value[addressTag] ?: emptyList() + + fun getAllHighlights(): Map> = _highlights.value + + private suspend fun loadIndex() { + mutex.withLock { + if (indexFile.exists()) { + try { + val data: Map> = mapper.readValue(indexFile) + _highlights.value = data + } catch (e: Exception) { + // Corrupted file — start fresh + _highlights.value = emptyMap() + } + } + } + } + + private fun saveIndex(data: Map>) { + try { + val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(data) + val tempFile = File(highlightsDir, "index.json.tmp") + tempFile.writeText(json) + Files.move( + tempFile.toPath(), + indexFile.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } catch (_: Exception) { + // Best effort — don't crash on write failure + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index 04fb56ac2..fee6d8c3b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -576,6 +576,24 @@ object FilterBuilders { until = until, ) + /** + * Creates a filter for a specific long-form article by author pubkey and d-tag. + * + * @param pubkey Author public key (hex-encoded, 64 chars) + * @param dTag The d-tag (slug) identifier for the addressable event + * @return Filter for a specific long-form article + */ + fun longFormByAddress( + pubkey: String, + dTag: String, + ): Filter = + Filter( + kinds = listOf(30023), // LongTextNoteEvent.KIND + authors = listOf(pubkey), + tags = mapOf("d" to listOf(dTag)), + limit = 1, + ) + /** * Creates a filter for long-form content (kind 30023) from specific authors. * diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt index a6bdbc908..da9f93413 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt @@ -104,7 +104,11 @@ fun createUserPostsSubscription( ): SubscriptionConfig = SubscriptionConfig( subId = generateSubId("posts-${pubKeyHex.take(8)}"), - filters = listOf(FilterBuilders.textNotesFromAuthors(listOf(pubKeyHex), limit = limit)), + filters = + listOf( + FilterBuilders.textNotesFromAuthors(listOf(pubKeyHex), limit = limit), + FilterBuilders.longFormFromAuthors(listOf(pubKeyHex), limit = 50), + ), relays = relays, onEvent = onEvent, onEose = onEose, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt new file mode 100644 index 000000000..c0debf9ac --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt @@ -0,0 +1,337 @@ +/* + * 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.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownEditorState +import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownToolbar +import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel +import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown +import com.vitorpamplona.amethyst.commons.model.nip23LongContent.LongFormPublishAction +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore +import com.vitorpamplona.amethyst.desktop.service.drafts.DraftMetadata +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.awt.Desktop +import java.net.URI + +private val ALLOWED_SCHEMES = setOf("https", "http", "nostr", "lightning") + +@Composable +fun ArticleEditorScreen( + draftSlug: String?, + draftStore: DesktopDraftStore, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + onBack: () -> Unit, + onPublished: () -> Unit, +) { + val scope = rememberCoroutineScope() + + var title by remember { mutableStateOf("") } + var summary by remember { mutableStateOf("") } + var bannerUrl by remember { mutableStateOf("") } + var tags by remember { mutableStateOf>(emptyList()) } + var slug by remember { mutableStateOf(draftSlug ?: "") } + val editorState = remember { MarkdownEditorState() } + var publishing by remember { mutableStateOf(false) } + var saveMessage by remember { mutableStateOf(null) } + var debouncedContent by remember { mutableStateOf("") } + + LaunchedEffect(editorState.text) { + delay(300) + debouncedContent = editorState.text + } + + // Load existing draft + LaunchedEffect(draftSlug) { + if (draftSlug != null) { + val meta = draftStore.loadMetadata(draftSlug) + val body = draftStore.loadContent(draftSlug) + if (meta != null) { + title = meta.title + summary = meta.summary ?: "" + bannerUrl = meta.image ?: "" + tags = meta.tags + slug = draftSlug + } + if (body != null) { + editorState.loadContent(body) + } + } + } + + // Auto-generate slug from title if creating a new draft + LaunchedEffect(title) { + if (draftSlug == null && title.isNotBlank()) { + slug = draftStore.slugFromTitle(title) + } + } + + val onLinkClick: (String) -> Unit = + remember { + { url: String -> + val scheme = url.substringBefore(":").lowercase() + if (scheme in ALLOWED_SCHEMES) { + try { + Desktop.getDesktop().browse(URI(url)) + } catch (_: Exception) { + // Ignore unsupported or malformed URLs + } + } + } + } + + fun saveDraft() { + if (slug.isBlank()) return + scope.launch { + draftStore.saveDraft( + slug = slug, + content = editorState.text, + metadata = + DraftMetadata( + title = title, + summary = summary.ifBlank { null }, + image = bannerUrl.ifBlank { null }, + tags = tags, + ), + ) + saveMessage = "Saved" + } + } + + fun publishArticle() { + if (publishing) return + publishing = true + scope.launch { + try { + val event = + LongFormPublishAction.publish( + title = title, + content = editorState.text, + summary = summary.ifBlank { null }, + image = bannerUrl.ifBlank { null }, + tags = tags, + dTag = slug.ifBlank { draftStore.slugFromTitle(title) }, + signer = account.signer, + ) + // TODO: send() is fire-and-forget; markPublished runs before relay ack. + // Consider waiting for relay OK response before marking as published. + relayManager.send(event) + draftStore.markPublished(slug) + onPublished() + } catch (e: Exception) { + saveMessage = "Publish failed: ${e.message}" + } finally { + publishing = false + } + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.isMetaPressed) { + when (event.key) { + Key.S -> { + saveDraft() + true + } + + Key.B -> { + editorState.toggleBold() + true + } + + Key.I -> { + editorState.toggleItalic() + true + } + + Key.E -> { + editorState.toggleInlineCode() + true + } + + Key.K -> { + editorState.insertLink() + true + } + + else -> { + false + } + } + } else { + false + } + }, + ) { + // Top bar + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onBack) { + Text("Back") + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + saveMessage?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } + OutlinedButton(onClick = { saveDraft() }) { + Text("Save") + } + Button( + onClick = { publishArticle() }, + enabled = !publishing && title.isNotBlank() && editorState.text.isNotBlank(), + ) { + Text(if (publishing) "Publishing..." else "Publish") + } + } + } + + // Metadata panel (collapsible) + MetadataPanel( + title = title, + onTitleChange = { title = it }, + summary = summary, + onSummaryChange = { summary = it }, + bannerUrl = bannerUrl, + onBannerUrlChange = { bannerUrl = it }, + tags = tags, + onTagsChange = { tags = it }, + slug = slug, + onSlugChange = { slug = it }, + ) + + Spacer(Modifier.height(8.dp)) + + // Markdown toolbar — selection-aware toggle behavior + MarkdownToolbar(state = editorState) + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Split pane: source left, preview right + Row( + modifier = Modifier.fillMaxSize().weight(1f), + ) { + // Source editor + TextField( + value = editorState.value, + onValueChange = { + editorState.onValueChange(it) + saveMessage = null + }, + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(end = 4.dp), + textStyle = + TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurface, + ), + placeholder = { Text("Write your article in markdown...") }, + ) + + VerticalDivider() + + // Preview + SelectionContainer { + Column( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(start = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column(modifier = Modifier.widthIn(max = 680.dp)) { + if (debouncedContent.isNotBlank()) { + RenderMarkdown( + content = debouncedContent, + onLinkClick = onLinkClick, + ) + } else { + Text( + "Preview will appear here...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt new file mode 100644 index 000000000..4bd362e90 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt @@ -0,0 +1,736 @@ +/* + * 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.desktop.ui + +import androidx.compose.foundation.ContextMenuItem +import androidx.compose.foundation.ContextMenuRepresentation +import androidx.compose.foundation.ContextMenuState +import androidx.compose.foundation.LocalContextMenuRepresentation +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.compose.article.ArticleHeader +import com.vitorpamplona.amethyst.commons.compose.article.TableOfContents +import com.vitorpamplona.amethyst.commons.compose.article.extractTableOfContents +import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown +import com.vitorpamplona.amethyst.commons.model.nip23LongContent.ReadingTimeCalculator +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig +import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.highlights.ArticleHighlightsPanel +import com.vitorpamplona.amethyst.desktop.ui.highlights.HighlightAnnotationDialog +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +private val articleDateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) + +/** + * Parses a NIP-23 address tag in the format "30023:pubkey:d-tag". + * Returns a Triple of (kind, pubkey, dTag) or null if invalid. + */ +private fun parseAddressTag(addressTag: String): Triple? { + val parts = addressTag.split(":", limit = 3) + if (parts.size < 3) return null + val kind = parts[0].toIntOrNull() ?: return null + return Triple(kind, parts[1], parts[2]) +} + +/** + * Desktop Article Reader Screen - renders long-form NIP-23 content with + * a Medium-style layout: optional ToC sidebar, centered content column, + * article header with hero image, markdown body, and reaction row. + */ +@Composable +fun ArticleReaderScreen( + addressTag: String, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + account: AccountState.LoggedIn?, + nwcConnection: Nip47WalletConnect.Nip47URINorm? = null, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + highlightStore: DesktopHighlightStore? = null, + onBack: () -> Unit, + onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, +) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val scrollState = rememberScrollState() + + // Parse address tag + val parsed = remember(addressTag) { parseAddressTag(addressTag) } + val pubkey = parsed?.second + val dTag = parsed?.third + + // Article state + var article by remember(addressTag) { mutableStateOf(null) } + var eoseReceived by remember(addressTag) { mutableStateOf(false) } + + // Zoom level for article text + var zoomLevel by remember { mutableStateOf(1.0f) } + + // Coroutine scope for highlight operations + val scope = rememberCoroutineScope() + + // Highlight state — collect outside let to ensure proper Compose subscription + val allHighlights by (highlightStore?.highlights ?: kotlinx.coroutines.flow.MutableStateFlow(emptyMap())) + .collectAsState() + val articleHighlights = allHighlights[addressTag] ?: emptyList() + + var showAnnotationDialog by remember { mutableStateOf(null) } + var showHighlightsPanel by remember { mutableStateOf(false) } + val focusRequester = + remember { + androidx.compose.ui.focus + .FocusRequester() + } + + // Active ToC entry tracking (placeholder — no scroll-position-based tracking yet) + var activeTocIndex by remember { mutableStateOf(null) } + + // Link click handler for markdown + val onLinkClick: (String) -> Unit = + remember(articleHighlights) { + { url: String -> + when { + url.startsWith("highlight://") -> { + showHighlightsPanel = true + } + + url.startsWith("nostr:") -> { + // TODO: Parse nostr: URI and navigate + } + + else -> { + try { + java.awt.Desktop + .getDesktop() + .browse(java.net.URI(url)) + } catch (_: Exception) { + } + } + } + } + } + + // Load author metadata via coordinator + LaunchedEffect(article, subscriptionsCoordinator) { + val art = article ?: return@LaunchedEffect + subscriptionsCoordinator?.loadMetadataForPubkeys(listOf(art.pubKey)) + } + + // Subscribe to the article by address components + rememberSubscription(relayStatuses, addressTag, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || pubkey == null || dTag == null) { + return@rememberSubscription null + } + + SubscriptionConfig( + subId = "article-${addressTag.hashCode()}", + filters = listOf(FilterBuilders.longFormByAddress(pubkey, dTag)), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is LongTextNoteEvent) { + // Keep the most recent version + val current = article + if (current == null || event.createdAt > current.createdAt) { + article = event + } + } + }, + onEose = { _, _ -> + eoseReceived = true + }, + ) + } + + // Interaction state + val articleEventId = article?.id + val eventIds = listOfNotNull(articleEventId) + + var zapReceipts by remember { mutableStateOf>(emptyList()) } + var reactionCount by remember { mutableStateOf(0) } + var replyCount by remember { mutableStateOf(0) } + var repostCount by remember { mutableStateOf(0) } + var bookmarkList by remember { mutableStateOf(null) } + var bookmarkedEventIds by remember { mutableStateOf>(emptySet()) } + + // Subscribe to zaps + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + createZapsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is LnZapEvent) { + val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription + if (zapReceipts.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) { + zapReceipts = zapReceipts + receipt + } + } + }, + ) + } + + // Subscribe to reactions + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + val reactionIds = mutableSetOf() + createReactionsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is ReactionEvent && reactionIds.add(event.id)) { + reactionCount = reactionIds.size + } + }, + ) + } + + // Subscribe to replies + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + val replyIds = mutableSetOf() + createRepliesSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (replyIds.add(event.id)) { + replyCount = replyIds.size + } + }, + ) + } + + // Subscribe to reposts + rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null + + val repostIds = mutableSetOf() + createRepostsSubscription( + relays = configuredRelays, + eventIds = eventIds, + onEvent = { event, _, _, _ -> + if (event is RepostEvent && repostIds.add(event.id)) { + repostCount = repostIds.size + } + }, + ) + } + + // Subscribe to bookmark list + rememberSubscription(relayStatuses, account, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null) { + SubscriptionConfig( + subId = "article-bookmarks-${account.pubKeyHex.take(8)}", + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(account.pubKeyHex), + kinds = listOf(BookmarkListEvent.KIND), + limit = 1, + ), + ), + relays = configuredRelays, + onEvent = { event, _, _, _ -> + if (event is BookmarkListEvent) { + bookmarkList = event + bookmarkedEventIds = + event + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Derived data from article + val title = article?.title() ?: "Untitled" + val content = article?.content ?: "" + val tocEntries = remember(content) { extractTableOfContents(content) } + val readingTime = + remember(content) { + if (content.isNotBlank()) ReadingTimeCalculator.calculate(content) else null + } + val bannerUrl = article?.image() + val publishedAt = + article?.let { art -> + val ts = art.publishedAt() ?: art.createdAt + Instant + .ofEpochSecond(ts) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(articleDateFormat) + } + + // Author info from local cache + val authorUser = article?.let { localCache.getOrCreateUser(it.pubKey) } + val authorName = authorUser?.toBestDisplayName() + val authorPicture = authorUser?.profilePicture() + + val clipboardManager = LocalClipboardManager.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column( + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.isMetaPressed) { + when (event.key) { + Key.Equals -> { + zoomLevel = (zoomLevel + 0.1f).coerceAtMost(2.0f) + true + } + + Key.Minus -> { + zoomLevel = (zoomLevel - 0.1f).coerceAtLeast(0.5f) + true + } + + Key.Zero -> { + zoomLevel = 1.0f + true + } + + else -> { + false + } + } + } else { + false + } + }, + ) { + // Top bar: back + bookmark placeholder + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(24.dp), + ) + } + Spacer(Modifier.width(8.dp)) + Text( + "Article", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + if (zoomLevel != 1.0f) { + Spacer(Modifier.width(8.dp)) + Text( + "${(zoomLevel * 100).toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Loading / error / content states + when { + parsed == null -> { + EmptyState( + title = "Invalid article address", + description = "Could not parse address: $addressTag", + onRefresh = onBack, + refreshLabel = "Go back", + ) + } + + connectedRelays.isEmpty() -> { + LoadingState("Connecting to relays...") + } + + article == null && !eoseReceived -> { + LoadingState("Loading article...") + } + + article == null && eoseReceived -> { + EmptyState( + title = "Article not found", + description = "This article may have been deleted or is not available from connected relays", + onRefresh = onBack, + refreshLabel = "Go back", + ) + } + + else -> { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val showToc = maxWidth > 1100.dp && tocEntries.isNotEmpty() + + Row(modifier = Modifier.fillMaxSize()) { + // ToC sidebar + if (showToc) { + TableOfContents( + entries = tocEntries, + activeEntryIndex = activeTocIndex, + onEntryClick = { entry -> + activeTocIndex = entry.index + // TODO: scroll to heading position + }, + modifier = Modifier.padding(top = 16.dp, start = 8.dp), + ) + VerticalDivider() + } + + // Main content column + Column( + modifier = + Modifier + .weight(1f) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp), + ) { + Column( + modifier = + Modifier + .widthIn(max = 680.dp) + .align(Alignment.CenterHorizontally), + ) { + Spacer(Modifier.height(16.dp)) + + ArticleHeader( + title = title, + authorName = authorName, + authorPicture = authorPicture, + publishedAt = publishedAt, + readingTimeMinutes = readingTime, + bannerUrl = bannerUrl, + onAuthorClick = + article?.let { + { onNavigateToProfile(it.pubKey) } + }, + ) + + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + thickness = 1.dp, + ) + + // Collapsible highlights section + if (articleHighlights.isNotEmpty()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + showHighlightsPanel = !showHighlightsPanel + }.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (showHighlightsPanel) { + Icons.Default.KeyboardArrowDown + } else { + Icons.AutoMirrored.Filled.KeyboardArrowRight + }, + contentDescription = "Toggle highlights", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(4.dp)) + Text( + text = "${articleHighlights.size} highlight${if (articleHighlights.size != 1) "s" else ""}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + if (showHighlightsPanel && highlightStore != null) { + ArticleHighlightsPanel( + highlights = articleHighlights, + highlightStore = highlightStore, + articleContent = content, + signer = account?.signer, + relayManager = relayManager, + modifier = Modifier.padding(bottom = 16.dp), + ) + HorizontalDivider( + modifier = Modifier.padding(bottom = 16.dp), + thickness = 1.dp, + ) + } + } + + // Markdown body with right-click highlight via context menu + val defaultRepresentation = LocalContextMenuRepresentation.current + val highlightRepresentation = + remember( + defaultRepresentation, + highlightStore, + addressTag, + title, + ) { + HighlightContextMenuRepresentation( + delegate = defaultRepresentation, + clipboardManager = clipboardManager, + onHighlight = { text -> + scope.launch { + highlightStore?.addHighlight( + articleAddressTag = addressTag, + text = text, + note = null, + articleTitle = title, + ) + } + }, + onHighlightWithNote = { text -> + showAnnotationDialog = text + }, + ) + } + + CompositionLocalProvider( + LocalContextMenuRepresentation provides highlightRepresentation, + ) { + SelectionContainer { + RenderMarkdown( + content = content, + onLinkClick = onLinkClick, + fontScale = zoomLevel, + highlightedTexts = articleHighlights.map { it.text }, + ) + } + } + + Spacer(Modifier.height(32.dp)) + + // Topics / hashtags + val topics = article?.topics() ?: emptyList() + if (topics.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(bottom = 16.dp), + ) { + topics.forEach { topic -> + Text( + text = "#$topic", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + } + + HorizontalDivider(thickness = 1.dp) + + // Reaction actions + val art = article + if (art != null && account != null) { + Spacer(Modifier.height(16.dp)) + NoteActionsRow( + event = art, + relayManager = relayManager, + localCache = localCache, + account = account, + onReplyClick = { onNavigateToThread(art.id) }, + onZapFeedback = onZapFeedback, + zapCount = zapReceipts.size, + zapAmountSats = zapReceipts.sumOf { it.amountSats }, + zapReceipts = zapReceipts, + reactionCount = reactionCount, + replyCount = replyCount, + repostCount = repostCount, + nwcConnection = nwcConnection, + isBookmarked = articleEventId in bookmarkedEventIds, + bookmarkList = bookmarkList, + onBookmarkChanged = { newList -> + bookmarkList = newList + bookmarkedEventIds = + newList + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + }, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(Modifier.height(48.dp)) + } + } + } + } + } + } + } + + showAnnotationDialog?.let { selectedText -> + HighlightAnnotationDialog( + selectedText = selectedText, + onConfirm = { note -> + scope.launch { + highlightStore?.addHighlight( + articleAddressTag = addressTag, + text = selectedText, + note = note, + articleTitle = title, + ) + } + showAnnotationDialog = null + }, + onDismiss = { showAnnotationDialog = null }, + ) + } +} + +/** + * Custom context menu representation that adds "Highlight" and "Highlight with Note" + * items to the right-click menu inside a SelectionContainer. + * + * How it works: The SelectionContainer provides a "Copy" item that has access to the + * selected text. Our items piggyback on Copy's onClick — calling it first to put the + * selected text on the clipboard, then reading the clipboard to get the text. + */ +private class HighlightContextMenuRepresentation( + private val delegate: ContextMenuRepresentation, + private val clipboardManager: androidx.compose.ui.platform.ClipboardManager, + private val onHighlight: (String) -> Unit, + private val onHighlightWithNote: (String) -> Unit, +) : ContextMenuRepresentation { + @Composable + override fun Representation( + state: ContextMenuState, + items: () -> List, + ) { + val extendedItems = { + val original = items() + val copyItem = original.find { it.label == "Copy" } + + if (copyItem != null) { + original + + listOf( + ContextMenuItem("Highlight") { + copyItem.onClick() + val text = clipboardManager.getText()?.text + if (!text.isNullOrBlank()) { + onHighlight(text) + } + }, + ContextMenuItem("Highlight with Note") { + copyItem.onClick() + val text = clipboardManager.getText()?.text + if (!text.isNullOrBlank()) { + onHighlightWithNote(text) + } + }, + ) + } else { + original + } + } + + delegate.Representation(state, extendedItems) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt new file mode 100644 index 000000000..64d383860 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -0,0 +1,188 @@ +/* + * 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.desktop.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore +import com.vitorpamplona.amethyst.desktop.service.drafts.DraftEntry +import kotlinx.coroutines.launch + +@Composable +fun DraftsScreen( + draftStore: DesktopDraftStore, + onOpenEditor: (slug: String?) -> Unit, +) { + val drafts by draftStore.drafts.collectAsState() + val scope = rememberCoroutineScope() + var deleteTarget by remember { mutableStateOf(null) } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Drafts", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Button(onClick = { onOpenEditor(null) }) { + Icon(Icons.Default.Add, contentDescription = null) + Text("New Draft", modifier = Modifier.padding(start = 4.dp)) + } + } + + Spacer(Modifier.height(16.dp)) + + if (drafts.isEmpty()) { + Text( + "No drafts yet. Click \"New Draft\" to start writing.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(drafts, key = { it.slug }) { entry -> + DraftCard( + entry = entry, + onClick = { onOpenEditor(entry.slug) }, + onDelete = { deleteTarget = entry }, + ) + } + } + } + } + + // Delete confirmation dialog + deleteTarget?.let { entry -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text("Delete Draft") }, + text = { + Text( + "Delete \"${entry.metadata.title.ifBlank { entry.slug }}\"? This cannot be undone.", + ) + }, + confirmButton = { + TextButton(onClick = { + scope.launch { draftStore.deleteDraft(entry.slug) } + deleteTarget = null + }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { deleteTarget = null }) { + Text("Cancel") + } + }, + ) + } +} + +@Composable +private fun DraftCard( + entry: DraftEntry, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.metadata.title.ifBlank { "Untitled" }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = entry.metadata.updatedAt.take(10), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (entry.metadata.published) { + Text( + text = "Published", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete draft", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 3853e4b6e..2d5aba286 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -68,13 +69,20 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createLongFormFeedSubscr import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import java.text.SimpleDateFormat -import java.util.Date +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Locale -private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) +private val dateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) -private fun formatDate(timestamp: Long): String = dateFormat.format(Date(timestamp * 1000)) +private fun formatDate(timestamp: Long): String = + Instant + .ofEpochSecond(timestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(dateFormat) /** * Card displaying long-form content (NIP-23) with title, summary, and image. @@ -171,8 +179,11 @@ fun ReadsScreen( relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, account: AccountState.LoggedIn? = null, + nwcConnection: Nip47WalletConnect.Nip47URINorm? = null, onNavigateToProfile: (String) -> Unit = {}, onNavigateToArticle: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() val scope = rememberCoroutineScope() @@ -357,12 +368,27 @@ fun ReadsScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { items(events, key = { it.id }) { event -> - LongFormCard( - event = event, - localCache = localCache, - onAuthorClick = onNavigateToProfile, - onClick = { onNavigateToArticle(event.id) }, - ) + Column { + LongFormCard( + event = event, + localCache = localCache, + onAuthorClick = onNavigateToProfile, + onClick = { onNavigateToArticle(event.addressTag()) }, + ) + if (account != null) { + NoteActionsRow( + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReplyClick = { onNavigateToThread(event.id) }, + onZapFeedback = onZapFeedback, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + } + } + HorizontalDivider(thickness = 1.dp) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 15f0a0de6..c79b4e368 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -96,7 +96,9 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -118,6 +120,7 @@ fun UserProfileScreen( onBack: () -> Unit, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, + onNavigateToArticle: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() @@ -157,6 +160,8 @@ fun UserProfileScreen( var selectedTab by remember { mutableStateOf(0) } var lightboxState by remember { mutableStateOf(null) } val pictureEvents = remember { mutableStateListOf() } + val articleEvents = remember { mutableStateListOf() } + val highlightEvents = remember { mutableStateListOf() } // Follow state val followState = @@ -344,6 +349,60 @@ fun UserProfileScreen( } } + // Subscribe to long-form articles (kind 30023) for reads tab + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + articleEvents.clear() + SubscriptionConfig( + subId = generateSubId("articles-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(pubKeyHex), + kinds = listOf(LongTextNoteEvent.KIND), + limit = 50, + ), + ), + relays = connectedRelays, + onEvent = { event, _, _, _ -> + if (event is LongTextNoteEvent && articleEvents.none { it.id == event.id }) { + articleEvents.add(event) + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Subscribe to highlight events (kind 9802) for highlights tab + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + highlightEvents.clear() + SubscriptionConfig( + subId = generateSubId("hl-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(pubKeyHex), + kinds = listOf(HighlightEvent.KIND), + limit = 100, + ), + ), + relays = connectedRelays, + onEvent = { event, _, _, _ -> + if (event is HighlightEvent && highlightEvents.none { it.id == event.id }) { + highlightEvents.add(event) + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + // Scroll state for detecting scroll direction val listState = rememberLazyListState() var showFloatingHeader by remember { mutableStateOf(false) } @@ -629,8 +688,20 @@ fun UserProfileScreen( Text("Notes", modifier = Modifier.padding(12.dp)) } Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { + Text( + "Reads${if (articleEvents.isNotEmpty()) " (${articleEvents.size})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } + Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) { Text("Gallery", modifier = Modifier.padding(12.dp)) } + Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) { + Text( + "Highlights${if (highlightEvents.isNotEmpty()) " (${highlightEvents.size})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } } } @@ -726,6 +797,38 @@ fun UserProfileScreen( } 1 -> { + if (articleEvents.isEmpty()) { + item(key = "no-articles") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No long-form articles", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + items( + articleEvents.sortedByDescending { it.publishedAt() ?: it.createdAt }, + key = { "art-${it.id}" }, + ) { article -> + LongFormCard( + event = article, + localCache = localCache, + onAuthorClick = { onNavigateToProfile(article.pubKey) }, + onClick = { + val addressTag = "${LongTextNoteEvent.KIND}:${article.pubKey}:${article.dTag()}" + onNavigateToArticle(addressTag) + }, + ) + } + } + } + + 2 -> { item(key = "gallery") { GalleryTab( pictureEvents = pictureEvents, @@ -734,6 +837,33 @@ fun UserProfileScreen( ) } } + + 3 -> { + if (highlightEvents.isEmpty()) { + item(key = "no-highlights") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No published highlights", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + items( + highlightEvents.sortedByDescending { it.createdAt }, + key = { "hl-${it.id}" }, + ) { highlight -> + PublishedHighlightCard( + highlight = highlight, + localCache = localCache, + ) + } + } + } } } } @@ -940,3 +1070,64 @@ private suspend fun updateProfileDisplayName( onStatusUpdate(ProfileBroadcastStatus.Failed("display name", e.message ?: "Unknown error")) } } + +@Composable +private fun PublishedHighlightCard( + highlight: HighlightEvent, + localCache: DesktopLocalCache, +) { + val articleAddress = highlight.inPostAddress() + val articleTitle = articleAddress?.let { "Article" } ?: "Unknown source" + + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + // Quoted highlight text + Text( + text = "\u201C${highlight.quote()}\u201D", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + ) + + // Note/comment + val comment = highlight.comment() + if (!comment.isNullOrBlank()) { + Spacer(Modifier.height(8.dp)) + Text( + text = comment, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Context (surrounding paragraph) + val context = highlight.context() + if (!context.isNullOrBlank() && context != highlight.quote()) { + Spacer(Modifier.height(8.dp)) + Text( + text = context.take(200) + if (context.length > 200) "\u2026" else "", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + + Spacer(Modifier.height(8.dp)) + + // Source article reference + if (articleAddress != null) { + Text( + text = "from ${articleAddress.dTag.ifBlank { "article" }}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt index f7ef606dd..f85a4707c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt @@ -53,6 +53,8 @@ private val COLUMN_OPTIONS = DeckColumnType.Messages, DeckColumnType.Search, DeckColumnType.Reads, + DeckColumnType.Drafts, + DeckColumnType.MyHighlights, DeckColumnType.Bookmarks, DeckColumnType.GlobalFeed, DeckColumnType.MyProfile, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index 3deb62b25..3bd9fe2ca 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -132,6 +132,10 @@ fun DeckColumnType.icon(): ImageVector = DeckColumnType.MyProfile -> Icons.Default.Person DeckColumnType.Chess -> Icons.Default.Extension DeckColumnType.Settings -> Icons.Default.Settings + is DeckColumnType.Article -> Icons.AutoMirrored.Filled.Article + is DeckColumnType.Editor -> Icons.AutoMirrored.Filled.Article + DeckColumnType.Drafts -> Icons.AutoMirrored.Filled.Article + DeckColumnType.MyHighlights -> Icons.AutoMirrored.Filled.Article is DeckColumnType.Profile -> Icons.Default.Person is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article is DeckColumnType.Hashtag -> Icons.Default.Tag diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 4e3ff3016..1da11dbd6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -44,9 +44,14 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.chess.ChessScreen import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen +import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen +import com.vitorpamplona.amethyst.desktop.ui.DraftsScreen import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen @@ -92,6 +97,8 @@ fun DeckColumnContainer( iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore, + draftStore: DesktopDraftStore, appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, @@ -132,6 +139,8 @@ fun DeckColumnContainer( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, compactMode = true, onShowComposeDialog = onShowComposeDialog, @@ -139,6 +148,8 @@ fun DeckColumnContainer( onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, + onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, ) if (currentOverlay != null) { Surface( @@ -152,11 +163,14 @@ fun DeckColumnContainer( account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onBack = { navState.pop() }, ) } @@ -175,6 +189,8 @@ internal fun RootContent( iAccount: DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore? = null, + draftStore: DesktopDraftStore? = null, appScope: CoroutineScope, compactMode: Boolean = false, onShowComposeDialog: () -> Unit, @@ -182,6 +198,8 @@ internal fun RootContent( onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, + onNavigateToArticle: (String) -> Unit = {}, + onNavigateToEditor: (String?) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -231,8 +249,11 @@ internal fun RootContent( relayManager = relayManager, localCache = localCache, account = account, + nwcConnection = nwcConnection, onNavigateToProfile = onNavigateToProfile, - onNavigateToArticle = onNavigateToThread, + onNavigateToArticle = onNavigateToArticle, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, ) } @@ -275,6 +296,7 @@ internal fun RootContent( onBack = {}, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) } @@ -323,6 +345,44 @@ internal fun RootContent( ) } + is DeckColumnType.Article -> { + ArticleReaderScreen( + addressTag = columnType.addressTag, + relayManager = relayManager, + localCache = localCache, + account = account, + subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + onBack = {}, + onNavigateToProfile = onNavigateToProfile, + ) + } + + is DeckColumnType.Editor -> { + ArticleEditorScreen( + draftSlug = columnType.draftSlug, + draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, + account = account, + relayManager = relayManager, + onBack = {}, + onPublished = {}, + ) + } + + DeckColumnType.Drafts -> { + DraftsScreen( + draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, + onOpenEditor = { slug -> onNavigateToEditor(slug) }, + ) + } + + DeckColumnType.MyHighlights -> { + com.vitorpamplona.amethyst.desktop.ui.highlights.MyHighlightsScreen( + highlightStore = highlightStore ?: remember { DesktopHighlightStore(scope) }, + onNavigateToArticle = onNavigateToArticle, + ) + } + is DeckColumnType.Hashtag -> { SearchScreen( localCache = localCache, @@ -344,11 +404,14 @@ internal fun OverlayContent( account: AccountState.LoggedIn, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore? = null, + draftStore: DesktopDraftStore? = null, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, + onNavigateToArticle: (String) -> Unit = {}, onBack: () -> Unit, ) { when (screen) { @@ -363,6 +426,7 @@ internal fun OverlayContent( onBack = onBack, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, + onNavigateToArticle = onNavigateToArticle, onZapFeedback = onZapFeedback, ) } @@ -383,6 +447,31 @@ internal fun OverlayContent( ) } + is DesktopScreen.Article -> { + ArticleReaderScreen( + addressTag = screen.addressTag, + relayManager = relayManager, + localCache = localCache, + account = account, + subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + onBack = onBack, + onNavigateToProfile = onNavigateToProfile, + ) + } + + is DesktopScreen.Editor -> { + val overlayScope = androidx.compose.runtime.rememberCoroutineScope() + ArticleEditorScreen( + draftSlug = screen.draftSlug, + draftStore = draftStore ?: remember { DesktopDraftStore(overlayScope) }, + account = account, + relayManager = relayManager, + onBack = onBack, + onPublished = onBack, + ) + } + else -> { androidx.compose.material3.Text( "Unsupported screen type", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt index 2b1642244..3f5ea27b7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt @@ -51,6 +51,18 @@ sealed class DeckColumnType { val noteId: String, ) : DeckColumnType() + data class Article( + val addressTag: String, + ) : DeckColumnType() + + data class Editor( + val draftSlug: String? = null, + ) : DeckColumnType() + + object Drafts : DeckColumnType() + + object MyHighlights : DeckColumnType() + data class Hashtag( val tag: String, ) : DeckColumnType() @@ -67,6 +79,10 @@ sealed class DeckColumnType { MyProfile -> "Profile" Chess -> "Chess" Settings -> "Settings" + is Article -> "Article" + is Editor -> "New Article" + Drafts -> "Drafts" + MyHighlights -> "Highlights" is Profile -> "Profile" is Thread -> "Thread" is Hashtag -> "#$tag" @@ -84,6 +100,10 @@ sealed class DeckColumnType { MyProfile -> "my_profile" Chess -> "chess" Settings -> "settings" + is Article -> "article" + is Editor -> "editor" + Drafts -> "drafts" + MyHighlights -> "highlights" is Profile -> "profile" is Thread -> "thread" is Hashtag -> "hashtag" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index 519d93217..e9f23f353 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm @@ -61,6 +62,8 @@ fun DeckLayout( iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore, + draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore, appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, @@ -113,6 +116,8 @@ fun DeckLayout( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index ea458daa2..7afabc73a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen @@ -79,6 +80,8 @@ private val navItems = listOf( NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"), NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"), + NavItem(DeckColumnType.Drafts, Icons.AutoMirrored.Filled.Article, "Drafts"), + NavItem(DeckColumnType.MyHighlights, Icons.AutoMirrored.Filled.Article, "Highlights"), NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"), NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"), NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"), @@ -97,6 +100,8 @@ fun SinglePaneLayout( iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount, nwcConnection: Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + highlightStore: DesktopHighlightStore, + draftStore: com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore, appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, @@ -171,12 +176,16 @@ fun SinglePaneLayout( iAccount = iAccount, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, + onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, ) if (currentOverlay != null) { Surface( @@ -190,11 +199,14 @@ fun SinglePaneLayout( account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + highlightStore = highlightStore, + draftStore = draftStore, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onBack = { navState.pop() }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/ArticleHighlightsPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/ArticleHighlightsPanel.kt new file mode 100644 index 000000000..3fec86bff --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/ArticleHighlightsPanel.kt @@ -0,0 +1,208 @@ +/* + * 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.desktop.ui.highlights + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Public +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.launch + +@Composable +fun ArticleHighlightsPanel( + highlights: List, + highlightStore: DesktopHighlightStore, + articleContent: String, + signer: NostrSigner?, + relayManager: DesktopRelayConnectionManager?, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + var editTarget by remember { mutableStateOf(null) } + + Column(modifier = modifier.fillMaxWidth().padding(top = 8.dp)) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + highlights.forEach { highlight -> + HighlightPanelCard( + highlight = highlight, + onDelete = { + scope.launch { highlightStore.removeHighlight(highlight.id) } + }, + onEditNote = { editTarget = highlight }, + onPublish = + if (!highlight.published && signer != null && relayManager != null) { + { + scope.launch { + val context = + HighlightPublishAction.extractContext( + articleContent, + highlight.text, + ) + val event = + HighlightPublishAction.publish( + highlightText = highlight.text, + articleAddressTag = highlight.articleAddressTag, + note = highlight.note, + context = context, + signer = signer, + ) + relayManager.broadcastToAll(event) + highlightStore.markPublished(highlight.id, event.id) + } + } + } else { + null + }, + ) + } + } + } + + editTarget?.let { highlight -> + HighlightAnnotationDialog( + selectedText = highlight.text, + onConfirm = { note -> + scope.launch { highlightStore.updateNote(highlight.id, note) } + editTarget = null + }, + onDismiss = { editTarget = null }, + ) + } +} + +@Composable +private fun HighlightPanelCard( + highlight: HighlightData, + onDelete: () -> Unit, + onEditNote: () -> Unit, + onPublish: (() -> Unit)?, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "\u201C${highlight.text}\u201D", + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurface, + ) + + val noteText = highlight.note + if (!noteText.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = noteText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + // Published status + Icon( + imageVector = if (highlight.published) Icons.Default.Public else Icons.Default.Lock, + contentDescription = if (highlight.published) "Published" else "Private", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = if (highlight.published) "Published" else "Private", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp), + ) + + Spacer(Modifier.weight(1f)) + + // Publish button + if (onPublish != null) { + IconButton(onClick = onPublish, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.Public, + contentDescription = "Publish to relays", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + IconButton(onClick = onEditNote, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit note", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightAnnotationDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightAnnotationDialog.kt new file mode 100644 index 000000000..d43b51c13 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightAnnotationDialog.kt @@ -0,0 +1,80 @@ +/* + * 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.desktop.ui.highlights + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun HighlightAnnotationDialog( + selectedText: String, + onConfirm: (note: String) -> Unit, + onDismiss: () -> Unit, +) { + var note by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Highlight Note") }, + text = { + Column { + Text( + text = "\"$selectedText\"", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = note, + onValueChange = { note = it }, + label = { Text("Note (optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 5, + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(note) }) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightPublishAction.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightPublishAction.kt new file mode 100644 index 000000000..992640b02 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/HighlightPublishAction.kt @@ -0,0 +1,75 @@ +/* + * 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.desktop.ui.highlights + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip84Highlights.tags.CommentTag +import com.vitorpamplona.quartz.nip84Highlights.tags.ContextTag +import com.vitorpamplona.quartz.utils.TimeUtils + +object HighlightPublishAction { + suspend fun publish( + highlightText: String, + articleAddressTag: String, + note: String?, + context: String?, + signer: NostrSigner, + ): HighlightEvent { + val tags = mutableListOf>() + + tags.add(AltTag.assemble(HighlightEvent.ALT)) + tags.add(ATag.assemble(articleAddressTag, null)) + + // Tag the article author + val parts = articleAddressTag.split(":", limit = 3) + val pubkey = parts.getOrNull(1) + if (!pubkey.isNullOrBlank()) { + tags.add(PTag.assemble(pubkey, null)) + } + + if (!note.isNullOrBlank()) { + tags.add(CommentTag.assemble(note)) + } + + if (!context.isNullOrBlank()) { + tags.add(ContextTag.assemble(context)) + } + + return signer.sign( + createdAt = TimeUtils.now(), + kind = HighlightEvent.KIND, + tags = tags.toTypedArray(), + content = highlightText, + ) + } + + fun extractContext( + content: String, + highlightText: String, + ): String? { + val paragraphs = content.split("\n\n") + return paragraphs.find { it.contains(highlightText) } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt new file mode 100644 index 000000000..ff417478e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -0,0 +1,234 @@ +/* + * 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.desktop.ui.highlights + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Public +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun MyHighlightsScreen( + highlightStore: DesktopHighlightStore, + onNavigateToArticle: (addressTag: String) -> Unit, +) { + val allHighlights by highlightStore.highlights.collectAsState() + val scope = rememberCoroutineScope() + var deleteTarget by remember { mutableStateOf(null) } + + Column(modifier = Modifier.fillMaxSize()) { + Text( + "Highlights", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(Modifier.height(16.dp)) + + if (allHighlights.isEmpty()) { + EmptyState( + title = "No highlights yet", + description = "Select text in an article and choose \"Highlight\" to save passages.", + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + allHighlights.forEach { (addressTag, highlights) -> + val articleTitle = highlights.firstOrNull()?.articleTitle ?: addressTag + + stickyHeader(key = addressTag) { + ArticleGroupHeader( + title = articleTitle, + onClick = { onNavigateToArticle(addressTag) }, + ) + } + + items(highlights, key = { it.id }) { highlight -> + HighlightCard( + highlight = highlight, + onDelete = { deleteTarget = highlight }, + ) + } + } + } + } + } + + deleteTarget?.let { highlight -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text("Delete Highlight") }, + text = { + Text( + "Delete this highlight? This cannot be undone.", + ) + }, + confirmButton = { + TextButton(onClick = { + scope.launch { highlightStore.removeHighlight(highlight.id) } + deleteTarget = null + }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { deleteTarget = null }) { + Text("Cancel") + } + }, + ) + } +} + +@Composable +private fun ArticleGroupHeader( + title: String, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun HighlightCard( + highlight: HighlightData, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "\u201C${highlight.text}\u201D", + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurface, + ) + + val noteText = highlight.note + if (!noteText.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = noteText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(4.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatTimestamp(highlight.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = if (highlight.published) Icons.Default.Public else Icons.Default.Lock, + contentDescription = if (highlight.published) "Published" else "Private", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete highlight", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +private fun formatTimestamp(epochSeconds: Long): String = + Instant + .ofEpochSecond(epochSeconds) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt index e19f4ac63..525a8b468 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt @@ -102,7 +102,7 @@ class AccountManagerKeyLoginTest { val state = manager.generateNewAccount() assertTrue(state.npub.startsWith("npub1")) assertNotNull(state.nsec) - assertTrue(state.nsec!!.startsWith("nsec1")) + assertTrue(state.nsec.startsWith("nsec1")) assertFalse(state.isReadOnly) } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt index 085f96046..193aa1019 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopUploadTrackerTest.kt @@ -68,7 +68,7 @@ class DesktopUploadTrackerTest { val state = tracker.state.value assertFalse(state.isUploading) assertNotNull(state.result) - assertEquals("https://blossom.example.com/abc123.png", state.result!!.blossom.url) + assertEquals("https://blossom.example.com/abc123.png", state.result.blossom.url) assertNull(state.error) } diff --git a/docs/plans/2026-03-24-feat-article-highlights-notes-plan.md b/docs/plans/2026-03-24-feat-article-highlights-notes-plan.md new file mode 100644 index 000000000..7e3edfccb --- /dev/null +++ b/docs/plans/2026-03-24-feat-article-highlights-notes-plan.md @@ -0,0 +1,410 @@ +--- +title: "feat: Article Highlights & Note-Taking" +type: feat +status: active +date: 2026-03-24 +deepened: 2026-03-24 +origin: docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md +--- + +# feat: Article Highlights & Note-Taking + +## Enhancement Summary + +**Deepened on:** 2026-03-24 +**Research agents used:** text-selection, richtext-rendering, floating-popup, nip84-highlights + +### Key Improvements +1. **Text selection strategy resolved** — Use `LocalTextContextMenu` override (only official API exposing selected text), not clipboard polling +2. **Inline rendering strategy resolved** — Pre-process markdown with special link URI (`highlight://`) as v1; fork-level `==highlight==` syntax as v2 +3. **Floating toolbar pattern confirmed** — `Popup` + custom `PopupPositionProvider`, matches existing `ChatPane.kt` pattern +4. **NIP-84 gap found** — `HighlightEvent.create()` only takes `msg`+`signer`, needs tag assembly wrapper for full highlight creation + +### Resolved Questions +- **`` support?** No — richtext library ignores `HtmlInline` nodes. Use pre-processing or fork changes. +- **Clipboard polling reliability?** Moot — use `LocalTextContextMenu` instead (right-click UX, official API) +- **NIP-09 deletion?** Yes — send kind 5 event with `["e", highlightEventId]` + `["k", "9802"]` + +## Overview + +Text selection-based highlight and annotation system for the Desktop article reader. Users select text in NIP-23 articles, a context menu option or floating toolbar appears, and they create highlights (with optional notes). Highlights render inline as colored markers. Supports private (local) and public (NIP-84) modes. Includes a "My Highlights" aggregation screen. + +## Problem Statement + +Desktop article reader has no way to mark up, annotate, or take notes on long-form content. Users reading NIP-23 articles can't highlight passages, add personal notes, or publish highlights to their Nostr social graph. This limits the reading experience compared to tools like Kindle, Medium, or Hypothesis. + +## Proposed Solution + +Three-phase implementation: +1. **Storage + data model** — DesktopHighlightStore (Preferences-based) + highlight data classes +2. **Selection UX + inline rendering** — Text selection interception via context menu, floating toolbar, yellow highlight markers in markdown +3. **My Highlights screen + NIP-84 publishing** — Aggregation view, public/private toggle, relay broadcast + +## Technical Approach + +### Architecture + +``` +desktopApp/ +├── service/highlights/ +│ └── DesktopHighlightStore.kt # Preferences-based storage (like DraftStore) +├── ui/ +│ ├── ArticleReaderScreen.kt # Modified: selection + inline highlights +│ ├── highlights/ +│ │ ├── FloatingHighlightToolbar.kt # Popup on text selection +│ │ ├── HighlightAnnotationDialog.kt # Note entry dialog +│ │ ├── HighlightPublishAction.kt # NIP-84 tag assembly + publish +│ │ └── MyHighlightsScreen.kt # Aggregation screen +│ └── deck/ +│ ├── DeckColumnType.kt # Add MyHighlights +│ ├── DeckColumnContainer.kt # Route MyHighlights +│ └── SinglePaneLayout.kt # Add nav item + +commons/ +├── compose/markdown/ +│ └── RenderMarkdown.kt # Modified: accept highlight ranges, render yellow bg +├── model/highlights/ +│ └── HighlightData.kt # Shared data class +``` + +### Implementation Phases + +#### Phase 1: Storage & Data Model + +**Goal:** DesktopHighlightStore + highlight data classes, no UI yet. + +**Files:** +- `desktopApp/service/highlights/DesktopHighlightStore.kt` — follows DesktopDraftStore pattern (Jackson + Preferences) +- `commons/model/highlights/HighlightData.kt` — shared data class + +**Data model:** +```kotlin +data class HighlightData( + val id: String, // UUID + val text: String, // selected/highlighted text + val note: String?, // optional annotation + val articleAddressTag: String, // "30023:pubkey:d-tag" + val articleTitle: String?, // cached for My Highlights display + val createdAt: Long, // epoch seconds + val published: Boolean, // false = private, true = NIP-84 published + val eventId: String?, // NIP-84 event ID if published +) +``` + +**DesktopHighlightStore API:** +```kotlin +class DesktopHighlightStore(scope: CoroutineScope) { + private val mapper = jacksonObjectMapper() + val highlights: StateFlow>> // keyed by articleAddressTag + + suspend fun addHighlight(articleAddressTag: String, text: String, note: String?, articleTitle: String?) + suspend fun updateNote(highlightId: String, note: String) + suspend fun removeHighlight(highlightId: String) + suspend fun markPublished(highlightId: String, eventId: String) + fun getHighlightsForArticle(addressTag: String): List + fun getAllHighlights(): Map> +} +``` + +**Tests:** Unit tests for store CRUD, serialization round-trip. + +**Success criteria:** +- [ ] HighlightData serializes/deserializes via Jackson +- [ ] Store persists across app restarts via Preferences +- [ ] CRUD operations work correctly +- [ ] StateFlow emits on changes + +### Research Insights — Phase 1 + +**Storage pattern:** Follow `DesktopDraftStore.kt` exactly: +- `jacksonObjectMapper()` for serialization (line 62) +- Atomic writes with temp files + `Files.move()` (lines 237-254) +- POSIX file permissions for security (lines 261-288) +- Preferences key: `"highlights:${articleAddressTag}"` with JSON array value + +**Edge case — Preferences size limit:** `java.util.prefs.Preferences` has a per-value limit of 8192 bytes on some platforms. For articles with many highlights, the JSON array could exceed this. Mitigation: if value exceeds 6KB, spill to file-based storage (same pattern as DraftStore's file storage). + +--- + +#### Phase 2: Selection UX + Inline Rendering + +**Goal:** Select text in article → create highlight → see yellow marker. + +##### Text Selection Strategy (REVISED) + +**Primary: `LocalTextContextMenu` override** — the only official Compose Desktop API that exposes `selectedText`: + +```kotlin +@Composable +fun HighlightableContent( + onHighlight: (String) -> Unit, + onAnnotate: (String) -> Unit, + content: @Composable () -> Unit, +) { + val defaultMenu = LocalTextContextMenu.current + + CompositionLocalProvider( + LocalTextContextMenu provides object : TextContextMenu { + @Composable + override fun Area( + textManager: TextContextMenu.TextManager, + state: ContextMenuState, + content: @Composable () -> Unit, + ) { + ContextMenuDataProvider({ + val selected = textManager.selectedText + if (selected.text.isNotEmpty()) { + listOf( + ContextMenuItem("Highlight") { onHighlight(selected.text) }, + ContextMenuItem("Highlight with Note") { onAnnotate(selected.text) }, + ) + } else { + emptyList() + } + }) { + defaultMenu.Area(textManager, state, content = content) + } + } + }, + content = content, + ) +} +``` + +**UX:** Select text → right-click → "Highlight" / "Highlight with Note" in context menu. Natural desktop UX. No clipboard polling needed. + +**Secondary: Keyboard shortcut (Cmd+H)** — reads clipboard after user copies: + +```kotlin +Modifier.onPreviewKeyEvent { event -> + if (event.isMetaPressed && event.key == Key.H && event.type == KeyEventType.KeyDown) { + val clipText = clipboard.getText()?.text + if (!clipText.isNullOrBlank()) onHighlight(clipText) + true + } else false +} +``` + +##### Inline Rendering Strategy (REVISED) + +**Research finding:** richtext library ignores `HtmlInline` (``) and has no `Highlight` format. Three options ranked: + +| Approach | Effort | Quality | Recommended | +|----------|--------|---------|-------------| +| Pre-process: wrap in special link `[text](highlight://)` | Low | Hacky but works | v1 | +| Fork: add `==text==` DelimiterProcessor + `Format.Highlight` | Medium | Clean, semantic | v2 | +| Overlay: position colored Box composables | High | Fragile | No | + +**v1 approach (ship fast):** Pre-process markdown before parsing: +```kotlin +fun applyHighlights(content: String, highlights: List): String { + var result = content + // Sort by length descending to avoid nested replacement issues + highlights.sortedByDescending { it.text.length }.forEach { h -> + val idx = result.indexOf(h.text) + if (idx >= 0) { + // Wrap in bold + italic to visually distinguish + result = result.replaceFirst(h.text, "***${h.text}***") + } + } + return result +} +``` + +**v2 approach (proper):** Add highlight support to Vitor's richtext fork: +1. Add `AstHighlight` inline node type +2. Add `HighlightDelimiterProcessor` for `==text==` syntax +3. Add `Format.Highlight` with `SpanStyle(background = Color(0xFFFFEB3B))` +4. Pre-process: wrap highlights with `==text==` before parsing + +##### Floating Toolbar (for future enhancement beyond context menu) + +**Pattern:** `Popup` + custom `PopupPositionProvider` (matches existing `ChatPane.kt:584`): + +```kotlin +class MousePositionProvider(private val offset: IntOffset) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, windowSize: IntSize, + layoutDirection: LayoutDirection, popupContentSize: IntSize, + ): IntOffset { + val x = (offset.x - popupContentSize.width / 2) + .coerceIn(0, windowSize.width - popupContentSize.width) + val y = (offset.y - popupContentSize.height - 8) + .coerceIn(0, windowSize.height - popupContentSize.height) + return IntOffset(x, y) + } +} +``` + +Track mouse via `Modifier.pointerInput` + `awaitPointerEventScope` (pattern from `VideoControls.kt:89`). Dismiss on scroll via `LaunchedEffect(scrollState.isScrollInProgress)`. + +**Files:** +- `desktopApp/ui/highlights/FloatingHighlightToolbar.kt` — Popup with Highlight/Annotate buttons +- `desktopApp/ui/highlights/HighlightAnnotationDialog.kt` — AlertDialog for note text +- `desktopApp/ui/ArticleReaderScreen.kt` — Add context menu override, highlight state, inline rendering +- `commons/compose/markdown/RenderMarkdown.kt` — Add `highlights: List` param + +**UX flow (revised):** +1. User reads article, selects text by click-dragging +2. Right-click → context menu shows "Highlight" / "Highlight with Note" (via `LocalTextContextMenu`) +3. "Highlight" → saves immediately via DesktopHighlightStore, re-renders with bold/italic marker (v1) or yellow bg (v2) +4. "Highlight with Note" → opens HighlightAnnotationDialog → saves with note +5. Click existing highlight → popup with note / delete / publish toggle + +**Success criteria:** +- [ ] Right-click context menu shows "Highlight" option when text is selected +- [ ] Highlight saves and renders as visual marker in article +- [ ] Annotation dialog captures and saves notes +- [ ] Highlights persist across navigation (leave article and return) +- [ ] Best-effort match: highlights survive minor article edits (see brainstorm) +- [ ] Cmd+H keyboard shortcut works as alternative (select, copy, Cmd+H) + +--- + +#### Phase 3: My Highlights Screen + NIP-84 Publishing + +**Goal:** Aggregation screen showing all highlights grouped by article. Public/private toggle per highlight. + +**Files:** +- `desktopApp/ui/highlights/MyHighlightsScreen.kt` +- `desktopApp/ui/highlights/HighlightPublishAction.kt` — tag assembly + publish +- `desktopApp/ui/deck/DeckColumnType.kt` — Add `object MyHighlights` +- `desktopApp/ui/deck/DeckColumnContainer.kt` — Route MyHighlights +- `desktopApp/ui/deck/SinglePaneLayout.kt` — Add nav item +- `desktopApp/ui/deck/AddColumnDialog.kt` — Add to column options +- `desktopApp/ui/deck/ColumnHeader.kt` — Add icon + +##### NIP-84 Publishing (REVISED) + +**Gap found:** `HighlightEvent.create()` only takes `msg` + `signer` — doesn't accept source/context/comment tags. Need a wrapper: + +```kotlin +object HighlightPublishAction { + suspend fun publish( + highlightText: String, + articleEvent: LongTextNoteEvent, + note: String?, + signer: NostrSigner, + ): HighlightEvent { + val tags = TagArrayBuilder().apply { + // Article reference (addressable event) + add(ATag.assemble(30023, articleEvent.pubKey, articleEvent.dTag())) + // Article author attribution + add(PTag.assemble(articleEvent.pubKey, role = "author")) + // Optional annotation + note?.let { add(CommentTag.assemble(it)) } + // Surrounding paragraph as context + extractContext(articleEvent.content, highlightText)?.let { + add(ContextTag.assemble(it)) + } + // Alt text for non-NIP-84 clients + add(AltTag.assemble("Highlight: $highlightText")) + }.build() + + return HighlightEvent.create( + msg = highlightText, + tags = tags, + signer = signer, + ) + } + + /** Extract the paragraph containing the highlighted text */ + fun extractContext(content: String, highlightText: String): String? { + val paragraphs = content.split("\n\n") + return paragraphs.find { it.contains(highlightText) } + } +} +``` + +##### NIP-09 Deletion for Published Highlights + +```kotlin +suspend fun deleteHighlight(eventId: String, signer: NostrSigner): DeletionEvent { + return DeletionEvent.create( + deleteEvents = listOf(eventId), + deleteKinds = listOf(9802), + reason = "User deleted highlight", + signer = signer, + ) +} +``` + +**My Highlights screen layout:** +``` +┌─────────────────────────────────┐ +│ My Highlights │ +├─────────────────────────────────┤ +│ ▼ "Article Title One" │ +│ "highlighted text..." 🔒 │ +│ Note: my annotation ╳ │ +│ Mar 24, 2026 │ +│ │ +│ "another highlight..." 🌐 │ +│ Mar 24, 2026 │ +│ │ +│ ▼ "Article Title Two" │ +│ "highlighted passage..." 🔒 │ +│ Note: thoughts here ╳ │ +└─────────────────────────────────┘ +🔒 = private 🌐 = published +Click article title → navigates to article +Click 🔒 → publish to Nostr (NIP-84) +Click ╳ → delete (+ NIP-09 if published) +``` + +**Success criteria:** +- [ ] My Highlights accessible from sidebar nav +- [ ] Highlights grouped by article with collapsible sections +- [ ] Click article title navigates to article (onNavigateToArticle) +- [ ] Public/private toggle publishes NIP-84 event to relays +- [ ] Delete removes from local store + sends NIP-09 deletion for published +- [ ] Empty state when no highlights exist + +## Acceptance Criteria + +- [ ] Right-click selected text in article → "Highlight" in context menu +- [ ] Click "Highlight" → text marked visually, saved locally +- [ ] Click "Highlight with Note" → note dialog, then saved with annotation +- [ ] Cmd+H keyboard shortcut creates highlight from clipboard +- [ ] Highlights persist across app restarts +- [ ] Highlights survive article content updates (best-effort string match) +- [ ] "My Highlights" screen shows all highlights grouped by article +- [ ] Can toggle highlight public/private (publishes NIP-84 event) +- [ ] Can delete highlights (+ NIP-09 for published) +- [ ] Zoom (Cmd+/Cmd-) doesn't break highlight rendering +- [ ] Works in both single-pane and deck layout modes + +## Dependencies & Risks + +| Risk | Impact | Mitigation | Status | +|------|--------|------------|--------| +| `SelectionContainer` doesn't expose selection state | High | **Resolved:** Use `LocalTextContextMenu` override — official API, accesses `selectedText` directly | Mitigated | +| richtext library doesn't support `` or highlight formatting | Medium | **Resolved:** v1 uses bold/italic pre-processing; v2 adds `Format.Highlight` to fork | Mitigated | +| `LocalTextContextMenu.TextManager.selectedText` doesn't work across multiple `Text()` children | Medium | Test during Phase 2; fallback to clipboard-based Cmd+H shortcut | Open | +| `HighlightEvent.create()` doesn't accept custom tags | Low | **Resolved:** Create `HighlightPublishAction` wrapper with `TagArrayBuilder` | Mitigated | +| Preferences 8KB per-value limit | Low | Monitor; spill to file storage if needed | Open | + +## Sources & References + +### Origin + +- **Brainstorm:** [docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md](docs/brainstorms/2026-03-24-article-highlights-notes-brainstorm.md) + - Key decisions: private+public scope, select+popup UX, Preferences storage, own highlights only, best-effort persistence, My Highlights screen + +### Internal References + +- HighlightEvent protocol: `quartz/nip84Highlights/HighlightEvent.kt:137-141` +- DraftStore pattern: `desktopApp/service/drafts/DesktopDraftStore.kt` +- Event publishing: `desktopApp/ui/ArticleEditorScreen.kt:161-187` +- SelectionContainer: `desktopApp/ui/ArticleEditorScreen.kt:304` +- Context menu override: `LocalTextContextMenu` (Compose Desktop API) +- Popup pattern: `desktopApp/ui/chats/ChatPane.kt:584` +- Mouse tracking: `desktopApp/ui/media/VideoControls.kt:89` +- Android highlight rendering: `amethyst/ui/note/types/Highlight.kt:179-198` + +### External References + +- [Compose Desktop context menus](https://kotlinlang.org/docs/multiplatform/compose-desktop-context-menus.html) +- [NIP-84 spec (Highlights)](https://github.com/nostr-protocol/nips/blob/master/84.md) +- [NIP-09 spec (Event Deletion)](https://github.com/nostr-protocol/nips/blob/master/09.md) +- [commonmark-java DelimiterProcessor](https://github.com/commonmark/commonmark-java) diff --git a/docs/plans/2026-03-24-long-form-reads-manual-testing.md b/docs/plans/2026-03-24-long-form-reads-manual-testing.md new file mode 100644 index 000000000..93a9e01c6 --- /dev/null +++ b/docs/plans/2026-03-24-long-form-reads-manual-testing.md @@ -0,0 +1,156 @@ +--- +title: "Long-Form Reads — Manual Testing Sheet" +date: 2026-03-24 +branch: features/long-form-content +--- + +# Long-Form Reads — Manual Testing Sheet + +**Run:** `cd AmethystMultiplatform-long-form && ./gradlew :desktopApp:run` + +## Pre-Test Setup + +- [x] App launches without crash +- [x] Login with existing account (needs relay connections) +- [x] Navigate to Reads tab in sidebar + +--- + +## 1. ReadsScreen Feed + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 1.1 | Feed loads articles | Click Reads in sidebar | Long-form article cards appear | | +| 1.2 | Reading time shown | Check article cards | "X min read" displayed on each card | | +| 1.3 | Global/Following toggle | Click Global/Following chips | Feed switches between modes | | +| 1.4 | Article click navigates | Click any article card | ArticleReaderScreen opens (not ThreadScreen) | | + +--- + +## 2. ArticleReaderScreen + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 2.1 | Article loads | Click article from Reads feed | Content renders with markdown formatting | | +| 2.2 | Title + metadata | Check header area | Title, author name, reading time, date displayed | | +| 2.3 | Banner image | Open article with banner | Hero image renders at top (if article has `image` tag) | | +| 2.4 | Markdown headings | Scroll through article | H1-H3 render with different sizes | | +| 2.5 | Bold/italic/code | Check formatting | **Bold**, *italic*, `inline code` render correctly | | +| 2.6 | Code blocks | Find code block | Monospace font, distinct background | | +| 2.7 | Links clickable | Click a URL link | Opens in system browser | | +| 2.8 | nostr: links | Click a nostr: link | Does NOT open OS error dialog (scheme filtered) | | +| 2.9 | Images in content | Find article with images | Images render via Coil | | +| 2.10 | Back button | Click ← Back | Returns to ReadsScreen | | +| 2.11 | Content width | Check article body | Max ~680dp centered column | | +| 2.12 | Loading state | Open article (watch transition) | "Loading article..." shown briefly | | +| 2.13 | Error state | Open invalid address tag (if testable) | "Article not found" message | | + +--- + +## 3. Table of Contents + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 3.1 | ToC visible | Open article on wide window (>1100dp) | ToC sidebar appears on left with heading list | | +| 3.2 | ToC hidden | Resize window to <900dp | ToC sidebar disappears | | +| 3.3 | Heading hierarchy | Check ToC entries | H2 indented less than H3 | | +| 3.4 | Click heading | Click a ToC entry | Active entry highlights (scroll-to is TODO) | | +| 3.5 | No headings | Open article with no markdown headings | ToC sidebar not shown or empty | | + +--- + +## 4. Article Editor + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 4.1 | Navigate to editor | Drafts tab → New Draft (or via menu) | Editor screen opens with split pane | | +| 4.2 | Split pane | Check layout | Source left, preview right | | +| 4.3 | Live preview | Type markdown in source pane | Preview updates after ~300ms | | +| 4.4 | Toolbar: Bold | Click B button | `**text**` inserted at cursor | | +| 4.5 | Toolbar: Italic | Click I button | `*text*` inserted | | +| 4.6 | Toolbar: Heading | Click H button | `## ` inserted | | +| 4.7 | Toolbar: Link | Click link button | `[text](url)` inserted | | +| 4.8 | Toolbar: Code | Click code button | Backticks inserted | | +| 4.9 | Toolbar: Quote | Click quote button | `> ` inserted | | +| 4.10 | Metadata: Title | Enter title | Title field accepts input, max 256 chars | | +| 4.11 | Metadata: Summary | Enter summary | Summary field accepts input, max 1024 chars | | +| 4.12 | Metadata: Tags | Type tag + Enter | Tag chip added | | +| 4.13 | Metadata: Slug | Enter slug | Auto-sanitized (no special chars) | | +| 4.14 | Ctrl+S save | Press Ctrl+S (or Cmd+S) | Draft saved to disk | | +| 4.15 | Back button | Click ← Back | Returns to previous screen | | +| 4.16 | Preview link safety | Add `[click](javascript:alert(1))` in source | Link NOT clickable in preview (scheme blocked) | | + +--- + +## 5. Draft Storage + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 5.1 | Draft saved | Create draft, save, check filesystem | `~/.amethyst/drafts/.md` exists | | +| 5.2 | Index file | Check filesystem | `~/.amethyst/drafts/index.json` exists with metadata | | +| 5.3 | Drafts screen | Navigate to Drafts | Lists saved drafts with title, date | | +| 5.4 | Resume editing | Click a draft in list | Editor opens with content restored | | +| 5.5 | Delete draft | Click delete on a draft | Confirmation dialog → draft removed | | +| 5.6 | Slug sanitization | Try slug with `../` or special chars | Slug sanitized to safe characters | | +| 5.7 | Directory permissions | `ls -la ~/.amethyst/drafts/` | Dir permissions 700 (Unix) | | + +--- + +## 6. Publish + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 6.1 | Publish button | Fill title + content, click Publish | Event signed and sent to relays | | +| 6.2 | Publish feedback | After publish | Success snackbar / confirmation | | +| 6.3 | Published in feed | After publish, check Reads feed | Your article appears in Global feed | | +| 6.4 | Re-publish (replace) | Edit same draft, publish again | Article updated (same d-tag) | | +| 6.5 | Size limit | Try publishing >100KB content | Error message about content too large | | + +--- + +## 7. Typography (Visual) + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 7.1 | Body text | Read article body | Georgia-style serif, ~18sp, generous line height | | +| 7.2 | Content centered | Check horizontal layout | Content centered with max ~680dp width | | +| 7.3 | Dark mode | Check dark theme | Text ~#E0E0E0 on dark background, comfortable contrast | | +| 7.4 | Blockquotes | Find blockquote | Left border/indent, slightly larger text | | +| 7.5 | Tables | Find table | Renders with columns and rows | | + +--- + +## 8. Security Checks + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 8.1 | XSS in markdown | Article with `` | Rendered as literal text, no execution | | +| 8.2 | URI scheme: javascript | Link `[x](javascript:alert(1))` in article | Link not clickable / filtered | | +| 8.3 | URI scheme: file | Link `[x](file:///etc/passwd)` in article | Link not clickable / filtered | | +| 8.4 | Image URL validation | Article with `file:///etc/passwd` as banner | Image not loaded | | +| 8.5 | Slug traversal | Set slug to `../../.ssh/keys` | Sanitized to safe string | | + +--- + +## 9. Edge Cases + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| 9.1 | Empty article | Article with no content | Reader shows empty body, no crash | | +| 9.2 | No relays connected | Disconnect all relays → open article | "Connecting to relays..." loading state | | +| 9.3 | Very long article | Article with 10k+ words | Renders without freeze (may be slow) | | +| 9.4 | No banner image | Article without `image` tag | Header renders without banner, no crash | | +| 9.5 | No author metadata | Article from unknown pubkey | Shows pubkey hex, no profile pic | | +| 9.6 | Window resize | Resize during article reading | Layout adapts, ToC shows/hides | | + +--- + +## Notes + +_Record any bugs, unexpected behavior, or UX issues here:_ + +| | Issue | Severity | Notes | +|--|-------|----------|-------| +| | | | | +| | | | | +| | | | | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eeba3e5e5..b82ca191e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] accompanistAdaptive = "0.37.3" cachemapVersion = "0.2.4" -composeMultiplatform = "1.10.2" +composeMultiplatform = "1.10.3" activityCompose = "1.13.0" agp = "9.1.0" android-compileSdk = "36" @@ -20,7 +20,7 @@ coreKtx = "1.18.0" datastore = "1.2.1" devWhyolegCryptography = "0.5.0" espressoCore = "3.7.0" -firebaseBom = "34.10.0" +firebaseBom = "34.11.0" fragmentKtx = "1.8.9" gms = "4.4.4" jacksonModuleKotlin = "2.21.1" @@ -28,7 +28,7 @@ javaKeyring = "1.0.4" jtorctl = "0.4.5.7" junit = "4.13.2" kchesslib = "1.0.5" -kotlin = "2.3.10" +kotlin = "2.3.20" kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" kotlinxSerialization = "1.10.0" @@ -38,38 +38,37 @@ lightcompressor-enhanced = "1.6.0" markdown = "f92ef49c9d" material3 = "1.9.0" materialIconsExtended = "1.7.3" -media3 = "1.9.2" +media3 = "1.9.3" mockk = "1.14.9" kotlinx-coroutines-test = "1.10.2" netUrlencoderLibVersion = "1.6.0" navigationCompose = "2.9.7" okhttp = "5.3.2" runner = "1.7.0" -rfc3986 = "0.1.2" -secp256k1KmpJniAndroid = "0.22.0" +secp256k1KmpJniAndroid = "0.23.0" securityCryptoKtx = "1.1.0" -spotless = "8.3.0" +slf4j = "2.0.17" +spotless = "8.4.0" tarsosdsp = "2.5" torAndroid = "0.4.9.5.1" translate = "17.0.3" -jetbrainsCompose = "1.10.2" +jetbrainsCompose = "1.10.3" unifiedpush = "3.0.10" +uriReferenceKmp = "1.0" vico-charts-compose = "3.0.3" zelory = "3.0.1" zoomable = "2.11.1" vlcj = "4.8.3" -commonsImaging = "1.0.0-alpha5" +commonsImaging = "1.0.0-alpha6" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" androidxCamera = "1.5.3" androidxCollection = "1.6.0" -androidxExifinterface = "1.4.1" +androidxExifinterface = "1.4.2" kotlinTest = "2.3.0" core = "1.7.0" mavenPublish = "0.36.0" -spmForKmpVersion = "1.4.10" -stabilityAnalyser = "0.7.0" sqlite = "2.6.2" [libraries] @@ -124,6 +123,8 @@ coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" } +slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } +uri-reference-kmp = { module = "io.github.kotlingeekdev:uri-reference-kmp", version.ref = "uriReferenceKmp" } vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" } dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" } drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" } @@ -158,7 +159,6 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } -rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" } secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } @@ -167,7 +167,6 @@ tor-android = { module = "info.guardianproject:tor-android", version.ref = "torA unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" } vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" } -vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", version.ref = "vico-charts-compose" } zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } @@ -192,6 +191,4 @@ serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } -stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version.ref = "stabilityAnalyser" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } -frankois944-spmForKmp = { id = "io.github.frankois944.spmForKmp", version.ref = "spmForKmpVersion" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 1ed25d82e..5b49c924f 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -1,9 +1,5 @@ -@file:OptIn(ExperimentalSpmForKmpFeature::class) - import com.vanniktech.maven.publish.KotlinMultiplatform import com.vanniktech.maven.publish.SourcesJar -import io.github.frankois944.spmForKmp.swiftPackageConfig -import io.github.frankois944.spmForKmp.utils.ExperimentalSpmForKmpFeature import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest @@ -13,7 +9,6 @@ plugins { alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.serialization) alias(libs.plugins.vanniktech.mavenPublish) - alias(libs.plugins.frankois944.spmForKmp) } kotlin { @@ -26,7 +21,7 @@ kotlin { } } - androidLibrary { + android { namespace = "com.vitorpamplona.quartz" compileSdk = libs.versions.android.compileSdk @@ -64,29 +59,10 @@ kotlin { // https://developer.android.com/kotlin/multiplatform/migrate val xcfName = "quartz-kmpKit" - listOf( - iosArm64(), - iosSimulatorArm64(), - ).forEach { target -> - target.swiftPackageConfig(cinteropName = "swiftbridge") { - minIos = "17" - minMacos = "14" - dependency { - remotePackageVersion( - url = uri("https://github.com/swift-standards/swift-rfc-3986.git"), - packageName = "swift-rfc-3986", - products = { - add("RFC 3986") - }, - version = "0.1.0", - ) - } - } - } - iosArm64 { binaries.framework { baseName = xcfName + isStatic = true binaryOption("bundleId", "com.vitorpamplona.quartz") } } @@ -94,6 +70,7 @@ kotlin { iosSimulatorArm64 { binaries.framework { baseName = xcfName + isStatic = true binaryOption("bundleId", "com.vitorpamplona.quartz") } } @@ -141,6 +118,9 @@ kotlin { // SQLite KMP driver for event store api(libs.androidx.sqlite) implementation(libs.androidx.sqlite.bundled) + + // RFC3986 library(normalizes URLs) + api(libs.uri.reference.kmp) } } @@ -161,9 +141,6 @@ kotlin { dependsOn(commonMain.get()) dependencies { - // Normalizes URLs - api(libs.rfc3986.normalizer) - // Performant Parser of JSONs into Events api(libs.jackson.module.kotlin) @@ -296,7 +273,7 @@ mavenPublishing { coordinates( groupId = "com.vitorpamplona.quartz", artifactId = "quartz", - version = "1.05.1", + version = "1.06.3", ) // Configure publishing to Maven Central diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt index 35f268033..33adaa885 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/Nip46Test.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Ignore @@ -41,7 +40,7 @@ internal class Nip46Test { val dummyEvent = EventTemplate( - createdAt = TimeUtils.now(), + createdAt = 1753988264, kind = 1, tags = emptyArray(), content = "test", @@ -49,12 +48,12 @@ internal class Nip46Test { val dummyEventSigned = TextNoteEvent( - id = "", - pubKey = "", - createdAt = TimeUtils.now(), + id = "0b6d941c46411a95edb1c93da7ad6ca26370497d8c7b7d621f5cb59f48841bad", + pubKey = "6dd3b72e325da7383b275eef1c66131ba4664326e162bc060527509b4e33ae43", + createdAt = 1753988264, tags = emptyArray(), content = "test", - sig = "", + sig = "ec39e60722a083cccbd2d82d2827e13f5499fa7cbcedac5b76011a844c077473adb629d50d01fab147835ac6c8a3d5ba9aaddd87d6723f0c3c864b9119fc4356", ) suspend fun encodeDecodeEvent(req: T): T { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt index 9e707b75b..820298930 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.experimental.attestations.attestation import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus -import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent @@ -32,17 +31,14 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider -import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.serialization.json.JsonNull.content @Immutable class AttestationEvent( @@ -54,8 +50,7 @@ class AttestationEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, - AddressHintProvider, - PubKeyHintProvider { + AddressHintProvider { override fun eventHints(): List = tags.mapNotNull(ETag::parseAsHint) override fun linkedEventIds(): List = tags.mapNotNull(ETag::parseId) @@ -64,12 +59,6 @@ class AttestationEvent( override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - - override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) - - fun validity() = tags.validity() - fun status() = tags.status() fun validFrom() = tags.validFrom() @@ -94,10 +83,6 @@ class AttestationEvent( fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse) - fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey) - - fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse) - companion object { const val KIND = 31871 const val ALT_DESCRIPTION = "Attestation" @@ -106,7 +91,6 @@ class AttestationEvent( dTagId: String, about: EventHintBundle, content: String = "", - validity: Validity? = null, status: AttestationStatus? = null, validFrom: Long? = null, validTo: Long? = null, @@ -117,7 +101,6 @@ class AttestationEvent( alt(ALT_DESCRIPTION) dTag(dTagId) about(about) - validity?.let { validity(it) } status?.let { status(it) } validFrom?.let { validFrom(it) } validTo?.let { validTo(it) } @@ -129,7 +112,6 @@ class AttestationEvent( dTagId: String, about: EventHintBundle, content: String = "", - validity: Validity? = null, status: AttestationStatus? = null, validFrom: Long? = null, validTo: Long? = null, @@ -140,7 +122,6 @@ class AttestationEvent( alt(ALT_DESCRIPTION) dTag(dTagId) aboutReplaceable(about) - validity?.let { validity(it) } status?.let { status(it) } validFrom?.let { validFrom(it) } validTo?.let { validTo(it) } @@ -152,7 +133,6 @@ class AttestationEvent( dTagId: String, about: EventHintBundle, content: String = "", - validity: Validity? = null, status: AttestationStatus? = null, validFrom: Long? = null, validTo: Long? = null, @@ -163,7 +143,6 @@ class AttestationEvent( alt(ALT_DESCRIPTION) dTag(dTagId) aboutAddressable(about) - validity?.let { validity(it) } status?.let { status(it) } validFrom?.let { validFrom(it) } validTo?.let { validTo(it) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt index b64090b6f..3ef5d1a5b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt @@ -25,8 +25,6 @@ import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Reque import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.StatusTag import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidFromTag import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidToTag -import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity -import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidityTag import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent @@ -36,8 +34,6 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -fun TagArrayBuilder.validity(validity: Validity) = addUnique(ValidityTag.assemble(validity)) - fun TagArrayBuilder.status(status: AttestationStatus) = addUnique(StatusTag.assemble(status)) fun TagArrayBuilder.validFrom(timestamp: Long) = addUnique(ValidFromTag.assemble(timestamp)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt index 2b545818e..8e71b066b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt @@ -24,11 +24,8 @@ import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Reque import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.StatusTag import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidFromTag import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidToTag -import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidityTag import com.vitorpamplona.quartz.nip01Core.core.TagArray -fun TagArray.validity() = firstNotNullOfOrNull(ValidityTag::parse) - fun TagArray.status() = firstNotNullOfOrNull(StatusTag::parse) fun TagArray.validFrom() = firstNotNullOfOrNull(ValidFromTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/StatusTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/StatusTag.kt index 424c698db..853f84c7f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/StatusTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/StatusTag.kt @@ -26,10 +26,9 @@ import com.vitorpamplona.quartz.utils.ensure enum class AttestationStatus( val code: String, ) { - ACCEPTED("accepted"), - REJECTED("rejected"), VERIFYING("verifying"), - VERIFIED("verified"), + VALID("valid"), + INVALID("invalid"), REVOKED("revoked"), } @@ -45,10 +44,9 @@ class StatusTag { ensure(tag[1].isNotEmpty()) { return null } return when (tag[1]) { - AttestationStatus.ACCEPTED.code -> AttestationStatus.ACCEPTED - AttestationStatus.REJECTED.code -> AttestationStatus.REJECTED AttestationStatus.VERIFYING.code -> AttestationStatus.VERIFYING - AttestationStatus.VERIFIED.code -> AttestationStatus.VERIFIED + AttestationStatus.VALID.code -> AttestationStatus.VALID + AttestationStatus.INVALID.code -> AttestationStatus.INVALID AttestationStatus.REVOKED.code -> AttestationStatus.REVOKED else -> null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidityTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidityTag.kt deleted file mode 100644 index 72d7a23c5..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/ValidityTag.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * 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.experimental.attestations.attestation.tags - -import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.ensure - -enum class Validity( - val code: String, -) { - VALID("valid"), - INVALID("invalid"), -} - -class ValidityTag { - companion object { - const val TAG_NAME = "v" - - fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() - - fun parse(tag: Array): Validity? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - - return when (tag[1]) { - Validity.VALID.code -> Validity.VALID - Validity.INVALID.code -> Validity.INVALID - else -> null - } - } - - fun assemble(validity: Validity) = arrayOf(TAG_NAME, validity.code) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt index 49bd5109f..879bcf3c7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/AttestorProficiencyEvent.kt @@ -40,7 +40,7 @@ class AttestorProficiencyEvent( ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun kinds() = tags.kinds() - fun description() = tags.description() + fun description() = content.ifBlank { null } companion object { const val KIND = 11871 @@ -51,10 +51,9 @@ class AttestorProficiencyEvent( description: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { + ) = eventTemplate(KIND, description ?: "", createdAt) { alt(ALT_DESCRIPTION) kinds(kinds) - description?.let { desc(it) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayBuilderExt.kt index fc617bb51..652450801 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayBuilderExt.kt @@ -20,11 +20,8 @@ */ package com.vitorpamplona.quartz.experimental.attestations.proficiency -import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder fun TagArrayBuilder.kinds(kinds: List) = addAll(KindTag.assemble(kinds)) - -fun TagArrayBuilder.desc(description: String) = addUnique(DescriptionTag.assemble(description)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayExt.kt index a6dfab465..e9e9fe1b3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/proficiency/TagArrayExt.kt @@ -20,10 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.attestations.proficiency -import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag import com.vitorpamplona.quartz.nip01Core.core.TagArray fun TagArray.kinds() = mapNotNull(KindTag::parse) - -fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt index 704d85680..2a3a4bbf5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/AttestorRecommendationEvent.kt @@ -41,7 +41,7 @@ class AttestorRecommendationEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun kinds() = tags.kinds() - fun description() = tags.description() + fun description() = content.ifBlank { null } companion object { const val KIND = 31873 @@ -53,11 +53,10 @@ class AttestorRecommendationEvent( description: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { + ) = eventTemplate(KIND, description ?: "", createdAt) { alt(ALT_DESCRIPTION) dTag(attestorPubKey) kinds(kinds) - description?.let { desc(it) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayBuilderExt.kt index 23dfbc7e7..e1dfcca6b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayBuilderExt.kt @@ -20,11 +20,8 @@ */ package com.vitorpamplona.quartz.experimental.attestations.recommendation -import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder fun TagArrayBuilder.kinds(kinds: List) = addAll(KindTag.assemble(kinds)) - -fun TagArrayBuilder.desc(description: String) = addUnique(DescriptionTag.assemble(description)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayExt.kt index d784f3d9e..d04e04bd8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/TagArrayExt.kt @@ -20,10 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.attestations.recommendation -import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.DescriptionTag import com.vitorpamplona.quartz.experimental.attestations.recommendation.tags.KindTag import com.vitorpamplona.quartz.nip01Core.core.TagArray fun TagArray.kinds() = mapNotNull(KindTag::parse) - -fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt deleted file mode 100644 index 62b539920..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/recommendation/tags/DescriptionTag.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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.experimental.attestations.recommendation.tags - -import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.ensure - -class DescriptionTag { - companion object { - const val TAG_NAME = "desc" - - fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() - - fun parse(tag: Array): String? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - return tag[1] - } - - fun assemble(description: String) = arrayOf(TAG_NAME, description) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt index f388b1dcd..9241a884d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt @@ -72,6 +72,15 @@ class TagArrayBuilder { return this } + fun addUniqueValueIfNew(tag: Array): TagArrayBuilder { + if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this + val list = tagList.getOrPut(tag[0], ::mutableListOf) + if (list.none { it.valueOrNull() == tag[1] }) { + list.add(tag) + } + return this + } + fun addAll(tag: List>): TagArrayBuilder { tag.forEach(::add) return this @@ -82,6 +91,16 @@ class TagArrayBuilder { return this } + fun addAllUnique(tag: Array>): TagArrayBuilder { + tag.forEach(::addUnique) + return this + } + + fun addAllUniqueValueIfNew(tag: List>): TagArrayBuilder { + tag.forEach(::addUniqueValueIfNew) + return this + } + fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray() fun build() = toTypedArray() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt index ed0ff95ae..27cc372c3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.kotlinSerialization import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import com.vitorpamplona.quartz.nip45Count.HyperLogLog import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor @@ -57,6 +58,7 @@ object CountResultKSerializer : KSerializer { put("count", value.count) // Matches Jackson's CountResultSerializer which writes "pubkey" for approximate put("pubkey", value.approximate) + value.hll?.let { put("hll", HyperLogLog.encode(it)) } } override fun deserialize(decoder: Decoder): CountResult { @@ -68,5 +70,6 @@ object CountResultKSerializer : KSerializer { CountResult( count = jsonObject["count"]!!.jsonPrimitive.int, approximate = jsonObject["approximate"]?.jsonPrimitive?.boolean ?: false, + hll = jsonObject["hll"]?.jsonPrimitive?.content?.let { HyperLogLog.decode(it) }, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt index 15dd206ff..2816642c8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip45Count.HyperLogLog import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.withTimeoutOrNull @@ -137,3 +138,50 @@ suspend fun INostrClient.queryCountSuspend( return results } + +/** + * Queries multiple relays for a COUNT and merges the HyperLogLog + * registers from all responses to produce a single merged estimate. + * + * If any relay returns HLL data, the results are merged by taking + * the maximum register value across all relays, and the cardinality + * is re-estimated from the merged registers. + * + * If no relay returns HLL data, falls back to the maximum count + * reported by any relay. + * + * @param relays List of relays to query. + * @param filter The filter to count against. + * @param timeoutMs How long to wait for all responses (default 15 s). + * @return A merged [CountResult], or `null` if no relay responded. + */ +suspend fun INostrClient.queryCountMergedHll( + relays: List, + filter: Filter, + timeoutMs: Long = 15_000, +): CountResult? { + if (relays.isEmpty()) return null + + val results = + queryCountSuspend( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = timeoutMs, + ) + + if (results.isEmpty()) return null + + val hlls = results.values.mapNotNull { it.hll } + + return if (hlls.isNotEmpty()) { + val merged = HyperLogLog.merge(hlls) + val estimate = HyperLogLog.estimate(merged) + CountResult( + count = estimate.toInt(), + approximate = true, + hll = merged, + ) + } else { + // No HLL data - use the maximum count from any relay + results.values.maxByOrNull { it.count } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt index ceb4b36de..303f8727f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt @@ -63,7 +63,7 @@ class RelayLogger( is OkMessage -> if (debugReceiving) Log.d(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}") is AuthMessage -> if (debugReceiving) Log.d(logTag, "Auth: ${msg.challenge}") is NotifyMessage -> if (debugReceiving) Log.d(logTag, "Notify: ${msg.message}") - is CountMessage -> if (debugReceiving) Log.d(logTag, "Count: ${msg.result.count} approx: ${msg.result.approximate}") + is CountMessage -> if (debugReceiving) Log.d(logTag, "Count: ${msg.result.count} approx: ${msg.result.approximate} hll: ${msg.result.hll != null}") is ClosedMessage -> Log.w(logTag, "Closed: ${msg.subId} ${msg.message}") } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt index a1f929def..abda66eb1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt @@ -36,7 +36,7 @@ class RelayOfflineTracker( const val TAG = "RelayOfflineTracker" } - val cannotConnectRelays = mutableSetOf() + var cannotConnectRelays = setOf() private val clientListener = object : IRelayClientListener { @@ -45,14 +45,14 @@ class RelayOfflineTracker( pingMillis: Int, compressed: Boolean, ) { - cannotConnectRelays.remove(relay.url) + cannotConnectRelays -= relay.url } override fun onCannotConnect( relay: IRelayClient, errorMessage: String, ) { - cannotConnectRelays.add(relay.url) + cannotConnectRelays += relay.url } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountMessage.kt index f6661bc23..27915753c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountMessage.kt @@ -34,4 +34,5 @@ class CountMessage( class CountResult( val count: Int, val approximate: Boolean = false, + val hll: ByteArray? = null, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt index 2446661ed..91d38f0ae 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt @@ -52,6 +52,12 @@ class ReferenceTag { fun assemble(url: String) = arrayOf(TAG_NAME, HttpUrlFormatter.normalize(url)) - fun assemble(urls: List): List> = urls.mapTo(HashSet()) { HttpUrlFormatter.normalize(it) }.map { arrayOf(TAG_NAME, it) } + fun assemble(urls: List): List> = + urls + .mapTo(HashSet()) { + HttpUrlFormatter.normalize(it) + }.map { + arrayOf(TAG_NAME, it) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt index ad56ebffa..c4b864ec5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt @@ -20,15 +20,25 @@ */ package com.vitorpamplona.quartz.nip10Notes.content -import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter -import com.vitorpamplona.quartz.utils.fastFindURLs +import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.utils.startsWithAny +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector -fun findURLs(text: String) = fastFindURLs(text) +val rejectSchemes = + listOf( + DualCase("ftp:"), + DualCase("ftps:"), + DualCase("ws:"), + DualCase("wss:"), + DualCase("nostr:"), + DualCase("blossom:"), + ) -fun buildUrlRefs(urls: List): List> = - urls - .mapTo(HashSet()) { url -> - HttpUrlFormatter.normalize(url) - }.map { - arrayOf("r", it) +fun findURLs(text: String) = + UrlDetector(text).detect().mapNotNull { + if (it.originalUrl.startsWithAny(rejectSchemes)) { + null + } else { + it.originalUrl } + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt index 7a5bf1d0f..5fc0de6e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt @@ -31,18 +31,18 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -fun TagArrayBuilder.quote(tag: QTag) = add(tag.toTagArray()) +fun TagArrayBuilder.quote(tag: QTag) = addUniqueValueIfNew(tag.toTagArray()) -fun TagArrayBuilder.quotes(tag: List) = addAll(tag.map { it.toTagArray() }) +fun TagArrayBuilder.quotes(tag: List) = addAllUniqueValueIfNew(tag.map { it.toTagArray() }) fun TagArrayBuilder.quote(entity: Entity) = when (entity) { - is NNote -> add(entity.toQuoteTagArray()) - is NEvent -> add(entity.toQuoteTagArray()) - is NAddress -> add(entity.toQuoteTagArray()) - is NEmbed -> add(entity.toQuoteTagArray()) - is NPub -> add(entity.toQuoteTagArray()) - is NProfile -> add(entity.toQuoteTagArray()) + is NNote -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NEvent -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NAddress -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NEmbed -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NPub -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NProfile -> addUniqueValueIfNew(entity.toQuoteTagArray()) else -> this } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/HyperLogLog.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/HyperLogLog.kt new file mode 100644 index 000000000..0b8097019 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip45Count/HyperLogLog.kt @@ -0,0 +1,179 @@ +/* + * 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.nip45Count + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.math.ln +import kotlin.math.pow + +/** + * NIP-45 HyperLogLog implementation for probabilistic cardinality estimation. + * + * Uses 256 registers (8-bit precision, p=8) as specified by NIP-45. + * Each register is a single byte storing the maximum number of leading + * zero bits + 1 observed for events mapping to that register. + * + * HLL values are transmitted as 512-character hex strings (256 bytes). + */ +object HyperLogLog { + const val NUM_REGISTERS = 256 + + // Bias correction constant for m=256: α = 0.7213 / (1 + 1.079/m) + private val ALPHA_M: Double = 0.7213 / (1.0 + 1.079 / NUM_REGISTERS) + + /** + * Merges multiple HLL register arrays by taking the maximum value + * for each register position across all inputs. + * + * @param hlls List of 256-byte register arrays from different relays + * @return Merged 256-byte register array + */ + fun merge(hlls: List): ByteArray { + val merged = ByteArray(NUM_REGISTERS) + for (hll in hlls) { + for (i in 0 until NUM_REGISTERS) { + val value = hll[i].toInt() and 0xFF + val current = merged[i].toInt() and 0xFF + if (value > current) { + merged[i] = value.toByte() + } + } + } + return merged + } + + /** + * Estimates the cardinality from an HLL register array using the + * standard HyperLogLog algorithm with small/large range corrections. + * + * @param registers 256-byte HLL register array + * @return Estimated cardinality + */ + fun estimate(registers: ByteArray): Long { + var harmonicSum = 0.0 + var zeroRegisters = 0 + + for (i in 0 until NUM_REGISTERS) { + val value = registers[i].toInt() and 0xFF + harmonicSum += 2.0.pow(-value.toDouble()) + if (value == 0) zeroRegisters++ + } + + val m = NUM_REGISTERS.toDouble() + var estimate = ALPHA_M * m * m / harmonicSum + + // Small range correction: use linear counting when estimate is small + // and there are empty registers + if (estimate <= 2.5 * m && zeroRegisters > 0) { + estimate = m * ln(m / zeroRegisters.toDouble()) + } + + return estimate.toLong() + } + + /** + * Decodes a 512-character hex string into a 256-byte HLL register array. + * + * @param hex 512-character hex string + * @return 256-byte register array, or null if the hex string is invalid + */ + fun decode(hex: String): ByteArray? { + if (hex.length != NUM_REGISTERS * 2) return null + return try { + Hex.decode(hex) + } catch (_: Exception) { + null + } + } + + /** + * Encodes a 256-byte HLL register array into a 512-character hex string. + * + * @param registers 256-byte register array + * @return 512-character hex string + */ + fun encode(registers: ByteArray): String = Hex.encode(registers) + + /** + * Computes the deterministic offset for a given filter, as specified + * by NIP-45. The offset determines which byte of event pubkeys is + * used as the register index. + * + * Algorithm: + * 1. Extract the first item from the filter's first tag attribute + * 2. Convert to a 64-character hex string: + * - If already a 64-char hex (event ID or pubkey): use directly + * - If an address (kind:pubkey:dtag): extract the pubkey + * - Otherwise: SHA-256 hash the value + * 3. Take the hex character at position 32 + * 4. Parse as base-16 and add 8 + * + * @param filter The filter to compute offset for + * @return The offset (8-23), or null if the filter has no tag attributes + */ + fun computeOffset(filter: Filter): Int? { + val firstTagValue = extractFirstTagValue(filter) ?: return null + val hex64 = toHex64(firstTagValue) ?: return null + val charAtPos32 = hex64[32] + val hexValue = charAtPos32.digitToIntOrNull(16) ?: return null + return hexValue + 8 + } + + /** + * Extracts the first value from the first tag attribute in the filter. + */ + private fun extractFirstTagValue(filter: Filter): String? { + val tags = filter.tags ?: return null + for ((_, values) in tags) { + if (values.isNotEmpty()) { + return values[0] + } + } + return null + } + + /** + * Converts a tag value to a 64-character hex string. + * + * - If it's already a 64-char hex string: return as-is + * - If it's a Nostr address (kind:pubkey:dtag): extract pubkey + * - Otherwise: SHA-256 hash and hex-encode + */ + private fun toHex64(value: String): String? { + // Already a 64-char hex (event ID or pubkey) + if (value.length == 64 && Hex.isHex(value)) { + return value + } + + // Try to parse as address (kind:pubkey:dtag) + val address = Address.parse(value) + if (address != null) { + return address.pubKeyHex + } + + // Fall back to SHA-256 + val hash = sha256(value.encodeToByteArray()) + return Hex.encode(hash) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt index 53eb83d7f..73a106017 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt @@ -32,10 +32,22 @@ import kotlinx.serialization.Serializable class Nip47WalletConnect { companion object { + fun fix(uri: String): String { + var newUri = uri + + // POCO Phones seem to remove the + sign from the scheme + if (uri.startsWith("nostr walletconnect")) { + newUri = uri.replaceFirst("nostr walletconnect", "nostr+walletconnect") + } else if (uri.startsWith("amethyst walletconnect")) { + newUri = uri.replaceFirst("amethyst walletconnect", "amethyst+walletconnect") + } + + return newUri + } + fun parse(uri: String): Nip47URINorm { // nostr+walletconnect://b889ff5b...?relay=wss%3A%2F%2Frelay.damus.io&secret=...&lud16=user@example.com - - val url = UriParser(uri) + val url = UriParser(fix(uri)) if (url.scheme() != "nostrwalletconnect" && url.scheme() != "nostr+walletconnect" && url.scheme() != "amethyst+walletconnect") { throw IllegalArgumentException("Not a Wallet Connect QR Code") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt index 66f140b47..ca48ffada 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt @@ -21,6 +21,5 @@ package com.vitorpamplona.quartz.nip89AppHandlers.clientTag import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount -fun Event.client() = tags.zapraiserAmount() +fun Event.client() = tags.client() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt index 7af90417f..5403ffca5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt @@ -24,9 +24,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint -fun TagArrayBuilder.client(name: String) = addUnique(ClientTag.assemble(name)) +fun TagArrayBuilder.client(name: String) = addUnique(ClientTag.assemble(name)) -fun TagArrayBuilder.client( +fun TagArrayBuilder.client( name: String, address: AddressHint, ) = addUnique(ClientTag.assemble(name, address.addressId, address.relay)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt index 3503c2edf..a0bc76d7f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt @@ -20,12 +20,47 @@ */ package com.vitorpamplona.quartz.utils -expect object Rfc3986 { - fun normalize(uri: String): String +import io.kotlingeekdev.urireference.URIReference - fun isValidUrl(url: String): Boolean +object Rfc3986 { + fun normalize(uri: String): String = URIReference.parse(uri).normalize().toString() - fun normalizeAndRemoveFragment(url: String): String + fun isValidUrl(url: String): Boolean = + runCatching { + URIReference.parse(url) + }.isSuccess - fun host(url: String): String + fun normalizeAndRemoveFragment(url: String): String = + URIReference + .parse(url) + .normalize()!! + .toStringNoFragment() + .internIfPossible() + + fun host(url: String): String = + URIReference + .parse(url) + .host + ?.value + .toString() +} + +fun URIReference.toStringSchemeHost(): String { + val sb = StringBuilder() + + if (scheme != null) sb.append(scheme).append(":") + if (authority != null) sb.append("//").append(authority.toString()) + + return sb.toString() +} + +fun URIReference.toStringNoFragment(): String { + val sb = StringBuilder() + + if (scheme != null) sb.append(scheme).append(":") + if (host != null) sb.append("//").append(host.toString()) + if (path != null) sb.append(path) + if (query != null) sb.append("?").append(query) + + return sb.toString() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt index 538d9ea32..46927f771 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt @@ -228,11 +228,17 @@ class Url( return null } + val startIndex = urlMarker.indexOf(part) + if (startIndex < 0 || startIndex >= originalUrl.length) { + return null + } + val nextPart = nextExistingPart(part) return if (nextPart == null) { - originalUrl.substring(urlMarker.indexOf(part)) + originalUrl.substring(startIndex) } else { - originalUrl.substring(urlMarker.indexOf(part), urlMarker.indexOf(nextPart)) + val endIndex = urlMarker.indexOf(nextPart) + originalUrl.substring(startIndex, minOf(endIndex, originalUrl.length)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt index ebe64927c..d91147172 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt @@ -298,11 +298,22 @@ class DomainNameReader( topLevelLength = currentLabelLength } - var lastWasAscii: Boolean? = null + var lastWasAscii: Boolean? = + if (current.isNullOrEmpty()) { + null + } else { + val last = current.last() + if (isDot(last)) { + null + } else { + last.code < INTERNATIONAL_CHAR_START + } + } var isAscii = false while (!done && !reader.eof()) { val curr: Char = reader.read() + isAscii = curr.code < INTERNATIONAL_CHAR_START if (lastWasAscii == null) { lastWasAscii = isAscii @@ -765,7 +776,7 @@ class DomainNameReader( * The start of the utf character code table which indicates that this character is an international character. * Everything below this value is either a-z,A-Z,0-9 or symbols that are not included in domain name. */ - private const val INTERNATIONAL_CHAR_START = 192 + const val INTERNATIONAL_CHAR_START = 192 /** * The maximum length of each label in the domain name. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt index f572721a5..0c1f1fa8f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.utils.urldetector.detection import com.vitorpamplona.quartz.utils.urldetector.Url import com.vitorpamplona.quartz.utils.urldetector.UrlMarker import com.vitorpamplona.quartz.utils.urldetector.UrlPart +import com.vitorpamplona.quartz.utils.urldetector.detection.DomainNameReader.Companion.INTERNATIONAL_CHAR_START import kotlin.math.max import kotlin.text.deleteRange @@ -84,6 +85,9 @@ class UrlDetector( var length = 0 var position = 0 + var lastWasAscii: Boolean? = null + var isAscii = false + // until end of string read the contents while (!reader.eof()) { // read the next char to process. @@ -96,6 +100,7 @@ class UrlDetector( } readEnd(ReadEndState.InvalidUrl) length = 0 + lastWasAscii = null } '%' -> { @@ -116,6 +121,7 @@ class UrlDetector( length = 0 } } + lastWasAscii = null } '\u3002', '\uFF0E', '\uFF61', '.' -> { @@ -125,6 +131,7 @@ class UrlDetector( readEnd(ReadEndState.InvalidUrl) } length = 0 + lastWasAscii = null } '@' -> { @@ -136,6 +143,7 @@ class UrlDetector( } length = 0 } + lastWasAscii = null } '[' -> { @@ -153,6 +161,7 @@ class UrlDetector( reader.seek(beginning) } length = 0 + lastWasAscii = null } '/' -> { @@ -180,15 +189,29 @@ class UrlDetector( hasScheme = readHtml5Root() length = buffer.length } + lastWasAscii = null } ':' -> { // add the ":" to the url and check for scheme/username buffer.append(curr) length = processColon(length) + lastWasAscii = null } else -> { + isAscii = curr.code < INTERNATIONAL_CHAR_START + if (lastWasAscii == null) { + lastWasAscii = isAscii + } else if (isAscii != lastWasAscii) { + // threat changes in char as a space + if (buffer.isNotEmpty() && hasScheme) { + reader.goBack() + readDomainName(buffer.substring(length)) + } + length = 0 + } + buffer.append(curr) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt index aa0dda712..29c41ee4e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt @@ -33,9 +33,7 @@ import kotlin.test.Test import kotlin.test.assertEquals class QueryAssemblerTest : BaseDBTest() { - val hasher = - _root_ide_package_.com.vitorpamplona.quartz.nip01Core.store.sqlite - .TagNameValueHasher(0) + val hasher = TagNameValueHasher(0) val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt index 0d526ab17..02fbdc7b4 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz.nip10Notes.urls import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.utils.fastFindURLs import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals @@ -31,7 +30,7 @@ class UrlsDetectorTest { @Test fun detectUrlNumber() { - val detectedLinks = fastFindURLs(testSentence) + val detectedLinks = findURLs(testSentence) assertEquals(2, detectedLinks.size) } @@ -41,4 +40,14 @@ class UrlsDetectorTest { assertContains(detectedLinks, "https://mysite.xyz") assertContains(detectedLinks, "https://myblog.xyz") } + + /** + * Regression test for PR #1907: the Japanese phrase "今北産業" must not crash the URL + * detector with a StringIndexOutOfBoundsException and must return no URLs. + */ + @Test + fun doesNotCrashOnJapaneseText() { + val detectedLinks = findURLs("今北産業") + assertEquals(0, detectedLinks.size) + } } diff --git a/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt similarity index 99% rename from quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt index 4173f9e22..fd6bcc367 100644 --- a/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt @@ -30,7 +30,7 @@ import kotlin.test.assertEquals * NIP-44v2 tests verifying conversation key derivation, ECDH symmetry, * and encrypt/decrypt round-trips on JVM Desktop. */ -class Nip44v2JvmTest { +class Nip44v2Test { @Test fun conversationKeyFromSpecVector1() { val nip44v2 = Nip44v2() diff --git a/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt similarity index 92% rename from quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt index 90117ac43..4e9a209c0 100644 --- a/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt @@ -42,9 +42,9 @@ class LnZapPaymentRequestNip44EventTest { val walletServicePubkey: HexKey = walletKeyPair.pubKey.toHexKey() - val request = GetBalanceMethod.create() + val request = GetBalanceMethod.Companion.create() val event = - LnZapPaymentRequestEvent.createRequest( + LnZapPaymentRequestEvent.Companion.createRequest( request = request, walletServicePubkey = walletServicePubkey, signer = clientSigner, @@ -66,9 +66,9 @@ class LnZapPaymentRequestNip44EventTest { val walletServicePubkey: HexKey = walletKeyPair.pubKey.toHexKey() - val request = GetInfoMethod.create() + val request = GetInfoMethod.Companion.create() val event = - LnZapPaymentRequestEvent.createRequest( + LnZapPaymentRequestEvent.Companion.createRequest( request = request, walletServicePubkey = walletServicePubkey, signer = clientSigner, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt new file mode 100644 index 000000000..2cf13477b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt @@ -0,0 +1,64 @@ +/* + * 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.utils.urldetector + +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * Regression tests for PR #1907: StringIndexOutOfBoundsException in [Url.getPart] when + * processing the Japanese text "今北産業". + */ +class UrlTest { + /** + * Regression: detecting URLs in "今北産業" must not throw and must return no URLs. + */ + @Test + fun detectingImakitaSangyoDoesNotThrow() { + val urls = UrlDetector("今北産業").detect() + assertEquals(0, urls.size) + } + + /** + * Regression: constructing a Url with "今北産業" as the original string and a HOST + * marker at 0 with PORT at the string length (simulating a trimmed trailing character) + * must not throw StringIndexOutOfBoundsException when accessing any property. + * + * "今北産業" has length 4. PORT at 4 == length triggers the startIndex >= length guard + * added to getPart() in PR #1907. + */ + @Test + fun urlPropertiesDoNotThrowForImakitaSangyoWithOutOfRangeMarker() { + val marker = UrlMarker() + marker.setIndex(UrlPart.HOST, 0) + marker.setIndex(UrlPart.PORT, 4) // == "今北産業".length + val url = marker.createUrl("今北産業") + + assertNotNull(url.scheme) + assertNotNull(url.host) + assertNotNull(url.path) + assertNotNull(url.query) + assertNotNull(url.fragment) + assertEquals(443, url.port) // getPart(PORT) returns null → -1 + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt index 9e73fd43f..7bdf5afc0 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt @@ -739,6 +739,37 @@ class UriDetectionTest { runTest("blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net", "blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net") } + @Test + fun testBrokenCaseInProduction() { + runTest("今北産業") + runTest("http://test.com今北産業", "http://test.com") + runTest("ftp://test.com今北産業", "ftp://test.com") + runTest("test.com今北産業", "test.com") + runTest("wss://test.com今北産業", "wss://test.com") + runTest("blossom:test.com今北産業", "blossom:test.com") + runTest("nostr:test.com今北産業", "nostr:test.com") + runTest("nostr:test今北産業", "nostr:test") + runTest("nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m") + + runTest("今北産業http://test.com", "http://test.com") + runTest("今北産業ftp://test.com", "ftp://test.com") + runTest("今北産業test.com", "test.com") + runTest("今北産業wss://test.com", "wss://test.com") + runTest("今北産業blossom:test.com", "blossom:test.com") + runTest("今北産業nostr:test.com", "nostr:test.com") + runTest("今北産業nostr:test", "nostr:test") + runTest("今北産業nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m") + + runTest("今北産業http://test.com今北産業", "http://test.com") + runTest("今北産業ftp://test.com今北産業", "ftp://test.com") + runTest("今北産業test.com今北産業", "test.com") + runTest("今北産業wss://test.com今北産業", "wss://test.com") + runTest("今北産業blossom:test.com今北産業", "blossom:test.com") + runTest("今北産業nostr:test.com今北産業", "nostr:test.com") + runTest("今北産業nostr:test今北産業", "nostr:test") + runTest("今北産業nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m") + } + @Test fun testFullText() { val text = diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt deleted file mode 100644 index c782de2f9..000000000 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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.utils - -import kotlinx.cinterop.ExperimentalForeignApi -import platform.Foundation.NSURLComponents -import swiftbridge.Rfc3986UriBridge - -@OptIn(ExperimentalForeignApi::class) -actual object Rfc3986 { - private val rfc3986UriBridge = Rfc3986UriBridge() - - actual fun normalize(uri: String): String = - rfc3986UriBridge - .normalizeUrlWithUrl(uri, null) - ?.let { if (it.last() == '/') it else "$it/" } ?: throw Exception("Could not normalize URI: $uri") - - actual fun isValidUrl(url: String): Boolean = rfc3986UriBridge.isUrlValidWithUrl(url) - - actual fun normalizeAndRemoveFragment(url: String): String = - NSURLComponents(url) - .toStringNoFragment() - .internIfPossible() - - actual fun host(url: String): String = rfc3986UriBridge.hostFromUriWithUrl(url, null) ?: throw Exception("Could not retrieve host from URL.") -} - -fun NSURLComponents.toStringNoFragment(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (host != null) sb.append("//").append(host.toString()) - if (path != null) sb.append(path) - if (query != null) sb.append("?").append(query) - - return sb.toString() -} diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt deleted file mode 100644 index ad697c7f9..000000000 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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.utils - -import kotlinx.cinterop.ExperimentalForeignApi -import swiftbridge.UrlDetector - -@Suppress("UNCHECKED_CAST") -@OptIn(ExperimentalForeignApi::class) -actual fun fastFindURLs(text: String): List { - val detectorInstance = UrlDetector() - return detectorInstance.findURLsWithText(text) as List -} diff --git a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt new file mode 100644 index 000000000..f5042e2d1 --- /dev/null +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt @@ -0,0 +1,152 @@ +/* + * 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.nip19Bech32 + +import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class NIP19EmbedTests { + @Test + fun testEmbedKind1Event() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) + + val textNote = + signer.sign( + TextNoteEvent.build("I like this. It could solve the ninvite problem in #1062, and it seems like it could be applied very broadly to limit the spread of events that shouldn't stand on their own or need to be private. The one question I have is how long are these embeds? If it's 50 lines of text, that breaks the human readable (or at least parseable) requirement of kind 1s. Also, encoding json in a tlv is silly, we should at least use the tlv to reduce the payload size."), + ) + + assertNotNull(textNote) + + val bech32 = NEmbed.create(textNote) + + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(textNote.toJson(), decodedNote.toJson()) + } + + @Test + fun testVisionPrescriptionEmbedEvent() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) + + val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionFhir)) + + assertNotNull(eyeglassesPrescriptionEvent) + + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) + + println(eyeglassesPrescriptionEvent.toJson()) + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) + } + + @Test + fun testVisionPrescriptionBundleEmbedEvent() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) + + val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionBundle)) + + assertNotNull(eyeglassesPrescriptionEvent) + + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) + + println(eyeglassesPrescriptionEvent.toJson()) + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) + } + + @Test + fun testVisionPrescriptionBundle2EmbedEvent() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(decodePrivateKeyAsHexOrNull("nsec1arn3jlxv20y76n8ek8ydecy9ga06rl7aw8evznjylc3ap00hwkvqx4vvy6")!!.hexToByteArray()), + ) + + val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionBundle2)) + + assertNotNull(eyeglassesPrescriptionEvent) + + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) + + println(eyeglassesPrescriptionEvent.toJson()) + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) + } + + @Test + fun testTimsNembed() { + val uri = "nembed1r79ssq9446hkwqhl642ukmku8qg0c92pu7w3j0jyfte8tc7tvg85vmrys8x3sqgle5vjy7jpjswqhphl0kd6yf4sz0n3peyjq5rp3zkat4w6c6j3f7um0724jmfu5456xxgg2yxkn8dp23j64xsn9npcggzafyh2effyntqrqxzja8dp52kpcvc9zqxlj86e8mx05vevzxkeprjkfs4wmppxm3p96vj6yvu2mqgf5l4v99492r2qsggquxuv93uzx244652h2kkj8xseg9xkq0afpygknjtty9j4ju5v0nm9mezux9wyl6s5wr7lzce7cj397mnu0u04ha7aq3w7exelrhe3zs3l3urwa9sp36u80npllrs0hmsxqdn0fsuyav3nv0azjs5suzuurg2uymncjxez8p9xksc2j6gw992enjflgrdd7n5uq2xrpvfrd3rckw624ey0elvm6grr27tyzlf4vaswgm5vc3hdyczsl983g2j8e67r6z5zt30lat84ma4wclkwwxxrcflvdsuwd7346h7zqav4vdwe3gkt9lr87sfk4aqd2aey03tt4eyspldrqcmkx9pqe2pn63rv7grwwalr86akuldnvjm6m87wrw9sdwns8wq0rnsmj57vqwtc3g7hkwum3vl2dda78dwkycgfzw6qna3ufhpatcvq5a4hm4ehl45an8umwt0clf7rn77ctke475qglwu86hhfwhn7dkca4pkfpyc4y75rll6nvr5qc8nlhf8mk22celn5mecvyuzxd830drhdck9tcdpcafymk8wajwu2w8ha8gatggjfvq0a4jlf2sdamzj0ysqks9dk8me3q7a0qpmf6vykurkrcls4pug3u4pn4u26ezx3h8e482n07x2nsmu80dpufxqc0ttcyzhnppguxma4d8aumdawnlsyy7yzcuxl7lw5y9p4nv5h8fn6u8anpm2tsze3p6mgxy9j9uuqfxg2jvlmtjpakna5m4hln0msmw804hnun96h66fh62270yhhljnmmdl7jln07ll5vft7e870hemcld34a09n943ed6629fgtctsftma9q6tf4jfm2p0ukd2j2n2dpz53fqrkk4ctdcy2j5jar095g5jntf6u807ggkzauzt6uqkwk4tg5w7w55kskspc9663zx5dzzzfwpg3q546g2ve4kukr70n0a46eyce2crsqqq247ql5" + + val decodedNote = (Nip19Parser.uriToRoute(uri)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(timsPrescription, decodedNote.toJson()) + } + + val visionPrescriptionFhir = "{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"Patient/Donald Duck\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"Practitioner/Adam Careful\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}" + val visionPrescriptionBundle = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Careful\",\"given\":[\"Adam\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Duck\",\"given\":[\"Donald\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"#2\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}" + + val visionPrescriptionBundle2 = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Smith\",\"given\":[\"Dr. Joe\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Doe\",\"given\":[\"Jane\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}" + val timsPrescription = "{\"id\":\"18d8b22e6455dfc9f4c6d6be8c2cf015e961b8d160dfe5e4b7fc1578f2c4e0be\",\"pubkey\":\"46f1826abf5b03de972192e619e25fa94d775a1c555efe53a775412dbf49889b\",\"created_at\":1739566773,\"kind\":82,\"tags\":[[\"p\",\"46f1826abf5b03de972192e619e25fa94d775a1c555efe53a775412dbf49889b\"]],\"content\":\"{\\\"resourceType\\\": \\\"VisionPrescription\\\", \\\"id\\\": \\\"eyeglass-prescription-001\\\", \\\"status\\\": \\\"active\\\", \\\"created\\\": \\\"2025-02-14T10:00:00Z\\\", \\\"patient\\\": {\\\"reference\\\": \\\"Patient/12345\\\", \\\"display\\\": \\\"John Doe\\\"}, \\\"encounter\\\": {\\\"reference\\\": \\\"Encounter/67890\\\"}, \\\"dateWritten\\\": \\\"2025-02-10T15:00:00Z\\\", \\\"prescriber\\\": {\\\"reference\\\": \\\"Practitioner/56789\\\", \\\"display\\\": \\\"Dr. Emily Smith\\\"}, \\\"lensSpecification\\\": [{\\\"product\\\": {\\\"coding\\\": [{\\\"system\\\": \\\"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\\\", \\\"code\\\": \\\"lens\\\", \\\"display\\\": \\\"Eyeglasses\\\"}]}, \\\"eye\\\": \\\"right\\\", \\\"sphere\\\": -2.5, \\\"cylinder\\\": -1.0, \\\"axis\\\": 180, \\\"prism\\\": [{\\\"amount\\\": 0.5, \\\"base\\\": \\\"up\\\"}], \\\"add\\\": 2.0, \\\"duration\\\": {\\\"value\\\": 24, \\\"unit\\\": \\\"months\\\", \\\"system\\\": \\\"http://unitsofmeasure.org\\\", \\\"code\\\": \\\"mo\\\"}, \\\"note\\\": [{\\\"text\\\": \\\"Right eye prescription for near-sightedness with astigmatism.\\\"}]}, {\\\"product\\\": {\\\"coding\\\": [{\\\"system\\\": \\\"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\\\", \\\"code\\\": \\\"lens\\\", \\\"display\\\": \\\"Eyeglasses\\\"}]}, \\\"eye\\\": \\\"left\\\", \\\"sphere\\\": -3.0, \\\"cylinder\\\": -0.75, \\\"axis\\\": 160, \\\"prism\\\": [{\\\"amount\\\": 0.5, \\\"base\\\": \\\"down\\\"}], \\\"add\\\": 2.0, \\\"duration\\\": {\\\"value\\\": 24, \\\"unit\\\": \\\"months\\\", \\\"system\\\": \\\"http://unitsofmeasure.org\\\", \\\"code\\\": \\\"mo\\\"}, \\\"note\\\": [{\\\"text\\\": \\\"Left eye prescription for near-sightedness with astigmatism.\\\"}]}]}\",\"sig\":\"d22d3b86aea397094de8b6cdf69decdfd886c90008aeebf95fd43a2770b37d486b313bff5bdb44b78e33bbaa3f336d74ee8b36bc5b16050374054246c72d93c2\"}" +} diff --git a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt deleted file mode 100644 index 90117ac43..000000000 --- a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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.nip47WalletConnect - -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod -import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs - -class LnZapPaymentRequestNip44EventTest { - @Test - fun testCreateRequestWithNip44() = - runTest { - val clientKeyPair = KeyPair() - val walletKeyPair = KeyPair() - val clientSigner = NostrSignerInternal(clientKeyPair) - val walletServicePubkey: HexKey = - walletKeyPair.pubKey.toHexKey() - - val request = GetBalanceMethod.create() - val event = - LnZapPaymentRequestEvent.createRequest( - request = request, - walletServicePubkey = walletServicePubkey, - signer = clientSigner, - createdAt = 1000L, - useNip44 = true, - ) - - assertEquals(23194, event.kind) - assertEquals("nip44_v2", event.encryptionScheme()) - } - - @Test - fun testDecryptNip44Request() = - runTest { - val clientKeyPair = KeyPair() - val walletKeyPair = KeyPair() - val clientSigner = NostrSignerInternal(clientKeyPair) - val walletSigner = NostrSignerInternal(walletKeyPair) - val walletServicePubkey: HexKey = - walletKeyPair.pubKey.toHexKey() - - val request = GetInfoMethod.create() - val event = - LnZapPaymentRequestEvent.createRequest( - request = request, - walletServicePubkey = walletServicePubkey, - signer = clientSigner, - useNip44 = true, - ) - - assertEquals("nip44_v2", event.encryptionScheme()) - - // Wallet service should be able to decrypt NIP-44 encrypted request - val decrypted = event.decryptRequest(walletSigner) - assertIs(decrypted) - } -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.jvmAndroid.kt deleted file mode 100644 index 459567892..000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.jvmAndroid.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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.utils - -import org.czeal.rfc3986.URIReference - -actual object Rfc3986 { - actual fun normalize(uri: String) = - URIReference - .parse(uri) - .normalize() - .toString() - - actual fun isValidUrl(url: String): Boolean = - runCatching { - URIReference.parse(url) - }.isSuccess - - actual fun normalizeAndRemoveFragment(url: String): String = - URIReference - .parse(url) - .normalize() - .toStringNoFragment() - .intern() - - actual fun host(url: String): String = URIReference.parse(url).host.value -} - -fun URIReference.toStringSchemeHost(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (authority != null) sb.append("//").append(authority.toString()) - - return sb.toString() -} - -fun URIReference.toStringNoFragment(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (authority != null) sb.append("//").append(authority.toString()) - if (path != null) sb.append(path) - if (query != null) sb.append("?").append(query) - - return sb.toString() -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt deleted file mode 100644 index bdb01fd24..000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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.utils - -import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector - -actual fun fastFindURLs(text: String): List = UrlDetector(text).detect().map { it.originalUrl } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountResultHllSerializationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountResultHllSerializationTest.kt new file mode 100644 index 000000000..d4eff30ad --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/CountResultHllSerializationTest.kt @@ -0,0 +1,121 @@ +/* + * 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.nip45Count + +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.CountResultKSerializer +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CountResultHllSerializationTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun testSerializeWithHll() { + val hll = ByteArray(256) { (it % 16).toByte() } + val result = CountResult(count = 42, approximate = true, hll = hll) + val element = CountResultKSerializer.serializeToElement(result) + + assertEquals(42, element["count"]!!.jsonPrimitive.int) + assertNotNull(element["hll"]) + assertEquals(512, element["hll"]!!.jsonPrimitive.content.length) + } + + @Test + fun testSerializeWithoutHll() { + val result = CountResult(count = 10, approximate = false) + val element = CountResultKSerializer.serializeToElement(result) + + assertEquals(10, element["count"]!!.jsonPrimitive.int) + assertNull(element["hll"]) + } + + @Test + fun testDeserializeWithHll() { + val hll = ByteArray(256) { (it % 8).toByte() } + val hllHex = HyperLogLog.encode(hll) + + val jsonObject: JsonObject = + buildJsonObject { + put("count", 100) + put("approximate", true) + put("hll", hllHex) + } + + val result = CountResultKSerializer.deserializeFromElement(jsonObject) + + assertEquals(100, result.count) + assertTrue(result.approximate) + assertNotNull(result.hll) + assertTrue(hll.contentEquals(result.hll!!)) + } + + @Test + fun testDeserializeWithoutHll() { + val jsonObject: JsonObject = + buildJsonObject { + put("count", 50) + } + + val result = CountResultKSerializer.deserializeFromElement(jsonObject) + + assertEquals(50, result.count) + assertNull(result.hll) + } + + @Test + fun testRoundTripSerialization() { + val hll = ByteArray(256) { (it * 3 % 20).toByte() } + val original = CountResult(count = 75, approximate = true, hll = hll) + + val element = CountResultKSerializer.serializeToElement(original) + val deserialized = CountResultKSerializer.deserializeFromElement(element) + + assertEquals(original.count, deserialized.count) + assertNotNull(deserialized.hll) + assertTrue(hll.contentEquals(deserialized.hll!!)) + } + + @Test + fun testDeserializeFromRelayJsonWithHll() { + // Simulate a real relay response: ["COUNT", "sub1", {"count": 42, "hll": "00010203..."}] + val hll = ByteArray(256) { 5 } + val hllHex = HyperLogLog.encode(hll) + + val jsonStr = """{"count":42,"hll":"$hllHex"}""" + val jsonObject = json.decodeFromString(jsonStr) + + val result = CountResultKSerializer.deserializeFromElement(jsonObject) + assertEquals(42, result.count) + assertNotNull(result.hll) + assertEquals(256, result.hll!!.size) + assertEquals(5, result.hll!![0].toInt() and 0xFF) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/HyperLogLogTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/HyperLogLogTest.kt new file mode 100644 index 000000000..7bfdc5824 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip45Count/HyperLogLogTest.kt @@ -0,0 +1,162 @@ +/* + * 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.nip45Count + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HyperLogLogTest { + @Test + fun testEmptyRegistersEstimateZero() { + val registers = ByteArray(256) + val estimate = HyperLogLog.estimate(registers) + assertEquals(0L, estimate) + } + + @Test + fun testEncodeDecodeRoundTrip() { + val registers = ByteArray(256) { (it % 10).toByte() } + val hex = HyperLogLog.encode(registers) + assertEquals(512, hex.length) + + val decoded = HyperLogLog.decode(hex) + assertNotNull(decoded) + assertTrue(registers.contentEquals(decoded)) + } + + @Test + fun testDecodeInvalidHex() { + assertNull(HyperLogLog.decode("too_short")) + assertNull(HyperLogLog.decode("")) + } + + @Test + fun testMergeSelectsMaxPerRegister() { + val hll1 = ByteArray(256) { 1 } + val hll2 = ByteArray(256) { 2 } + val hll3 = ByteArray(256) { 0 } + + // Set some specific registers higher in hll1 + hll1[0] = 5 + hll1[100] = 10 + + val merged = HyperLogLog.merge(listOf(hll1, hll2, hll3)) + + // Register 0: max(5, 2, 0) = 5 + assertEquals(5, merged[0].toInt() and 0xFF) + // Register 1: max(1, 2, 0) = 2 + assertEquals(2, merged[1].toInt() and 0xFF) + // Register 100: max(10, 2, 0) = 10 + assertEquals(10, merged[100].toInt() and 0xFF) + } + + @Test + fun testMergeSingleHll() { + val hll = ByteArray(256) { (it % 5).toByte() } + val merged = HyperLogLog.merge(listOf(hll)) + assertTrue(hll.contentEquals(merged)) + } + + @Test + fun testMergeEmptyList() { + val merged = HyperLogLog.merge(emptyList()) + assertEquals(256, merged.size) + assertTrue(merged.all { it == 0.toByte() }) + } + + @Test + fun testEstimateNonZeroRegisters() { + // Set all registers to a uniform value > 0 + val registers = ByteArray(256) { 3 } + val estimate = HyperLogLog.estimate(registers) + // With all registers at 3, the estimate should be positive + assertTrue(estimate > 0) + } + + @Test + fun testEstimateIncreaseWithHigherValues() { + val lowRegisters = ByteArray(256) { 2 } + val highRegisters = ByteArray(256) { 5 } + + val lowEstimate = HyperLogLog.estimate(lowRegisters) + val highEstimate = HyperLogLog.estimate(highRegisters) + + assertTrue(highEstimate > lowEstimate) + } + + @Test + fun testComputeOffsetWithEventIdFilter() { + // Event ID hex: the char at position 32 determines the offset + val eventId = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + val filter = Filter(tags = mapOf("e" to listOf(eventId))) + val offset = HyperLogLog.computeOffset(filter) + + // char at position 32 is 'a' = 10, offset = 10 + 8 = 18 + assertNotNull(offset) + assertEquals(18, offset) + } + + @Test + fun testComputeOffsetWithPubkeyFilter() { + val pubkey = "0000000000000000000000000000000000000000000000000000000000000000" + val filter = Filter(tags = mapOf("p" to listOf(pubkey))) + val offset = HyperLogLog.computeOffset(filter) + + // char at position 32 is '0' = 0, offset = 0 + 8 = 8 + assertNotNull(offset) + assertEquals(8, offset) + } + + @Test + fun testComputeOffsetWithNoTags() { + val filter = Filter(kinds = listOf(1)) + val offset = HyperLogLog.computeOffset(filter) + assertNull(offset) + } + + @Test + fun testComputeOffsetWithEmptyTags() { + val filter = Filter(tags = mapOf("e" to emptyList())) + val offset = HyperLogLog.computeOffset(filter) + assertNull(offset) + } + + @Test + fun testComputeOffsetWithNonHexValue() { + // Non-hex value should be SHA-256 hashed + val filter = Filter(tags = mapOf("t" to listOf("nostr"))) + val offset = HyperLogLog.computeOffset(filter) + assertNotNull(offset) + assertTrue(offset in 8..23) + } + + @Test + fun testComputeOffsetDeterministic() { + val filter = Filter(tags = mapOf("e" to listOf("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"))) + val offset1 = HyperLogLog.computeOffset(filter) + val offset2 = HyperLogLog.computeOffset(filter) + assertEquals(offset1, offset2) + } +} diff --git a/quartz/src/swift/swiftbridge/Rfc3986UriBridge.swift b/quartz/src/swift/swiftbridge/Rfc3986UriBridge.swift deleted file mode 100644 index bafd34a5b..000000000 --- a/quartz/src/swift/swiftbridge/Rfc3986UriBridge.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Created by NullDev on 31/12/2025. -// - -import Foundation -import RFC_3986 - -@objcMembers public class Rfc3986UriBridge: NSObject { - public func normalizeUrl(url: String) throws -> String { - let uri = try RFC_3986.URI(url) - let normalized = uri.normalized() - return normalized.value - } - - public func isUrlValid(url: String) -> Bool { - return RFC_3986.isValidURI(url) - } - - public func hostFromUri(url: String) throws -> String { - let actualUri = try RFC_3986.URI(url) - return actualUri.host! - } -} diff --git a/quartz/src/swift/swiftbridge/UrlDetector.swift b/quartz/src/swift/swiftbridge/UrlDetector.swift deleted file mode 100644 index e4927c516..000000000 --- a/quartz/src/swift/swiftbridge/UrlDetector.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Created by NullDev on 20/01/2026. -// - -import Foundation - -@objcMembers public class UrlDetector: NSObject { - public func findURLs(text: String) -> [String] { - var links = [String]() - let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - let matches = detector.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)) - - for match in matches { - guard let range = Range(match.range, in: text) else { continue } - let url = text[range] - links.append(String(url)) - } - - return links - } -} \ No newline at end of file diff --git a/zapstore.yaml b/zapstore.yaml index ffc1ede49..0eafcad52 100644 --- a/zapstore.yaml +++ b/zapstore.yaml @@ -1,14 +1,51 @@ -name: Amethyst -description: The all-in-one Nostr client -license: MIT -builder: npub142gywvjkq0dv6nupggyn2euhx4nduwc7yz5f24ah9rpmunr2s39se3xrj0 +# ═══════════════════════════════════════════════════════════════════ +# SOURCE CONFIGURATION +# ═══════════════════════════════════════════════════════════════════ + +# Source code repository URL or NIP-34 naddr (for display in app store) repository: https://github.com/vitorpamplona/amethyst -homepage: https://amethyst.social/ -assets: - - amethyst-googleplay.*.apk -remote_metadata: - - github +# ═══════════════════════════════════════════════════════════════════ +# APP METADATA +# ═══════════════════════════════════════════════════════════════════ +# App name (overrides APK label) +name: Amethyst +# Short one-line description +summary: The all-in-one Nostr client +# Full description (supports markdown) +description: | + A privacy-focused Nostr client for Android. Built-in TOR support, the most configurable relay system, + encrypted messaging, zaps, marketplaces, live streams and complete data sovereignty. + +# Category tags +tags: + - social-network + - nostr + +# SPDX license identifier +license: MIT + +# App homepage +website: https://amethyst.social/ + +# ═══════════════════════════════════════════════════════════════════ +# MEDIA +# ═══════════════════════════════════════════════════════════════════ +# Screenshots (local paths or URLs) +images: + - ./docs/screenshots/home.png + - ./docs/screenshots/messages.png + - ./docs/screenshots/notifications.png + - ./docs/screenshots/replies.png + +# ═══════════════════════════════════════════════════════════════════ +# VARIANTS +# ═══════════════════════════════════════════════════════════════════ + +# APK variant patterns (for apps with multiple builds) +variants: + fdroid: ".*-fdroid-.*\\.apk$" + google: ".*-googleplay-.*\\.apk$"