Compare commits

...

2620 Commits

Author SHA1 Message Date
Vitor Pamplona
7874893938 v1.06.1 2026-03-23 18:19:42 -04:00
Vitor Pamplona
51fa28f22a no message 2026-03-23 18:16:24 -04:00
Vitor Pamplona
ec4f949782 Leaves some space to click when their is no space or new lines on the poll. 2026-03-23 18:15:36 -04:00
Vitor Pamplona
4c9f2de6f7 - Improves rendering of the completed polls
- Doesn't render quoted polls
2026-03-23 18:01:41 -04:00
Vitor Pamplona
e5aeb49a4b Solves some of the crashes of concurrent modification exception 2026-03-23 17:59:14 -04:00
Vitor Pamplona
da84848df7 Merge pull request #1911 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-23 17:46:06 -04:00
Crowdin Bot
3d488165c3 New Crowdin translations by GitHub Action 2026-03-23 21:40:20 +00:00
Vitor Pamplona
f99543dc3b Merge pull request #1913 from greenart7c3/claude/add-url-crash-tests-J8Omf
Claude/add url crash tests j8 omf
2026-03-23 17:38:51 -04:00
Claude
7ea5557828 test: fix UrlTest to use 今北産業 as the crash input (PR #1907)
https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2
2026-03-23 21:02:41 +00:00
Claude
3a93c4b075 test: add UrlTest for getPart() boundary conditions fixed in PR #1907
Tests the StringIndexOutOfBoundsException fix in Url.getPart() by directly
constructing Url objects with UrlMarker indices that exceed the trimmed
originalUrl length — the exact scenario that occurs when readEnd() strips
a trailing character (e.g. ':') while the PORT/QUERY marker still points
to the original buffer position.

https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2
2026-03-23 20:57:49 +00:00
Claude
9ebc4a1e67 style: remove trailing blank line in UrlMarkerTest
https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2
2026-03-23 20:56:58 +00:00
Claude
9202b60dcf test: add regression tests for 今北産業 URL crash (PR #1907)
Verifies that the Japanese phrase "今北産業" does not cause a
StringIndexOutOfBoundsException in Url.getPart() and is not
detected as a URL.

https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2
2026-03-23 20:46:59 +00:00
Claude
e31e3829a6 test: add regression tests for PR #1907 URL crash fix
Tests cover the StringIndexOutOfBoundsException in Url.getPart() that
occurred when readEnd() trimmed trailing characters (e.g. ':') from a
detected URL but urlMarker indices remained pointing past the trimmed
string's end.

- UrlMarkerTest: three cases testing PORT/QUERY index at or beyond
  string length (the boundary conditions fixed by minOf clamping and
  the startIndex >= length guard)
- UrlsDetectorTest: regression for "今北産業" (no crash, 0 URLs) and
  for a bare "example.com:" host
- UrlParserTest: end-to-end regression for "今北産業" and "example.com:"

https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2
2026-03-23 20:31:28 +00:00
Vitor Pamplona
f553489ddf Improves the wording of the Last Seen 2026-03-23 15:51:35 -04:00
Vitor Pamplona
2b920fc47a Merge pull request #1907 from greenart7c3/claude/fix-android-runtime-exception-auFbS
Fix URL parsing bounds checking in Url.kt
2026-03-23 15:51:07 -04:00
Vitor Pamplona
306d76f793 Tries to fix
IllegalArgumentException: 1.06.0-PLAY

| Prop | Value |
|------|-------|
| Manuf |Google |
| Model |Pixel 8a |
| Prod |akita |
| Android |16 |
| SDK Int |36 |
| Brand |google |
| Hardware |akita |
| Device | akita |
| Host | r-0123456789abcdef-0123 |
| User | android-user |

```
java.lang.IllegalArgumentException: Cannot disable reuse from root if it was caused by other groups
    androidx.compose.runtime.PreconditionsKt.throwIllegalArgumentException(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:3)
    androidx.compose.runtime.ComposerImpl.endReuseFromRoot(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:19)
    androidx.compose.runtime.PausedCompositionImpl.resume(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:369)
    androidx.compose.ui.layout.LayoutNodeSubcompositionsState$precomposePaused$2.resume(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:50)
    androidx.compose.foundation.lazy.layout.PrefetchHandleProvider$HandleAndRequestImpl.performPausableComposition(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:49)
    androidx.compose.foundation.lazy.layout.PrefetchHandleProvider$HandleAndRequestImpl.executeRequest(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:128)
    androidx.compose.foundation.lazy.layout.PrefetchHandleProvider$HandleAndRequestImpl.execute(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:33)
    androidx.compose.foundation.lazy.layout.AndroidPrefetchScheduler.runRequest(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:36)
    androidx.compose.foundation.lazy.layout.AndroidPrefetchScheduler.run(r8-map-id-eb237b6d0af50dd04fa25285a72672c5149c195876ad0d708b61d757d31f3d90:113)
    android.os.Handler.handleCallback(Handler.java:1070)
    android.os.Handler.dispatchMessage(Handler.java:125)
    android.os.Looper.dispatchMessage(Looper.java:333)
    android.os.Looper.loopOnce(Looper.java:263)
    android.os.Looper.loop(Looper.java:367)
    android.app.ActivityThread.main(ActivityThread.java:9331)
    java.lang.reflect.Method.invoke(Native Method)
    com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:566)
    com.android.internal.os.ZygoteInit.main(ZygoteInit.java:837)
```
2026-03-23 13:47:00 -04:00
Vitor Pamplona
eeca86e542 Fixes comparator to avoid Comparison method violates its general contract! 2026-03-23 13:16:32 -04:00
Claude
951610d9ca fix: prevent StringIndexOutOfBoundsException in Url.getPart()
When UrlDetector.readEnd() trims the last character from URLs ending
with characters like '.', ',', etc., the urlMarker indices remain
based on the original buffer length. This caused getPart() to call
substring() with an end index beyond the trimmed URL's length.

Fix by clamping the end index to originalUrl.length and guarding
against an out-of-bounds start index.

https://claude.ai/code/session_014Q2SvTE5vKgUm1w8dPVjYo
2026-03-23 17:10:56 +00:00
Vitor Pamplona
590bd21f62 UriParser might crash, wraps in a try catch 2026-03-23 13:03:43 -04:00
Vitor Pamplona
81380a32ae Fixes POCO phone's removal of the + sign in NWC 2026-03-23 13:03:18 -04:00
Vitor Pamplona
943f1256a7 Improves zap-store settings 2026-03-23 12:41:30 -04:00
Vitor Pamplona
7af1be7363 v1.06.0 2026-03-23 11:23:44 -04:00
Vitor Pamplona
a39688dabb remove warnings 2026-03-23 10:46:55 -04:00
Vitor Pamplona
33ec43a5c3 Removing warnings 2026-03-23 10:39:56 -04:00
Vitor Pamplona
1f5841d090 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  update translations: CZ, DE, PT, SE
  New Crowdin translations by GitHub Action
2026-03-23 10:31:22 -04:00
Vitor Pamplona
9d086df37a Updating all dependencies 2026-03-23 10:28:33 -04:00
Vitor Pamplona
ed3390f64a Fixes weird import 2026-03-23 10:16:59 -04:00
Vitor Pamplona
e659651dd4 Merge pull request #1906 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-23 09:26:59 -04:00
Crowdin Bot
0cc06f424e New Crowdin translations by GitHub Action 2026-03-23 13:12:22 +00:00
davotoula
11176a4679 update translations: CZ, DE, PT, SE 2026-03-23 14:08:14 +01:00
Vitor Pamplona
d964816fd3 Fixes build dependency bug: Spotless needs vlcSetup for... something... 2026-03-23 09:06:52 -04:00
Vitor Pamplona
bbb75afed9 Merge pull request #1904 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-23 08:27:58 -04:00
Crowdin Bot
2eb3ebd365 New Crowdin translations by GitHub Action 2026-03-23 12:25:24 +00:00
Vitor Pamplona
c75881d928 Merge pull request #1903 from nrobi144/fix/chess-interop
fix(chess): normalize SAN for cross-client Jester interop
2026-03-23 08:23:51 -04:00
nrobi144
9d8d0e249d fix(chess): encode required fields in Jester content JSON
JesterContent fields version, fen, and history were omitted from
serialized JSON because kotlinx.serialization skips default values.
Jester's isStartGameEvent checks arrayEquals(json.history, []) which
fails when the field is absent (undefined !== []).

Added @EncodeDefault to version, fen, and history so they are always
included in the JSON output regardless of value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:08:28 +02:00
nrobi144
677bc6c34a fix(chess): add offchain.pub relay for jester interop
jester.nyo.dev uses offchain.pub as one of its relays. Adding it gives
us 2 shared relays (relay.damus.io + offchain.pub) instead of 1,
improving challenge visibility between Amethyst and Jester clients.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:26:09 +02:00
nrobi144
3eaf3d38a5 fix(chess): normalize SAN for cross-client interop
ChessEngine.makeMove() now normalizes SAN notation before passing to
chesslib, fixing games against jester.nyo.dev and other clients that
use different notation:

- Castling: 0-0 → O-O, 0-0-0 → O-O-O (zeros to letters)
- Annotations: strips !, ?, !!, ??, !?, ?! suffixes

Previously, chesslib rejected 0-0/0-0-0 causing ChessStateReconstructor
to mark games as desynced and skip remaining moves. Games appeared
permanently stuck, even after app restart.

Also fixes discoverUserGames() silently dropping games classified as
spectator — now adds them to spectatingGames instead.

23 new interop tests covering SAN variants, reconstruction, spectator
detection, and missing start event handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 07:21:17 +02:00
Vitor Pamplona
d4fd8088f1 lint 2 2026-03-22 21:04:11 -04:00
Vitor Pamplona
3e7633ea6b lint 2026-03-22 20:02:13 -04:00
Vitor Pamplona
11ed878111 Removing unnecessary XML loading. 2026-03-22 19:43:00 -04:00
Vitor Pamplona
76df43b031 Removes logs from blossom fetcher 2026-03-22 19:07:18 -04:00
Vitor Pamplona
41b0abc8e9 Moves expiration process to IO 2026-03-22 19:06:37 -04:00
Vitor Pamplona
f70fd9ad2b more change log improvements. 2026-03-22 15:47:00 -04:00
Vitor Pamplona
9eba27e9b2 Additional updates to the change log 2026-03-22 15:28:08 -04:00
Vitor Pamplona
37f6a7b133 Makes sure subscriptions are closed on the NostrClient utility methods 2026-03-22 15:18:48 -04:00
Vitor Pamplona
439b392aa0 Moves EmptyNostrClient to a class to avoid auto-import issues 2026-03-22 15:14:49 -04:00
Vitor Pamplona
2e1066a609 Merge pull request #1902 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-22 14:55:48 -04:00
Crowdin Bot
0905b05388 New Crowdin translations by GitHub Action 2026-03-22 18:50:38 +00:00
Vitor Pamplona
b2f7c3296f Activate edits for Articles. 2026-03-22 14:47:27 -04:00
Vitor Pamplona
9c30e2d0f6 Adds draft editing to long form posting 2026-03-22 14:38:44 -04:00
Vitor Pamplona
570639c1df Fixes build 2026-03-22 12:50:26 -04:00
Vitor Pamplona
65ae09e201 Adding file uploads and metadata stripping to the Longform screen 2026-03-22 12:46:25 -04:00
Vitor Pamplona
93b1392080 Merge pull request #1901 from vitorpamplona/claude/add-markdown-post-screen-Xma8x
Add NIP-23 Long-Form Content (Markdown Articles) Support
2026-03-22 12:38:14 -04:00
Vitor Pamplona
3e93472dce Merge branch 'main' into claude/add-markdown-post-screen-Xma8x 2026-03-22 12:38:04 -04:00
Vitor Pamplona
26a3dcbd85 Adds upload cover url 2026-03-22 12:35:19 -04:00
Vitor Pamplona
ffa60c5fd3 Final touches on new Markdown Screen 2026-03-22 12:25:49 -04:00
Vitor Pamplona
cd3282b342 Merge pull request #1900 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-22 11:25:05 -04:00
Crowdin Bot
41539c1b7a New Crowdin translations by GitHub Action 2026-03-22 15:21:49 +00:00
Vitor Pamplona
40c45fb3a1 Adds a RelaySync filter by since/until dates. 2026-03-22 11:18:28 -04:00
Vitor Pamplona
b6b4856fe4 Fixes a closing bug when the coroutine crashes
Fixes an index bug on matching subscriptions
Requires the relay to send events that match the sub in order to move forward with the paging
2026-03-22 11:18:12 -04:00
Vitor Pamplona
adb3a0e0fc Better transitions to a Nostrclient that is autocloseable, destroying all links to everything else when closing 2026-03-22 11:16:54 -04:00
Vitor Pamplona
459f69ff2c Moves open polls view to a state flow in thew viewmodel 2026-03-21 16:20:15 -04:00
Vitor Pamplona
90cced23d2 Fixes search by noteID returning a version of the note instead of the address 2026-03-21 15:49:19 -04:00
David Kaspar
0dec0164ad Merge pull request #1899 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-21 18:47:46 +01:00
Crowdin Bot
5af0cc7eac New Crowdin translations by GitHub Action 2026-03-21 17:38:51 +00:00
Vitor Pamplona
ef5508b60b Merge pull request #1896 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-21 13:38:47 -04:00
Vitor Pamplona
fd79da0b09 Merge pull request #1898 from vitorpamplona/claude/nip42-relay-auth-P878m
Implement pluggable relay policies for NIP-42 authentication
2026-03-21 13:37:36 -04:00
Vitor Pamplona
15ff3040b5 Merge branch 'main' into claude/nip42-relay-auth-P878m 2026-03-21 13:37:21 -04:00
Crowdin Bot
a86b9ab2e1 New Crowdin translations by GitHub Action 2026-03-21 17:35:12 +00:00
Vitor Pamplona
862aa0cb78 Update CHANGELOG for release v1.06.0
Updated CHANGELOG for release v1.06.0, detailing new features, improvements, and removals including support for NIP-85 Polls, custom NIP-40 Expirations, and various UI enhancements.
2026-03-21 13:33:51 -04:00
Vitor Pamplona
cfead47d71 Improves design of the policy 2026-03-21 13:21:21 -04:00
davotoula
32c0efb291 update translations: CZ, DE, PT, SE 2026-03-21 16:04:16 +01:00
Vitor Pamplona
b2ad97bb6b merge 2026-03-20 21:19:49 -04:00
Vitor Pamplona
df6dc17047 Final touches on the Attestation cards 2026-03-20 21:16:59 -04:00
David Kaspar
81308a2368 Merge pull request #1894 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-21 00:16:29 +01:00
Crowdin Bot
24d2928881 New Crowdin translations by GitHub Action 2026-03-20 23:08:05 +00:00
Vitor Pamplona
2ef634ca01 Fixes showing attestations in the NoteMaster 2026-03-20 19:05:07 -04:00
Vitor Pamplona
aca62ec81f Improves layout of the open poll cards 2026-03-20 18:41:11 -04:00
Claude
b0a8cae194 refactor: extract AuthPolicy interface from requireAuth boolean
Replace the requireAuth: Boolean parameter in NostrServer with a
pluggable AuthPolicy interface. Each policy has trigger points for
EVENT (acceptEvent), REQ (acceptReq with filter rewriting), COUNT
(acceptCount), and live event delivery (canSendToSession).

Built-in policies: OpenPolicy (allow all) and RequireAuthPolicy
(require auth for all commands, matching the previous behavior).

https://claude.ai/code/session_017vdjbdxdYK1oJMH66koVZE
2026-03-20 22:40:18 +00:00
Vitor Pamplona
f2e60986d6 Merge pull request #1895 from vitorpamplona/claude/show-open-polls-notifications-1ZDVi
Add Open Polls section to notifications feed
2026-03-20 18:30:07 -04:00
Vitor Pamplona
b93ad35029 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  update translations: CZ, DE, PT, SE add: find missing translations skill
2026-03-20 18:18:24 -04:00
Vitor Pamplona
d54bc91c20 Updates changelog for next release 2026-03-20 18:12:48 -04:00
David Kaspar
b1ca425c46 Merge pull request #1893 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-20 23:11:31 +01:00
Claude
8fd08aa56d feat: implement NIP-42 server-side relay authentication
Add server-side AUTH challenge/response handling per NIP-42 spec:
- RelaySession generates a unique challenge per connection and tracks
  authenticated pubkeys (multiple users can auth on one session)
- NostrServer gains relayUrl and requireAuth parameters; when requireAuth
  is true, EVENT/REQ/COUNT are rejected with auth-required: prefix
- AUTH handler validates kind 22242, created_at within 10 min, challenge
  and relay tag matching
- AuthCmd now accepts Event (not just RelayAuthEvent) so the server can
  gracefully reject wrong-kind auth attempts
- 11 new tests covering auth success, wrong challenge/relay/kind/timestamp,
  multi-user auth, and requireAuth gating

https://claude.ai/code/session_017vdjbdxdYK1oJMH66koVZE
2026-03-20 22:05:53 +00:00
Crowdin Bot
6bdf65da56 New Crowdin translations by GitHub Action 2026-03-20 21:59:53 +00:00
davotoula
64316f7d73 update translations: CZ, DE, PT, SE
add: find missing translations skill
2026-03-20 22:55:31 +01:00
Vitor Pamplona
73536dd421 merge 2026-03-20 17:49:31 -04:00
Vitor Pamplona
817c4c7723 Removes some Attestation events from the feeds, adding on others 2026-03-20 17:45:45 -04:00
Claude
98cda3ba71 style: apply spotless formatting to OpenPollsSection
https://claude.ai/code/session_01LLLN5MDA5nJFYx38MVo81Q
2026-03-20 21:41:13 +00:00
Claude
b6e54db81b refactor: use LocalCache.observeNotes flow for open polls
Replace LocalCache.notes.forEach iteration with observeNotes using a
Filter for PollEvent.KIND and ZapPollEvent.KIND by the current user.
This makes the open polls list reactive — it updates automatically
when new polls arrive instead of only computing once on composition.

https://claude.ai/code/session_01LLLN5MDA5nJFYx38MVo81Q
2026-03-20 21:38:48 +00:00
David Kaspar
ed624a0a36 Merge pull request #1892 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-20 22:33:58 +01:00
Crowdin Bot
57c046261e New Crowdin translations by GitHub Action 2026-03-20 21:32:27 +00:00
Vitor Pamplona
6cb686f5cb Merge pull request #1889 from davotoula/convert-dropdown-menus-to-M3-action-sheet-dialogs
Convert dropdown menus to m3 action sheet dialogs
2026-03-20 17:30:58 -04:00
Vitor Pamplona
bbac00ccb3 Merge branch 'main' into convert-dropdown-menus-to-M3-action-sheet-dialogs 2026-03-20 17:30:50 -04:00
Vitor Pamplona
0f921989b9 Merge pull request #1891 from vitorpamplona/claude/add-attestations-home-feed-lQCT8
Add support for rendering attestation events
2026-03-20 17:28:38 -04:00
Vitor Pamplona
a21da546ab Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-03-20 17:26:22 -04:00
Vitor Pamplona
9bfb170ffa simple documentation 2026-03-20 17:15:05 -04:00
David Kaspar
ccffeb1e49 Merge pull request #1890 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-20 22:14:17 +01:00
Crowdin Bot
752f460bc0 New Crowdin translations by GitHub Action 2026-03-20 21:08:30 +00:00
Vitor Pamplona
b247310ba7 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  code review fixes:   1. options.isNotEmpty().also → if guard   2. Hardcoded English strings in FeedGroup enum   3. Hardcoded accessibility label   4. Raw 14.sp literals (×6) → replaced with Font14SP theme constant   5. Raw 12.sp literal → replaced with Font12SP theme constant   6. Modifier.size(20.dp) → replaced with existing Size20Modifier theme constant
  update gitignore
  add grouped feed filter dialog with Material 3 styling add icons, reduce text size, center group headers in filter dialog
  modernize SpinnerSelectionDialog with Material 3 styling
  feature switch chess icon on android: visible in debug/benchmark client only
  New Crowdin translations by GitHub Action
  Intentionality: check the return value and log a warning via Log.w() when deletion fails
  Update CS, DE, SV, PT
2026-03-20 17:05:29 -04:00
Vitor Pamplona
fc1e3e6b83 Adds a simple relay to quartz 2026-03-20 16:54:04 -04:00
Vitor Pamplona
d431b12f94 Migrates EventStore from Android's SQLLite to KMP
Fixes testing of libsodium between java and android
2026-03-20 16:00:17 -04:00
Vitor Pamplona
c5066d89c3 Fixes serialization issues when the code falls back to Kotlin Serialization 2026-03-20 15:58:17 -04:00
davotoula
26a97aa141 Code review fixes:
- Replaced manual .semantics with the simpler .clickable - avoids conditional Modifier allocation and a redundant semantics block
 - Moved LocalClipboardManager.current inside the if
 - Merged two consecutive postNostrUri?.let blocks into a single one
 - Wrapped the "Copy & Gallery" M3ActionSection in a condition
 - Removed duplicate string resource
2026-03-20 20:03:54 +01:00
davotoula
c9afec47cd style: run spotlessApply after M3 dialog migration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 19:24:49 +01:00
davotoula
bd10f74b2a feat: convert bookmark item options menu to M3 dialog
Replace DropdownMenu with M3ActionDialog, M3ActionSection, and M3ActionRow
components. Move dialog outside ClickableBox. Preserve wantsToEditPost and
reportDialogShowing state holders unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 19:22:31 +01:00
davotoula
6007e22871 feat: convert note actions menu to M3 dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 19:19:53 +01:00
davotoula
4e5d2e7d2a feat: convert media context menu to M3 dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 19:17:16 +01:00
davotoula
b3a61ba288 feat: convert user profile menu to M3 dialog
Replace DropdownMenu with M3ActionDialog in UserProfileDropDownMenu,
structured into share, moderation, and report sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 19:14:50 +01:00
davotoula
6632d3f515 feat: convert media playback menu to M3 dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 19:12:13 +01:00
davotoula
2fc9b294ae feat: convert follow pack actions menu to M3 dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 19:08:50 +01:00
davotoula
28dbb3948b feat: convert bookmark group management menu to M3 dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 19:05:47 +01:00
davotoula
5bde93d58f feat: convert people list management menu to M3 dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 18:33:26 +01:00
davotoula
2c6d18607a feat: convert bookmark list actions menu to M3 dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 18:30:58 +01:00
davotoula
cbe13f6819 feat: convert add member menu to M3 dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 18:10:45 +01:00
davotoula
dce1484dcc feat: convert add bookmark menu to M3 dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 17:19:27 +01:00
davotoula
aaed4bf697 feat: convert relay export menu to M3 dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 17:11:46 +01:00
davotoula
289c882d59 feat: convert URL preview copy menu to M3 dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 17:05:03 +01:00
davotoula
29f0b78fdd feat: convert Messages mark-as-read menu to M3 dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 17:02:46 +01:00
davotoula
19ff300f65 feat: add string resources for M3 action dialog titles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 16:59:55 +01:00
davotoula
fdb5b58513 feat: add reusable M3ActionDialog, M3ActionSection, M3ActionRow components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 16:59:51 +01:00
Vitor Pamplona
864581ecaa Merge pull request #1887 from davotoula/ui-polish-feed-filter
UI polish: feed filter
2026-03-20 10:00:38 -04:00
davotoula
620f8dbf10 code review fixes:
1. options.isNotEmpty().also → if guard
  2. Hardcoded English strings in FeedGroup enum
  3. Hardcoded accessibility label
  4. Raw 14.sp literals (×6) → replaced with Font14SP theme constant
  5. Raw 12.sp literal → replaced with Font12SP theme constant
  6. Modifier.size(20.dp) → replaced with existing Size20Modifier theme constant
2026-03-20 14:30:07 +01:00
davotoula
f15b7620df update gitignore 2026-03-20 14:01:45 +01:00
davotoula
3a195c4648 add grouped feed filter dialog with Material 3 styling
add icons, reduce text size, center group headers in filter dialog
2026-03-20 14:01:45 +01:00
davotoula
460ccc2d02 modernize SpinnerSelectionDialog with Material 3 styling 2026-03-20 14:00:19 +01:00
Vitor Pamplona
4f80fbfae4 Merge pull request #1886 from davotoula/feature-switch-for-chess
feature switch out chess icon on android
2026-03-20 07:41:38 -04:00
Claude
525b54e3c6 feat: add attestation events to LocalCache, Home feed, profile feed, and NoteCompose UI
- Load AttestationEvent (31871), AttestationRequestEvent (31872),
  AttestorRecommendationEvent (31873), and AttestorProficiencyEvent (11871)
  into LocalCache via consumeBaseReplaceable
- Create beautiful card-based UI renderers for all four attestation types
  with color-coded status indicators, validity badges, and kind chips
- Add attestation kinds to Home feed data source (FilterHomePostsByAuthors)
  and DAL filter (HomeNewThreadFeedFilter)
- Add attestation kinds to user profile feed data source
  (FilterUserProfilePosts) and DAL filter (UserProfileNewThreadFeedFilter)
- Add string resources for attestation status labels

https://claude.ai/code/session_01BEMFoHZENBwzmzS6TMPxVE
2026-03-20 10:39:32 +00:00
davotoula
05eb94915b feature switch chess icon on android: visible in debug/benchmark client only 2026-03-20 10:14:59 +01:00
David Kaspar
1159edad15 Merge pull request #1885 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-20 10:06:54 +01:00
Crowdin Bot
450a352c98 New Crowdin translations by GitHub Action 2026-03-20 08:36:42 +00:00
davotoula
94cf31eb29 Intentionality: check the return value and log a warning via Log.w() when deletion fails 2026-03-20 09:32:11 +01:00
davotoula
123d9eea0c Update CS, DE, SV, PT 2026-03-20 09:23:01 +01:00
Vitor Pamplona
c976f24a50 Fixes the list of users in the connected relays UI 2026-03-19 17:41:25 -04:00
Vitor Pamplona
583b9e56ba Merge pull request #1883 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-19 17:13:00 -04:00
Crowdin Bot
e183d5efd2 New Crowdin translations by GitHub Action 2026-03-19 21:10:44 +00:00
Vitor Pamplona
f9369b0cf1 Merge pull request #1884 from vitorpamplona/claude/improve-android-push-notifications-9DzMr
Add direct reply and mark read actions to DM notifications
2026-03-19 17:08:50 -04:00
Vitor Pamplona
11b5ebe108 Final touches. 2026-03-19 17:07:03 -04:00
Vitor Pamplona
379cbc7e69 Merge pull request #1882 from vitorpamplona/claude/add-attestations-package-ZnoES
Add attestation event types and tag support for NIP-31871
2026-03-19 16:42:44 -04:00
Vitor Pamplona
a3941e3b01 Merge pull request #1837 from vitorpamplona/claude/strip-file-metadata-sVIEd
Add metadata stripping for images, videos, and audio files
2026-03-19 16:41:10 -04:00
Claude
afb3221446 feat: add attestations package to quartz experimental
Implement the Attestations NIP with four event types:
- AttestationEvent (31871, addressable) - validity claims about other events
- AttestationRequestEvent (31872, addressable) - request attestation from attestors
- AttestorRecommendationEvent (31873, addressable) - recommend attestors for kinds
- AttestorProficiencyEvent (11871, replaceable) - declare verification proficiency

Each event follows Quartz's tag-per-file pattern with TagArrayExt and
TagArrayBuilderExt for reading/building tags.

https://claude.ai/code/session_01UswHRVTNqn1ToU4P4XDh2V
2026-03-19 20:40:19 +00:00
David Kaspar
32fdb63a89 Merge pull request #1881 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-19 21:32:11 +01:00
Claude
638bad3672 feat: show open polls at top of notification screen
Adds an "Open Polls" section at the top of the notification feed that
displays the user's PollEvent (NIP-88) and ZapPollEvent (kind 6969)
that are still active. Polls without a closing time are assumed to
expire 1 day after creation.

https://claude.ai/code/session_01LLLN5MDA5nJFYx38MVo81Q
2026-03-19 20:24:41 +00:00
Crowdin Bot
a1694bd84d New Crowdin translations by GitHub Action 2026-03-19 20:16:12 +00:00
David Kaspar
892d6f2b7c Merge pull request #1880 from davotoula/update-translations
Update CS, DE, SV, PT
2026-03-19 21:14:26 +01:00
davotoula
d1e70879ee Update CS, DE, SV, PT 2026-03-19 21:09:36 +01:00
davotoula
adc2e15078 Merge branch 'main-upstream' into claude/strip-file-metadata-sVIEd 2026-03-19 20:49:15 +01:00
Vitor Pamplona
4df78340b6 Merge pull request #1879 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-19 15:41:16 -04:00
davotoula
ae5f76e018 Merge branch 'main-upstream' into claude/strip-file-metadata-sVIEd 2026-03-19 20:38:57 +01:00
Crowdin Bot
f8d6ac7900 New Crowdin translations by GitHub Action 2026-03-19 19:38:07 +00:00
Vitor Pamplona
cee4291ba9 Merge pull request #1878 from vitorpamplona/claude/event-sync-screen-sYGtN
Claude/event sync screen
2026-03-19 15:36:38 -04:00
Vitor Pamplona
fd5e8dcca0 Merge pull request #1877 from vitorpamplona/claude/nip17-dm-migration-qxDbT
Deprecate NIP-04 and always use NIP-17 for DM sending
2026-03-19 15:25:38 -04:00
Vitor Pamplona
ad53bff8e3 Final touches to remove nip04 dms 2026-03-19 15:23:48 -04:00
Vitor Pamplona
f75971fc6a Merge pull request #1876 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-19 13:31:51 -04:00
Crowdin Bot
2f60697b28 New Crowdin translations by GitHub Action 2026-03-19 16:43:50 +00:00
Vitor Pamplona
3f4e265dc0 Brings the creation of Zap Polls back. 2026-03-19 12:40:14 -04:00
Claude
695d3c7a23 feat: deprecate NIP-04 DM sending, always use NIP-17
NIP-04 encryption for sending DMs is now deprecated. All new messages
are sent using NIP-17 (gift-wrapped sealed messages). Reading NIP-04
messages remains supported for backward compatibility.

When a recipient lacks both a DM relay list (kind 10050) and NIP-65
inbox relays, the send button is disabled and a warning is shown
explaining that messages cannot be delivered.

Changes:
- ChatNewMessageState: Remove nip17/requiresNip17 toggles, add
  recipientsMissingDmRelays state, always send via NIP-17
- ChatNewMessageViewModel: Always use NIP-17, remove NIP-04 send
  paths, add recipient relay status checking
- ToggleNip17Button -> Nip17Indicator: Replace toggle with static
  NIP-17 indicator (always on)
- PrivateMessageEditFieldRow: Show warning when recipients lack
  DM relay lists, hide message input
- ChatFileSender: sendAll() always uses NIP-17
- Desktop ChatPane: Remove NIP-17 toggle, show relay warning
- ChatroomView: Reactively check recipient DM relay availability

https://claude.ai/code/session_01T7QhUW9cZogk4DxDXbbbJJ
2026-03-19 15:22:13 +00:00
Vitor Pamplona
185fd96802 Merge branch 'main' into claude/strip-file-metadata-sVIEd 2026-03-19 10:06:20 -04:00
Vitor Pamplona
0f4555cfaa Merge pull request #1875 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-19 09:58:46 -04:00
Crowdin Bot
5903af2229 New Crowdin translations by GitHub Action 2026-03-19 13:56:12 +00:00
Vitor Pamplona
29db4ca0e3 Merge pull request #1825 from vitorpamplona/claude/event-sync-screen-sYGtN
Add Event Sync feature to redistribute events across relays
2026-03-19 09:54:26 -04:00
Vitor Pamplona
78ffb9fce4 Merge branch 'main' into claude/event-sync-screen-sYGtN 2026-03-19 09:54:20 -04:00
Vitor Pamplona
30f862cf26 Increase max parallel to 50 2026-03-19 09:50:50 -04:00
Vitor Pamplona
ab81b2a71d Improving calendar debug 2026-03-19 09:50:31 -04:00
Vitor Pamplona
ec29a47dc4 More debugging information 2026-03-19 09:34:52 -04:00
Vitor Pamplona
98578de840 Merge pull request #1874 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-19 08:51:23 -04:00
Crowdin Bot
311bbe19cf New Crowdin translations by GitHub Action 2026-03-19 12:45:49 +00:00
Vitor Pamplona
3f841c94ec Merge pull request #1873 from nrobi144/feat/desktop-media
feat(desktop): Full media parity — images, video, audio, encrypted DMs, upload, lightbox
2026-03-19 08:44:21 -04:00
nrobi144
5b2f2ca42b feat(chats): per-message encryption badge — lock for NIP-17, lock-open for NIP-04
Show a small lock icon next to the timestamp on each DM message:
- NIP-17 (ChatMessageEvent, ChatMessageEncryptedFileHeaderEvent): filled
  lock in primary color — relay can't see sender/recipient
- NIP-04 (PrivateDmEvent): open lock in muted gray — legacy encryption,
  metadata visible to relays

Matches Android's IncognitoBadge pattern. Both NIP-04 and NIP-17 messages
in the same 1-on-1 conversation produce identical ChatroomKeys, so they
merge into one conversation — the badge is the only way to tell them apart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 09:23:06 +02:00
nrobi144
f3e55f9958 fix(chats): handle ChatMessageEncryptedFileHeaderEvent in GiftWrap unwrap
The GiftWrap inner event handler only processed ChatMessageEvent (kind 14)
and ReactionEvent. ChatMessageEncryptedFileHeaderEvent (kind 15) was
silently dropped, so encrypted file attachments never appeared in DMs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 09:11:46 +02:00
nrobi144
35b58e489b feat(chats): right-click context menu on encrypted files — download + forward
Right-click on encrypted file attachments in DMs now shows a context menu:
- "Save to disk" — decrypts and saves via native file dialog
- "Forward" — placeholder callback for forwarding to another conversation

Also caches decrypted bytes (not just image bitmap) so save doesn't
re-download. Non-image files also get decrypted on load for save support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 09:06:26 +02:00
nrobi144
d9848b868b feat(chats): always show attach button, auto-force NIP-17 with snackbar
- Attach button always visible in DM input (not gated on NIP-17 mode)
- Drag-and-drop always enabled
- When files are attached in NIP-04 mode, auto-switches to NIP-17
  with snackbar: "Switched to NIP-17 — file attachments require
  encrypted messaging"
- NIP-17 toggle remains for manual control

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 09:02:05 +02:00
nrobi144
9187bf448f fix(chats): pass compactMode through RootContent — single-pane keeps split
compactMode was hardcoded in the Messages branch of RootContent, which is
shared between deck and single-pane. Now compactMode is a RootContent
parameter: deck passes true, single-pane defaults to false.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 07:53:03 +02:00
nrobi144
0184ebad11 fix(test): disambiguate mock upload call after ByteArray overload
The new ByteArray upload overload on DesktopBlossomClient made the
positional any() mock call ambiguous. Specify explicit type parameters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 07:51:22 +02:00
nrobi144
e06287acb3 feat(chats): stacked messages layout in deck columns
In multi-deck mode, Messages column now uses stacked navigation instead
of side-by-side split pane. Full-width contact list OR full-width chat —
clicking a conversation navigates to chat, back arrow returns to list.

Single-pane mode keeps the existing split layout (280dp list + flex chat).

Changes:
- Add compactMode param to DesktopMessagesScreen (default false)
- Extract SplitMessagesContent and CompactMessagesContent composables
- Add onBack callback to ChatPane with back arrow in header
- Remove hardcoded 280dp from ConversationListPane (caller controls width)
- Pass compactMode=true from deck RootContent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 07:49:36 +02:00
nrobi144
598256639b fix(chats): share single DesktopIAccount across deck — DMs now visible
DeckColumnContainer was creating its own DesktopIAccount (and ChatroomList)
for the Messages column, while Main.kt created a separate one for DM relay
subscriptions. Events were added to one ChatroomList but the UI read from
the other, so DMs never appeared.

Fix: thread the single iAccount from Main.kt through DeckLayout →
DeckColumnContainer → RootContent → DesktopMessagesScreen. Removed the
duplicate DesktopIAccount and DmSendTracker creation from RootContent.

Audited all other deck column types — no similar instance duplication found.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 07:31:27 +02:00
nrobi144
24d4073b9b feat(media): encrypted file sharing in desktop DMs (NIP-17 Phase 6)
Add full send + receive encrypted media support in desktop DM chat:
- Paperclip attach button and drag-and-drop in ChatPane (NIP-17 mode only)
- AES-GCM encryption before upload to Blossom server
- ChatMessageEncryptedFileHeaderEvent (kind 15) wrapped in GiftWrap
- sendNip17EncryptedFile() added to IAccount interface and implementations
- DesktopUploadOrchestrator.uploadEncrypted() with proper encrypted hash
- DesktopBlossomClient ByteArray upload overload for encrypted blobs
- LRU cache in EncryptedMediaService to avoid re-downloading
- Error handling: retry on failure, disable send during upload

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 06:53:36 +02:00
Vitor Pamplona
3e3adaff10 Finalizes the RelaySync utility 2026-03-18 17:35:05 -04:00
Vitor Pamplona
e57023cad9 Fix: Filters onCannotConnect to the relays that sent a req 2026-03-18 17:34:50 -04:00
davotoula
7bfc35e04d Fixes Applied
Code reuse
  - Extracted remuxTracks() helper to deduplicate video/audio remux loop (~40 lines)
  - Extracted stripAfterCompression() to deduplicate identical blocks in upload()/uploadEncrypted()
  - Replaced 8 identical showStrippingFailureDialog() copy-pastes with shared SuspendableConfirmation utility
  Privacy
  - 4 metadata ViewModels now error on strip failure instead of silently uploading unstripped media
  Efficiency
  - Eliminated temp input file copy for video/audio — MediaExtractor reads URIs directly
  Bug fixes
  - Fixed Long.toInt() overflow for MP3 files >2GB
  - Fixed MP3 temp file leak on exception and null InputStream paths
  Cleanup
  - Mutex field and wrapping the suspendCancellableCoroutine in mutex.withLock. Concurrent callers now queued
  - Converted MetadataStripper from class to object (stateless)
  - Moved StrippingFailureState from service layer to UI as generic ConfirmationCallbacks
2026-03-18 20:59:46 +01:00
davotoula
0cf256e5b2 Merge branch 'main-upstream' into claude/strip-file-metadata-sVIEd 2026-03-18 19:32:43 +01:00
davotoula
bfc04497d6 Profile picture upload fails with an error instead of silently uploading unstripped media when stripping fails 2026-03-18 19:25:57 +01:00
davotoula
eb13bab738 fix grammar 2026-03-18 18:31:34 +01:00
davotoula
97e6206244 fix persisted account preference 2026-03-18 18:31:07 +01:00
davotoula
a7ac30493c Extension mismatch fix
Temp file leak fix
2026-03-18 18:25:31 +01:00
davotoula
49c8423be5 moving the stripping decision after compressIfNeeded 2026-03-18 18:20:01 +01:00
davotoula
127079ede3 - Hide privacy toggle when video compression is selected (compression
already handles metadata stripping)
- Pass stripMetadata=false when toggle is hidden for compressed video
- Hide compression quality for audio and other non-compressible media types
- refactor
2026-03-18 13:54:15 +01:00
nrobi144
c90bf8f610 feat(media): add Note/Picture post type selector; fix gallery crash
- Post type toggle (Note vs Picture/kind 20) in compose dialog, only
  shown when image files are attached. Text input disabled in picture mode.
- Fix LazyVerticalGrid crash in GalleryTab: bounded height via
  fillParentMaxHeight() when nested inside LazyColumn.
- Phase 1 testing plan: all pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:42:44 +02:00
nrobi144
cf17dea53f feat(media): add Note/Picture post type selector in compose dialog
When images are attached, a Note/Picture toggle appears letting the user
publish as kind 20 (PictureEvent) instead of kind 1. Text input is
disabled in picture mode. Selector only shows for image file types.
Updates testing plan: Phase 1 all pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:37:48 +02:00
nrobi144
1d1d32da0e feat(media): add server selector to compose dialog; update testing plan
Server selector dropdown appears when files are attached, letting the
user choose which Blossom server to upload to. Updates testing plan:
Phase 2 & 3 all pass, Phase 9 audio tested with volume bug tracked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:26:47 +02:00
nrobi144
836e024bd7 feat(media): use VLC :start-volume for initial audio; document volume bug
Passes :start-volume media option to VLC on play. Removes polling/delay
volume hacks. Documents 9.7 volume bug in testing plan — VLC ignores
initial volume on macOS, needs further investigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:26:52 +02:00
nrobi144
f0c80f3c7c fix(media): delay volume enforcement — VLC audio output not ready at playing event
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:34:36 +02:00
nrobi144
11b9fdad80 fix(media): enforce volume on playback start — VLC resets volume on new media
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:30:57 +02:00
nrobi144
2a6087af27 feat(media): global media player — persistent playback across navigation
Media playback now survives navigation. A GlobalMediaPlayer singleton owns
VLC players and exposes StateFlows. Composables are thin viewports.
NowPlayingBar has full controls (volume, mute, save, fullscreen).
GlobalFullscreenOverlay renders video fullscreen above all screens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:27:13 +02:00
davotoula
20ab105e44 feat: add metadata stripping failure dialog to all upload ViewModels
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 11:12:36 +01:00
davotoula
c0632e17bd feat: add onStrippingFailed callback to upload orchestrators
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:51:30 +01:00
davotoula
9ce1ff3e63 feat: plumb stripMetadata from UI through ViewModels to upload orchestrators
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:50:16 +01:00
davotoula
3b1b15911f feat: rename stripLocationMetadata to stripMetadata in UI state and dialogs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:42:59 +01:00
davotoula
c76459859d feat: update all Screen call-sites for new stripMetadata onAdd parameter
Add 6th Boolean parameter to onAdd lambdas in all 6 files that call
ImageVideoDescription. The parameter is unused (_) for now in 5 sites
and named in ShortNotePostScreen for future plumbing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:38:04 +01:00
davotoula
0d514a46d7 feat: add privacy toggle to ImageVideoDescription
Add stripMetadata state and SettingSwitchItem toggle. Extend onAdd
callback signature with 6th Boolean parameter for stripMetadata.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:36:56 +01:00
davotoula
f7a9389a66 feat: add string resources for privacy toggle and metadata dialog
Rename strip_location_metadata_label/description to strip_metadata_label/description
to reflect broader scope. Add new strings for the metadata stripping failure dialog.
Update references in NewMediaView.kt and ChatFileUploadDialog.kt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:36:03 +01:00
davotoula
24e8487db6 fix: hide codec toggle when video compression is set to uncompressed
When the compression quality slider is set to UNCOMPRESSED (value 3),
no encoding happens so the H264/H265 codec choice is irrelevant. Hide
the codec toggle in this case to avoid confusion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:33:35 +01:00
davotoula
0e62904883 fix: hide compression slider for audio uploads
Audio has no compression support in MediaCompressor (falls through to
no-op), so showing the compression quality slider for audio files is
misleading. This removes audio from the slider visibility condition in
ImageVideoDescription and wraps the unconditionally-shown slider in
ChatFileUploadDialog with a media type check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:33:28 +01:00
davotoula
489e731eea feat: add MP3 ID3 tag stripping and early-exit for unsupported audio
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:29:09 +01:00
davotoula
b9d62edaad feat: support multiple tracks in audio metadata stripping
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:28:13 +01:00
davotoula
7219c787a6 fix: update direct MetadataStripper.strip() call sites for StrippingResult
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:26:02 +01:00
davotoula
4fc5166815 feat: propagate StrippingResult through UploadOrchestrator
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:25:55 +01:00
davotoula
f9176fc3fb feat: add StrippingResult data class to MetadataStripper
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:25:50 +01:00
nrobi144
6e1315a4fa feat(media): improve video player pool, thumbnail extraction, and NoteCard layout
Harden VLC player pool with dedicated thumbnail acquisition, better error
logging, and defensive buffer checks. Simplify lightbox/video controls,
fix NoteCard media sizing across feed, thread, bookmarks, and search screens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:24:00 +02:00
Vitor Pamplona
f25be5e7bd removes the benchmark action... it's not that useful. 2026-03-17 16:00:46 -04:00
Vitor Pamplona
bc5b254d01 merge 2026-03-17 15:57:44 -04:00
Vitor Pamplona
62811282a1 Merge pull request #1871 from vitorpamplona/claude/reorganize-nip47-wallet-mbVxR
Refactor NIP47 WalletConnect into organized subpackages
2026-03-17 15:47:51 -04:00
Vitor Pamplona
3e19195e4a Renaming and making a test case that considers limits.
Fixes active filter index bug
2026-03-17 15:45:52 -04:00
davotoula
72158e8fc2 Add rotation to stripped video 2026-03-17 20:27:15 +01:00
davotoula
e5e51c9c42 REMUX_BUFFER_SIZE = 8MB — extracted as a companion constant (was 1024 * 1024 inline in both video and audio). 8MB prevents silent frame loss on 4K keyframes.
stripAudioMetadata try/finally — now mirrors stripVideoMetadata exactly: muxer/extractor always released, incomplete output file deleted on failure.
2026-03-17 20:09:33 +01:00
Claude
5029db2302 refactor: reorganize nip47 wallet connect package into subpackages
Split the flat nip47WalletConnect package into logical subpackages:
- rpc/ - JSON-RPC protocol models (Request, Response, Notification, NwcMethod, NwcErrorCode, NwcTransaction, etc.)
- events/ - Nostr event types (LnZapPaymentRequestEvent, LnZapPaymentResponseEvent, NwcInfoEvent, NwcNotificationEvent)
- cache/ - Decryption caches (NostrWalletConnectRequestCache, NostrWalletConnectResponseCache)

Root level keeps the entry points: Nip47WalletConnect (URI parsing), Nip47Client, Nip47Server.
Existing subpackages (tags/, kotlinSerialization/, jackson/) remain unchanged.

https://claude.ai/code/session_018YcwMeTHPXmeqpbZHcBEUg
2026-03-17 19:04:24 +00:00
davotoula
d87bf37138 added comment about limitations 2026-03-17 19:53:57 +01:00
davotoula
e7d11fdc84 add Track interleaving 2026-03-17 19:51:58 +01:00
davotoula
a2f29d5e2e Early return with the original uri if there are no tracks 2026-03-17 19:43:16 +01:00
davotoula
4cf3a84190 delete orphaned output file on failure 2026-03-17 19:42:18 +01:00
davotoula
b61e1d5779 fix resource leak(s) on exception 2026-03-17 19:40:12 +01:00
Vitor Pamplona
b8351b8e29 Merge branch 'main' into claude/event-sync-screen-sYGtN 2026-03-17 14:17:13 -04:00
Vitor Pamplona
18fdfcc9d8 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  adaptability: use constant instead of repeated string literals
2026-03-17 14:10:45 -04:00
Vitor Pamplona
b22aa1ad0f Shows default banner if the user's image is not available 2026-03-17 14:02:09 -04:00
Vitor Pamplona
7f5baab5df Adds last seen to the user profile 2026-03-17 13:58:07 -04:00
davotoula
76f8130d40 Merge branch 'main-upstream' into claude/strip-file-metadata-sVIEd 2026-03-17 18:47:35 +01:00
Vitor Pamplona
aaa3bcfb2a Merge pull request #1870 from vitorpamplona/claude/fix-event-counter-per-filter-Aps6R
Add per-filter event counting for relay pagination
2026-03-17 13:46:10 -04:00
Claude
308e9fcb9e chore: remove unused coroutineContext import in EventSync
https://claude.ai/code/session_01Baaira4xoEHEsaSQCX52Sy
2026-03-17 17:43:04 +00:00
davotoula
a6c153a154 adaptability: use constant instead of repeated string literals 2026-03-17 18:42:39 +01:00
Claude
76c00dea3c fix: track event counts per filter in downloadFromRelay
Change the event counter from a single global counter to per-filter
tracking using Filter.match(). Each filter's limit is now respected
individually - fulfilled filters are excluded from subsequent pages,
and pagination stops when all filters with limits are satisfied.

https://claude.ai/code/session_01Baaira4xoEHEsaSQCX52Sy
2026-03-17 17:41:40 +00:00
Vitor Pamplona
504a218eee Merge branch 'main' into claude/event-sync-screen-sYGtN 2026-03-17 13:41:34 -04:00
Vitor Pamplona
97758a5163 fixes build 2026-03-17 13:35:50 -04:00
Vitor Pamplona
dd9e57542f Merge branch 'main' into claude/event-sync-screen-sYGtN 2026-03-17 13:28:36 -04:00
Vitor Pamplona
985cab73cd Merge branch 'main' into claude/strip-file-metadata-sVIEd 2026-03-17 13:27:00 -04:00
Vitor Pamplona
843058ce52 Merge pull request #1869 from vitorpamplona/claude/move-delete-button-topbar-SlZVz
Refactor DraftListScreen UI with top app bar improvements
2026-03-17 13:26:23 -04:00
Vitor Pamplona
91994a0b74 Merge branch 'main' into claude/strip-file-metadata-sVIEd 2026-03-17 13:25:54 -04:00
Vitor Pamplona
acb8068606 Merge pull request #1866 from vitorpamplona/claude/auto-load-transactions-scroll-KC8of
Add transaction filtering and pagination to wallet screen
2026-03-17 12:06:31 -04:00
Vitor Pamplona
cc98ac72c1 Revert to 20 since Alby only replies if limit is 20. 2026-03-17 12:04:10 -04:00
Vitor Pamplona
3764cd6d7f missing imports 2026-03-17 11:51:02 -04:00
Vitor Pamplona
e66e5e28c1 Merge branch 'main' into claude/auto-load-transactions-scroll-KC8of 2026-03-17 11:48:21 -04:00
Vitor Pamplona
c027e70c8b Merge pull request #1849 from vitorpamplona/claude/file-encryption-toggle-PwA8W
Add encrypted file upload with fallback option for NIP17 chats
2026-03-17 11:45:25 -04:00
Vitor Pamplona
30b785eec9 Merge branch 'main' into claude/file-encryption-toggle-PwA8W 2026-03-17 11:45:17 -04:00
Vitor Pamplona
4770f1eb90 Fixes sending url without cipher. 2026-03-17 11:43:24 -04:00
Claude
034cc0ab6d feat: move downloadFromRelay pagination logic to Quartz library
Extract the paginated relay download into a reusable INostrClient
extension (NostrClientDownloadFromRelayExt.kt) in the Quartz
commonMain accessories package. Uses Channel-based event collection
for KMP compatibility (no JVM-only atomics).

EventSyncViewModel.downloadFromRelay now delegates to the Quartz
extension, keeping the ViewModel thin.

Add NostrClientDownloadFromRelayTest integration test against
wss://nos.lol with Filter(kinds = [MetadataEvent.KIND], limit = 10)
to verify paginated downloads return correctly-typed events.

https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
2026-03-17 15:34:17 +00:00
Claude
bb32962f7d fix: prevent infinite loading spinner on wallet transactions screen
- Convert filteredTransactions from cold Flow to StateFlow via stateIn()
  to ensure reliable state updates with collectAsState()
- Add 30s timeout to NWC requests (fetchBalance, fetchTransactions,
  loadMoreTransactions) so loading state resets if wallet doesn't respond

https://claude.ai/code/session_01CdNY7qYDRHJr1jhAFvVgzj
2026-03-17 15:29:37 +00:00
Vitor Pamplona
e0dc4edf7a Merge branch 'main' into claude/event-sync-screen-sYGtN 2026-03-17 11:21:54 -04:00
nrobi144
d70b13ef33 fix(media): properly constrain video height within NoteCard
Replace aspectRatio modifier with BoxWithConstraints that manually
calculates height from aspect ratio and clamps it to the max height
constraint. Video now stays within its allocated space in the card,
so controls are accessible and content below is not overlapped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:09:40 +02:00
Vitor Pamplona
45e0dc9bd5 renames class and other small changes 2026-03-17 11:02:30 -04:00
nrobi144
fbde0052a8 fix(media): video respects max height, stays within NoteCard bounds
Remove fillMaxWidth before aspectRatio so the caller's heightIn(max)
constraint is respected. Video now sizes to fit within the NoteCard
without overlapping the author header or overflowing the card.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:56:42 +02:00
nrobi144
9a19148da8 fix(media): stable hover controls, fix text cutoff
- Single hover zone on entire video — no more flickering from competing
  zones. Play button always visible when paused, bottom controls appear
  on hover (works in fullscreen too)
- Media height capped to 50% of window (min 200dp) so post text is
  never pushed off-screen by large media

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:51:44 +02:00
nrobi144
248f38cc47 fix(media): zone-based video controls — play always visible, controls on hover
- Center play button always visible when paused
- Pause button appears on center hover when playing
- Bottom controls bar only appears when hovering the bottom area
- Controls also show when paused for discoverability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:32:08 +02:00
nrobi144
e9249c924c fix(media): only show video controls on hover
Controls (play button, seek bar, volume) are hidden by default and
appear when the mouse enters the video area. They auto-hide 2s after
the mouse leaves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:27:57 +02:00
nrobi144
bce6c12ab3 feat(media): video thumbnails in feed via VLC first-frame extraction
Add VideoThumbnailCache that grabs the first rendered frame from VLC
and caches it in memory. Videos in the feed now show a thumbnail
preview instead of a blank background before the user clicks play.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:12:16 +02:00
Vitor Pamplona
baa55ab7f9 reverts to amber's session start 2026-03-17 09:55:56 -04:00
nrobi144
89f39fa77e feat(search): show images and media in search results
Replace plain-text NotePreviewCard with full NoteCard in search results
so images, videos, and author avatars are visible inline without having
to open the note.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:40:48 +02:00
nrobi144
d091084064 fix(media): resize media to fit visible window, never truncate text
- Remove maxLines/TextOverflow from RichTextContent so post text is
  never truncated
- Media height = window height minus 200dp chrome, so each image/video
  fits in the visible area without scrolling
- Use ContentScale.Fit for images to scale down while keeping aspect
  ratio instead of filling width and overflowing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:33:54 +02:00
nrobi144
bdd1feae3e fix(ui): show full post text, scrollable profile header with floating bar
- Remove maxLines=10 on post text so content is never truncated
- Profile header/card/tabs now scroll with content instead of staying
  sticky — scrolls away naturally as you browse posts
- Floating compact header (back + avatar + name) slides in when
  scrolling up and the full header is out of view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:26:20 +02:00
Vitor Pamplona
9bf3086230 Make the session-start executable by default 2026-03-17 09:25:33 -04:00
Vitor Pamplona
345a569115 Merge pull request #1867 from vitorpamplona/claude/add-apk-pr-comments-gR7m7
Add direct APK download link and PR comments to benchmark workflow
2026-03-17 09:01:12 -04:00
nrobi144
2aa84c98ad fix(media): cap feed media height to 70% of window height
Replace hardcoded 400.dp max with window-relative height so media
(images and videos) never overflow the visible area and controls
are always accessible without scrolling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:53:58 +02:00
nrobi144
dffb996335 fix(media): immersive fullscreen via GraphicsDevice, separate view mode buttons
Replace WindowPlacement.Fullscreen (macOS maximize-only) with
GraphicsDevice.setFullScreenWindow() for true exclusive fullscreen that
hides menu bar and dock. Split single cycling view mode button into two
distinct toggles (theater + fullscreen). Hide all navigation chrome
(sidebar, nav rail, dividers, arrows, overlay menus) during fullscreen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:49:39 +02:00
davotoula
f10387025d spotlessApply 2026-03-17 07:27:56 +01:00
nrobi144
7d3d633e03 feat(media): bundle VLC, lazy video player, fullscreen with seek continuity
- Bundle VLC via ir.mahozad.vlc-setup plugin (all plugins for network playback)
- BundledVlcDiscoverer (Win/Linux) and MacOsVlcDiscoverer for bundled VLC discovery
- Lazy player activation — no VLC calls during composition, only on play click
- Pre-init VLC on background thread at startup
- Video controls: seek slider, volume, mute, fullscreen (two-row layout, no clipping)
- Buffering indicator (CircularProgressIndicator) while loading
- ActiveMediaManager enforces single-player — pauses others when new video plays
- Fullscreen passes seek position so lightbox resumes from current playback point
- LightboxOverlay renders videos (DesktopVideoPlayer) or images (ZoomableImage)
- Gitignore downloaded VLC binaries (appResources/{linux,macos,windows}/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 07:23:42 +02:00
Claude
dbf5d6954c fix: revert to artifact zip download, PR comment only
Use upload-artifact with artifact-id for direct zip download URL.
Remove prerelease approach and commit comments.

https://claude.ai/code/session_0138kUcZaPQoQcAc7nA1KDbf
2026-03-17 04:08:30 +00:00
Claude
706d282681 fix: direct APK download via prerelease asset, PR comment only
- Upload APK as a GitHub prerelease asset for direct .apk download
  (no zip wrapping like artifacts)
- Remove commit comment, only comment on open PRs
- Clean up existing release with same tag before creating

https://claude.ai/code/session_0138kUcZaPQoQcAc7nA1KDbf
2026-03-17 04:05:33 +00:00
Claude
61dc903e9a fix: use artifact ID for direct download URL instead of nightly.link
Use the artifact-id output from upload-artifact to construct a direct
GitHub artifact download URL. No third-party service needed — the URL
points to the artifact on GitHub which triggers a download when clicked.

https://claude.ai/code/session_0138kUcZaPQoQcAc7nA1KDbf
2026-03-17 04:03:18 +00:00
Vitor Pamplona
13da04e9f1 Merge pull request #1868 from vitorpamplona/claude/fix-missing-plugin-adHgr
Fix proxy bypass and improve code formatting
2026-03-16 23:51:49 -04:00
Claude
6bc927147c fix: use nightly.link for direct APK download without auth
GitHub artifact URLs require authentication and don't provide direct
file downloads. nightly.link proxies artifact downloads, giving a
direct download link that works for anyone without GitHub login.

https://claude.ai/code/session_0138kUcZaPQoQcAc7nA1KDbf
2026-03-17 03:47:47 +00:00
nrobi144
cd7ee69f50 feat(media): add imeta tags on upload and drag-and-drop
- Build IMetaTag (NIP-92) from upload metadata (mime, sha256, dim, blurhash, size)
- Use TextNoteEvent.build directly with tag initializer for imeta injection
- Add drag-and-drop file support via Compose DragAndDropTarget
- Filter dropped files by media extension whitelist
- Visual border highlight on drag-over state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:44:33 +02:00
nrobi144
adecc35721 feat(media): wire profile gallery tab and lightbox
- Add Notes/Gallery PrimaryTabRow to UserProfileScreen
- Subscribe to PictureEvent (kind 20) for gallery grid
- Wire GalleryTab composable with image click callbacks
- Wire LightboxOverlay for full-screen image viewing
- Pass onImageClick to FeedNoteCard in notes tab

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:44:33 +02:00
nrobi144
7773844d2f feat(media): wire encrypted media, server settings, and server preferences
- ChatPane: display ChatFileAttachment for encrypted file header events
- DesktopPreferences: add blossom server list persistence
- Settings: add MediaServerSettings section above relay settings
- ComposeNoteDialog: use preferred blossom server from preferences

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:44:33 +02:00
nrobi144
e654d48cdf fix(media): code review — dead code removal, perf fixes, bug fixes
- Remove unused: MediaAspectRatioCache, VideoPlayerState, resetZoom,
  onDoubleClick, delete/headUpload/createDeleteAuth, encryptAndUpload,
  EncryptedUploadResult, unused options params, formatAudioTime dupe
- Fix VlcjPlayerPool.release() accumulating stale event listeners
- Fix SaveMediaAction FileDialog running on IO instead of EDT
- Pre-allocate video frame ByteArray to avoid ~240MB/s GC pressure
- Pool audio players in VlcjPlayerPool instead of factory-per-instance
- Remove listener in onDispose before returning player to pool
- Fix acquire() race condition by moving poll inside synchronized
- Reduce memory cache to 15%/256MB with weak references
- Parallelize server health checks
- Remove redundant Content-Type/Content-Length headers
- Deduplicate BlurHashFetcher bitmap conversion via bufferedImageToSkiaBitmap
- Hoist audioExtensions to top-level constant, rename allMediaUrls
- Remove nonfunctional Save button and dead decryptedBytes state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:44:33 +02:00
nrobi144
d5facd90df test(media): add 49 unit tests for media upload, encryption, and server services
Coverage for DesktopMediaMetadata, DesktopUploadTracker, DesktopMediaCompressor,
DesktopBlossomClient (MockK), DesktopUploadOrchestrator (MockK + mockkObject),
ServerHealthCheck, and AESGCM encrypt/decrypt round-trip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:44:33 +02:00
nrobi144
dfaca39528 feat(media): inline audio playback with VLCJ no-video mode
Phase 9: AudioPlayer composable using VLCJ --no-video factory,
play/pause/seek controls, NoteCard splits audio from video URLs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:44:33 +02:00
nrobi144
4450aacdf9 feat(media): Blossom server management settings with health checks
Phase 8: MediaServerSettings UI for add/remove/reorder Blossom servers,
ServerHealthCheck with 5s timeout HEAD requests, status indicators.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:44:33 +02:00
nrobi144
ca4e51afa9 feat(media): NIP-68 picture display and profile gallery grid
Phase 7: PictureDisplay composable for kind 20 events with image-first
layout, GalleryTab with adaptive grid of thumbnails, lightbox support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:44:33 +02:00
nrobi144
f0acbb1934 feat(media): encrypted media service for NIP-17 DM file sharing
Phase 6: AESGCM encrypt/decrypt for DM file attachments, Blossom
upload of encrypted files, ChatFileAttachment composable with
auto-decrypt for images and file type display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:44:33 +02:00
nrobi144
1a2dc9efac feat(media): lightbox overlay with zoom, gallery navigation, and save
Phase 5: Full-screen lightbox overlay with mouse wheel zoom + pan,
left/right gallery carousel, keyboard shortcuts (Esc/arrows/Ctrl+S),
save to disk via FileDialog. Images in NoteCard are clickable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:44:33 +02:00
nrobi144
405601463e feat(media): VLCJ video playback with DirectRendering and player pool
Phase 4: VLCJ player pool (max 3 instances), DirectRendering via
CallbackVideoSurface → Skia Bitmap → Compose Image. Video controls
with auto-hide, seek, play/pause. Graceful fallback when VLC missing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:44:33 +02:00
nrobi144
2181407647 feat(media): desktop upload UX with file picker, clipboard paste, and attachment row
Phase 3: Native file dialog, AWT clipboard paste handler, media
attachment UI with thumbnails, and ComposeNoteDialog media integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:44:33 +02:00
nrobi144
443f7c6a09 feat(media): Phase 2 — Desktop Blossom upload client
- DesktopBlossomClient: PUT /upload, DELETE, HEAD /upload (BUD-06)
- DesktopBlossomAuth: kind 24242 auth with base64 Nostr header
- DesktopMediaMetadata: SHA-256, dimensions, blurhash via ImageIO
- DesktopMediaCompressor: lossless EXIF stripping (Commons Imaging)
- DesktopUploadOrchestrator: strip EXIF → metadata → auth → upload
- DesktopUploadTracker: StateFlow-based upload state tracking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:44:33 +02:00
nrobi144
212dda40ca feat(media): Phase 0+1 — Coil3 image loading + inline images in notes
- DesktopImageLoaderSetup: Coil3 with explicit memory cache (25% heap,
  max 512MB), persistent disk cache (OS-appropriate paths), SVG decoder
- DesktopBlurHashFetcher: blurhash placeholder images via Skia Bitmap
- DesktopBase64Fetcher: base64 data: URI image decoding
- SkiaGifDecoder: GIF first-frame rendering via Skia Codec
- MediaAspectRatioCache: thread-safe LruCache for aspect ratios
- NoteCard: inline AsyncImage for image URLs, stripped from text
- Added coil-compose, coil-okhttp, coil-svg, vlcj, commons-imaging,
  androidx-collection deps to desktopApp

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:44:33 +02:00
Claude
883a2f995e style: apply ktlint formatting fixes
https://claude.ai/code/session_01HeWiRdKDTnMGfEBUegk3d1
2026-03-17 03:43:19 +00:00
Claude
1c4f9769b1 fix: session-start hook fails silently when dl.google.com is blocked
The Claude Code web egress proxy does not include dl.google.com in its
allowlist, causing the session-start hook to abort entirely (due to
set -euo pipefail) when Android SDK downloads fail. This cascading
failure also prevented local.properties creation and ANDROID_HOME export.

Additionally, JAVA_TOOL_OPTIONS contains nonProxyHosts entries for
*.google.com and *.googleapis.com, which causes the JVM to bypass the
proxy for Google Maven, resulting in connection failures.

Changes:
- Remove set -euo pipefail; use per-section error handling instead
- Fix JAVA_TOOL_OPTIONS: strip *.google.com from nonProxyHosts
- Fix no_proxy: remove *.google.com and *.googleapis.com entries
- Move local.properties and env exports before SDK download section
- Make SDK downloads non-fatal with clear warning messages
- Install standalone ktlint as fallback for spotlessApply
- Create spotless-apply wrapper script for proxy-blocked environments
- Update Stop hook to fall back to spotless-apply when Gradle fails

https://claude.ai/code/session_01HeWiRdKDTnMGfEBUegk3d1
2026-03-17 03:43:10 +00:00
Claude
af12d13d67 fix: use artifact-url output for direct APK download link
The manually constructed URL was invalid. Use the artifact-url output
from upload-artifact which provides the correct download URL.

https://claude.ai/code/session_0138kUcZaPQoQcAc7nA1KDbf
2026-03-17 03:34:53 +00:00
Claude
39e15a8929 feat: comment benchmark APK link on open PRs
Add PR commenting to the build-benchmark-apk workflow. When a build
completes, it now finds any open PR for the branch and posts a comment
with a direct download link to the benchmark APK artifact.

https://claude.ai/code/session_0138kUcZaPQoQcAc7nA1KDbf
2026-03-17 03:23:00 +00:00
Claude
866dfca710 fix: rename _transactions to allTransactions to fix ktlint backing-property-naming
The backing property _transactions no longer has a matching public
property since it was replaced by filteredTransactions.

https://claude.ai/code/session_01CdNY7qYDRHJr1jhAFvVgzj
2026-03-17 03:07:57 +00:00
Vitor Pamplona
73d1e39587 Merge branch 'main' into claude/auto-load-transactions-scroll-KC8of 2026-03-16 23:03:09 -04:00
Claude
856e85988a feat: improve Android push notifications with modern notification APIs
- Use MessagingStyle for DM notifications with sender avatars and
  conversation formatting (integrates with Android Bubbles/conversation space)
- Add Direct Reply action so users can respond to DMs from the
  notification shade without opening the app
- Add Mark as Read action with SEMANTIC_ACTION_MARK_AS_READ for
  Android Auto/Wear OS integration
- Apply notification grouping with InboxStyle summary notifications
  for both DMs and Zaps (group keys were defined but never used)
- Use BigTextStyle for zap notifications so long messages expand
- Set proper notification categories (CATEGORY_MESSAGE for DMs,
  CATEGORY_SOCIAL for zaps) for OS-level prioritization and DND bypass
- Fix PendingIntent flag from FLAG_MUTABLE to FLAG_IMMUTABLE where
  mutability is not needed (security improvement)
- Replace blocking image load (executeBlocking) with suspend-friendly
  Coil execute() on IO dispatcher
- Upgrade DM channel importance to IMPORTANCE_HIGH for heads-up display

https://claude.ai/code/session_012w6K8H4nvZYxeRryW2USAp
2026-03-17 03:00:08 +00:00
Claude
11950dc4f4 feat: add transaction filter for zaps vs non-zaps on wallet screen
Adds filter chips (All / Zaps / Non-Zaps) to the transactions list.
Zap transactions are identified by having nostr metadata. The filter
is applied client-side via a combined Flow in WalletViewModel.

https://claude.ai/code/session_01CdNY7qYDRHJr1jhAFvVgzj
2026-03-17 02:55:16 +00:00
Claude
c3f7e491a6 feat: increase wallet transaction page size to 100
https://claude.ai/code/session_01CdNY7qYDRHJr1jhAFvVgzj
2026-03-17 02:34:15 +00:00
Vitor Pamplona
7a3b1afacc Merge pull request #1865 from vitorpamplona/claude/add-apk-benchmark-action-5c4UX
Add GitHub Actions workflow for building benchmark APK
2026-03-16 22:33:15 -04:00
Claude
61e85df8db feat: post commit comment when benchmark APK is ready
Adds a GitHub Script step that comments on the commit with a direct
link to the workflow artifacts, making it visible in PRs and branch
pages.

https://claude.ai/code/session_013hcKzBgJgfsrArSgTTGNEk
2026-03-17 02:27:53 +00:00
Claude
146f3f91b5 feat: add GitHub Action to build benchmark APK on claude branches
Adds a lightweight workflow that triggers on pushes to claude/* branches,
building only the Play Benchmark APK for quick testing after Claude
completes a task.

https://claude.ai/code/session_013hcKzBgJgfsrArSgTTGNEk
2026-03-17 02:20:47 +00:00
Claude
0459633d82 feat: move delete all button to top bar as icon in drafts screen
Replace the ElevatedButton in a sticky header with a delete icon
(Icons.Default.Delete) in the top bar actions area. The confirmation
dialog is preserved and now lives at the screen level, reading the
current feed state directly when confirmed.

https://claude.ai/code/session_01AbiBeDnT4KPKD16sKKaLD8
2026-03-17 02:20:13 +00:00
Claude
4a430e6663 feat: auto-load wallet transactions on scroll
Add infinite scroll pagination to the wallet transactions screen.
When the user scrolls within 5 items of the bottom, the next page of
transactions is automatically fetched and appended. Uses the existing
NWC list_transactions limit/offset support and total_count to know
when all transactions have been loaded.

https://claude.ai/code/session_01CdNY7qYDRHJr1jhAFvVgzj
2026-03-17 02:09:44 +00:00
Vitor Pamplona
d0297b7e34 Fixes stability plugin for AGP9 2026-03-16 19:44:51 -04:00
Vitor Pamplona
56cce46858 Solving all problems with Quartz tests 2026-03-16 19:22:28 -04:00
Vitor Pamplona
962da3280b Moves the N44 test to Jvmtest 2026-03-16 18:57:00 -04:00
Vitor Pamplona
b0c714176a Updates versions for github actions 2026-03-16 18:46:39 -04:00
Vitor Pamplona
6ffc902598 Unifies UriParser 2026-03-16 18:15:43 -04:00
Vitor Pamplona
790fd45470 Removes warnings 2026-03-16 18:10:55 -04:00
Vitor Pamplona
f708f6c265 Specify which .def file is used between simulators and ios Devices 2026-03-16 17:46:20 -04:00
Vitor Pamplona
7f61b4819b new log fuctions mocked 2026-03-16 17:45:05 -04:00
Vitor Pamplona
e7760dc7c7 adds bundleId to the binary file 2026-03-16 17:16:18 -04:00
Vitor Pamplona
12c27af813 cancel other running builds in github actions when a new one comes up 2026-03-16 17:16:05 -04:00
Vitor Pamplona
7e2912ffd8 removes more warnings 2026-03-16 17:15:33 -04:00
Vitor Pamplona
874d750bf0 removing unused import 2026-03-16 17:01:59 -04:00
Vitor Pamplona
95c7adc90b Less warnings 2026-03-16 16:53:17 -04:00
Vitor Pamplona
b96c1c2c3a Fixes iosGzip 2026-03-16 16:38:31 -04:00
Vitor Pamplona
b916817247 Finishes serialization features for NIP-47 2026-03-16 16:35:05 -04:00
Vitor Pamplona
2d959b0108 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  drawerGesturesEnabled is true whenever the drawer is in transition (target != current)
  Split the logic between onPreScroll and onPostScroll
  New Crowdin translations by GitHub Action
  New Crowdin translations by GitHub Action
  Unnecessary ?. / ?: / !! on non-null Missing @OptIn annotations Parameter name mismatches Icons.Filled.*  / Icons.AutoMirrored.* No cast needed / remove inline LocalLifecycleOwner import fix NIP-51 name() → title() Chess gameId → startEventId Nullable DecimalFormat safe calls DelicateCoroutinesApi opt-in
  use 50/50 split for pager vs dock zones
  fix: allow swipe-to-close when drawer is open on pager screens
2026-03-16 16:28:55 -04:00
Vitor Pamplona
abf2151bac Damus' relay is way too restrictive 2026-03-16 16:28:42 -04:00
Vitor Pamplona
56f7d83b5b Merge pull request #1860 from davotoula/swipe-on-main-screen
Swipe to switch tabs. Main screen and messages
2026-03-16 16:14:53 -04:00
Vitor Pamplona
dd55827486 Merge pull request #1863 from davotoula/reduce-compilation-warnings
Reduce compilation warnings
2026-03-16 16:14:38 -04:00
David Kaspar
645affd511 Merge pull request #1864 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-16 18:34:37 +00:00
Crowdin Bot
1a671fd0b4 New Crowdin translations by GitHub Action 2026-03-16 18:27:11 +00:00
David Kaspar
0c8a462ddf Merge pull request #1862 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-16 18:25:26 +00:00
davotoula
9d95817b4d drawerGesturesEnabled is true whenever the drawer is in transition (target != current) 2026-03-16 19:04:01 +01:00
davotoula
bf6ee6dc87 Split the logic between onPreScroll and onPostScroll 2026-03-16 19:01:51 +01:00
Crowdin Bot
21b6f9c67e New Crowdin translations by GitHub Action 2026-03-16 17:50:19 +00:00
David Kaspar
d9c95de736 Merge pull request #1861 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-16 17:48:10 +00:00
Vitor Pamplona
7c22c4e6c5 Migrates to collections 1.6, which deprecates iOS Intel targets. 2026-03-16 13:45:01 -04:00
Crowdin Bot
1af1383428 New Crowdin translations by GitHub Action 2026-03-16 17:09:41 +00:00
Vitor Pamplona
ea6116cd5c Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  Fix swipe-back triggering unwanted draft deletion signing
  fix: revert ReactionsSettingsScreen to upstream version
  fix(nav): preserve screen state across overlay navigation
  feat(search): collapsible section headers + sorting + search improvements
  fix(ui): remove duplicate back button from SinglePaneLayout
  refactor(search): extract thread-safe EventDeduplicator, unify dedup
  fix(search): loading indicator, result delivery, and history race
  fix(search): cleanup review findings — dead code, FQN refs, naming
  refactor(search): extract shared code to commons, remove dead code
  feat(search): add keyboard shortcuts, search history UI, and operator hints
  feat(search): add SearchHistoryStore with recent queries and saved searches
  feat(search): add advanced search UI with panel, results list, and SearchScreen rewrite
  feat(search): add AdvancedSearchBarState with bidirectional sync
  feat(search): add SearchFilterFactory and SearchResultFilter
  feat(search): add query engine — SearchQuery, QueryParser, QuerySerializer, KindRegistry
2026-03-16 13:02:52 -04:00
Vitor Pamplona
81ecaa82ff Migrates to vico charts 3.0 2026-03-16 12:53:54 -04:00
davotoula
588cfe4c96 Unnecessary ?. / ?: / !! on non-null
Missing @OptIn annotations
Parameter name mismatches
Icons.Filled.*  / Icons.AutoMirrored.*
No cast needed / remove inline
LocalLifecycleOwner import fix
NIP-51 name() → title()
Chess gameId → startEventId
Nullable DecimalFormat safe calls
DelicateCoroutinesApi opt-in
2026-03-16 16:23:08 +01:00
davotoula
844847d871 use 50/50 split for pager vs dock zones 2026-03-16 15:40:59 +01:00
davotoula
2a9c9e4510 fix: allow swipe-to-close when drawer is open on pager screens
Keep drawer gestures enabled when the drawer is open so users can
swipe left to close it. Only disable gestures when the drawer is
closed (to let the pager and ZonedSwipeModifier handle gestures).
2026-03-16 15:40:40 +01:00
Vitor Pamplona
714afdc2a8 Update dependencies 2026-03-16 09:35:41 -04:00
Vitor Pamplona
7f6219c846 Merge pull request #1859 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-16 08:19:13 -04:00
Crowdin Bot
be09535cd6 New Crowdin translations by GitHub Action 2026-03-16 12:17:53 +00:00
Vitor Pamplona
2acf02945b Merge pull request #1840 from nrobi144/feat/desktop-advanced-search
feat(desktop): advanced search with NIP-50, collapsible sections, and nav state preservation
2026-03-16 08:14:18 -04:00
Vitor Pamplona
f5d4d967eb Merge pull request #1858 from dadofsambonzuki/fix/dm-swipe-back-signing
Fix swipe-back triggering unwanted draft deletion signing
2026-03-16 08:12:56 -04:00
Nathan Day
1c663cb26b Fix swipe-back triggering unwanted draft deletion signing
When swiping back on a DM screen with an empty message, the BackHandler
was calling sendDraftSync() which checks if the message is blank and then
calls deleteDraftIgnoreErrors(). This creates and signs a NIP-09 DeletionEvent,
prompting the external signer to sign a deletion event.

The fix adds a check to only call sendDraftSync() when there's actual
content in the message, avoiding the unnecessary signing prompt.
2026-03-16 10:32:18 +00:00
davotoula
c56665d4dd spotlessApply 2026-03-16 10:51:24 +01:00
nrobi144
e4b4606ab9 fix: revert ReactionsSettingsScreen to upstream version
Spotless reformatted this file during merge. Restoring upstream's
version to avoid unintended changes to the mobile app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 07:35:09 +02:00
Vitor Pamplona
78ffd5ec6b Moves nip06 tests to the right package. 2026-03-15 21:32:02 -04:00
Vitor Pamplona
d426dbd4ff adds icons for desktop releases 2026-03-15 21:10:59 -04:00
Vitor Pamplona
04b4429809 removes another deprecation 2026-03-15 20:57:46 -04:00
Vitor Pamplona
d10b43c098 Migrates old Preview annotation 2026-03-15 20:55:55 -04:00
Vitor Pamplona
8b4a45cb5b Removing deprecated library addresses and deprecated apis 2026-03-15 20:52:01 -04:00
Vitor Pamplona
bda7e48a84 removes more warnings 2026-03-15 20:33:31 -04:00
Vitor Pamplona
e87ac7468d Removing Locale deprecations 2026-03-15 20:29:05 -04:00
Vitor Pamplona
49f5d9518e Fixes warnings 2026-03-15 20:26:31 -04:00
Vitor Pamplona
19ca7afcb8 fixes warnings 2026-03-15 20:06:20 -04:00
Vitor Pamplona
1cdd061b06 removing warnings 2026-03-15 20:03:39 -04:00
Vitor Pamplona
929b5d6175 Merge pull request #1853 from vitorpamplona/claude/add-relay-event-counters-c7stK
Add NIP-45 COUNT query support to relay management UI
2026-03-15 19:58:19 -04:00
Vitor Pamplona
45b947ec68 Merge branch 'main' into claude/add-relay-event-counters-c7stK 2026-03-15 19:58:12 -04:00
Vitor Pamplona
88565d02ad Merge pull request #1856 from vitorpamplona/claude/cross-platform-github-actions-xeNlt
Refactor CI/CD workflows for multi-platform builds
2026-03-15 19:57:59 -04:00
Vitor Pamplona
0e476c8988 Adds test and some refinements. 2026-03-15 19:51:39 -04:00
Claude
d996911cda feat: add cross-platform CI for Linux, macOS, and Windows
- Split build.yml into lint, test (matrix: 3 OS), build-android, and
  build-desktop (matrix: 3 OS with native packaging) jobs
- Tests now run on ubuntu, macos, and windows to validate cross-platform
  compatibility
- Desktop distributions (DEB, DMG, MSI) built on their native OS and
  uploaded as artifacts
- Split create-release.yml into create-release, deploy-android, and
  deploy-desktop jobs so desktop distributions are included in releases
- Android APK/AAB builds and Quartz publishing remain on Linux

https://claude.ai/code/session_016dxFUErY4P6WRir4xxhCEr
2026-03-15 21:34:18 +00:00
Vitor Pamplona
61089fa866 Merge branch 'main' into claude/add-relay-event-counters-c7stK 2026-03-15 17:30:23 -04:00
Vitor Pamplona
91a9dcb9a1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  Rename build step for Benchmark APK in workflow
  compilation error fix: Changed map to mapNotNull
  spotlessApply
2026-03-15 17:16:00 -04:00
Vitor Pamplona
3624d584db Merge pull request #1855 from vitorpamplona/davotoula-patch-1
Rename build step for Benchmark APK in workflow
2026-03-15 17:15:39 -04:00
Vitor Pamplona
2fd29ab365 uses quiet mode to avoid lots of warnings in the commit screen 2026-03-15 17:15:16 -04:00
Vitor Pamplona
cef8f5acde Fixes test case 2026-03-15 17:14:57 -04:00
David Kaspar
76c6f50fa8 Rename build step for Benchmark APK in workflow 2026-03-15 20:57:02 +00:00
Vitor Pamplona
6216f7a618 no casts needed 2026-03-15 16:55:00 -04:00
Vitor Pamplona
0c939bd447 converted from Java arraylist to kotlin arraylist 2026-03-15 16:50:34 -04:00
davotoula
3cfa9e1619 compilation error fix: Changed map to mapNotNull 2026-03-15 21:37:21 +01:00
davotoula
cd9d345a0e spotlessApply 2026-03-15 21:35:23 +01:00
Vitor Pamplona
30b76347c5 Spotless 2026-03-15 16:15:44 -04:00
Vitor Pamplona
04d04ebe69 Merge pull request #1854 from vitorpamplona/claude/kotlin-serialization-mapper-4WYOP
Add Kotlin Serialization support for Nostr protocol types
2026-03-15 16:04:21 -04:00
Vitor Pamplona
ea48c2a5ec Merge branch 'main' into claude/kotlin-serialization-mapper-4WYOP 2026-03-15 16:03:55 -04:00
Vitor Pamplona
badc681511 Merge pull request #1851 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-15 16:01:44 -04:00
Claude
d10687d6fe feat: Implement iOS OptimizedJsonMapper using KotlinSerializationMapper
Replace all TODO stubs in the iOS actual implementation with delegation
to KotlinSerializationMapper. Wraps SerializationException into
IllegalArgumentException to match the jvmAndroid error handling pattern.

https://claude.ai/code/session_01QBc4Pb7a6m4TfLZSJKVdjw
2026-03-15 19:58:40 +00:00
Claude
3bea2f795c refactor: Move KSerializers to their respective NIP packages
- Bunker serializers → nip46RemoteSigner/kotlinSerialization/
- NIP-47 serializers → nip47WalletConnect/kotlinSerialization/
- Rumor serializer → nip59Giftwrap/rumors/kotlinSerialization/

Mirrors the existing Jackson serializer directory structure which uses
a jackson/ subpackage within each NIP package.

https://claude.ai/code/session_01QBc4Pb7a6m4TfLZSJKVdjw
2026-03-15 19:55:19 +00:00
Claude
ffac682238 feat: Add kotlinx.serialization KSerializers mirroring all Jackson custom serializers
Add 15 KSerializer implementations in commonMain that replicate the exact
behavior of all 21 Jackson serializer/deserializer registrations:

- EventKSerializer, TagArrayKSerializer, FilterKSerializer
- EventTemplateKSerializer, RumorKSerializer, CountResultKSerializer
- MessageKSerializer (8 message types), CommandKSerializer (5 command types)
- BunkerRequest/Response/MessageKSerializer (NIP-46)
- Nip47Request/Response/NotificationKSerializer (NIP-47)
- KotlinSerializationMapper as the main entry point

Uses JsonElement-based approach for performance. All serializers produce
identical JSON output to Jackson. Includes comprehensive test suite (35+
tests) verifying exact output parity and cross-deserialization between
Jackson and kotlinx.serialization.

https://claude.ai/code/session_01QBc4Pb7a6m4TfLZSJKVdjw
2026-03-15 16:37:22 +00:00
Claude
22945b7faa feat: add queryCountSuspend utility to Quartz and simplify ViewModels
Add INostrClient.queryCountSuspend() extension functions in Quartz that
wrap NIP-45 COUNT queries as suspend functions, managing subscription
lifecycle internally. Two overloads: single-relay and multi-relay.

Simplify BasicRelaySetupInfoModel and Nip65RelayListViewModel to use the
new utility — removes IRelayClientListener implementation, subId tracking
maps, onIncomingMessage handlers, and cleanup logic from both ViewModels.

https://claude.ai/code/session_016158D5mq5BygS1uBbLNbsA
2026-03-15 16:14:24 +00:00
Crowdin Bot
d8de0a9b84 New Crowdin translations by GitHub Action 2026-03-15 15:47:20 +00:00
Claude
17c3611f50 feat: redesign RelayEventCountRow with pill chip style
Replace the plain icon+text count display with rounded pill chips that
have a subtle green border and tinted background. Each count entry gets
its own pill with an icon and bold text, making the event counts more
visually distinct and scannable.

https://claude.ai/code/session_016158D5mq5BygS1uBbLNbsA
2026-03-15 15:46:56 +00:00
Vitor Pamplona
3bd6ef5f6c changes from damus to nos.lol for more stable relay behavior in tests 2026-03-15 11:44:20 -04:00
Vitor Pamplona
2b157cc383 Fixes throw vs null test cases and nsec hallucinations 2026-03-15 11:18:39 -04:00
Vitor Pamplona
a793ff149b Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-03-15 11:18:10 -04:00
Vitor Pamplona
c0a81ef9a2 Merge pull request #1850 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-15 11:07:30 -04:00
Crowdin Bot
68f58c3b8d New Crowdin translations by GitHub Action 2026-03-15 15:06:39 +00:00
Vitor Pamplona
0380927f7e fromJsonTo calls throw, they should always be wrapped if we want null results. 2026-03-15 11:05:40 -04:00
Vitor Pamplona
2c91fdea15 Avoids using format to write hex-encoded strings. 2026-03-15 11:03:50 -04:00
Claude
764c2241ca refactor: integrate relay event counts into each ViewModel
Move count query logic from separate RelayEventCountViewModel into each
relay list's own ViewModel. BasicRelaySetupInfoModel now manages counts
via countFilters() override. Nip65RelayListViewModel gets its own count
infrastructure for home (outbox) and notification (inbox) relay lists.
Simplify AllRelayListScreen by removing 7 extra count ViewModels and
their LaunchedEffects.

https://claude.ai/code/session_016158D5mq5BygS1uBbLNbsA
2026-03-15 15:02:09 +00:00
Claude
b1723a5dfb feat: add relay event count stats to AllRelay settings screen
Use NostrClient's NIP-45 COUNT queries to show how many events each
relay stores, with filters specific to each relay role:
- Outbox: events authored by the user
- Inbox: events where the user is p-tagged
- DM Inbox: DM events (kind 4, 1059) tagging the user
- Private Home: events authored by the user
- Proxy: total event count
- Search: total event count
- Indexer: kind 0 and kind 10002 counts separately
- Broadcast: no count (as specified)

https://claude.ai/code/session_016158D5mq5BygS1uBbLNbsA
2026-03-15 14:41:19 +00:00
Claude
997a59dc23 feat: add file encryption toggle for encrypted DMs with retry on failure
Add an "Encrypt files" toggle (default: on) to the file upload dialog in
NIP-17 encrypted DMs. When encrypted upload fails, show an informative
dialog explaining that many servers don't accept encrypted files on free
accounts, with an option to retry without encryption. The retry dialog
warns that without encryption anyone with the link can see the content.

https://claude.ai/code/session_012xvKzHrZPq3ZTAzBN2LvrR
2026-03-15 14:30:26 +00:00
Claude
aecbcb68a5 feat: per-relay recv/new counts and OK-true acceptance tracking
ViewModel:
- MAX_ACTIVITY_LOG raised to 5000
- Remove activeRelays from LiveSyncActivity (active relay chips removed)
- Add eventsAccepted: Int to CompletedRelayInfo
- Add totalEventsAccepted: Int to SyncState.Done
- Register IRelayClientListener on account.client for the duration of
  the sync to intercept OkMessage(success=true) from destination relays
- sourceRelayOfEvent map attributes each forwarded event to the relay
  it was first seen on; ConcurrentHashMap.remove() ensures the first
  OK true is counted exactly once per event (dedup across dest relays)
- acceptedCountPerRelay accumulates per-source-relay accepted counts,
  read at onRelayComplete time
- downloadFromPool.onEvent changed to (Event, NormalizedRelayUrl) to
  carry the source relay into runSync routing logic

Screen:
- Remove ActiveRelaysCard and ActiveRelayChip composables + preview
- Remove animation imports (no longer needed)
- ActivityLogRow now shows two right-hand columns per relay:
    recv N  — events received from that source relay
    new N   — events accepted as new by destination relays (highlighted
               in primary color when > 0)
- DoneCard now shows three lines:
    Forwarded N events to destination relays.
    M events accepted as new by destination relays.  ← key headline
    Completed in N seconds.

Strings: event_sync_done_body replaced by done_sent / done_accepted /
done_duration; event_sync_reading_from and events_found_log removed;
log_recv and log_new added.

https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
2026-03-15 14:19:08 +00:00
David Kaspar
a21e38f428 Merge pull request #1847 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-15 11:53:03 +00:00
Crowdin Bot
7999010390 New Crowdin translations by GitHub Action 2026-03-15 11:51:55 +00:00
David Kaspar
00fb924dbb Update README with build instructions and requirements
Added instructions for full build and requirements.
2026-03-15 11:50:24 +00:00
davotoula
f28fdd8404 Convert between flag types
spotlessApply
2026-03-14 21:39:27 +01:00
David Kaspar
f3aafd3884 Merge pull request #1845 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-14 20:26:58 +00:00
Crowdin Bot
2df98c456a New Crowdin translations by GitHub Action 2026-03-14 20:04:41 +00:00
David Kaspar
21bf8a343a Merge pull request #1844 from davotoula/update-translations
update cz, pt, de, sv
2026-03-14 20:03:08 +00:00
David Kaspar
caee58da55 Add migrations.xml to .gitignore 2026-03-14 20:02:48 +00:00
davotoula
64bf1d5090 update cz, pt, de, sv 2026-03-14 21:00:31 +01:00
Vitor Pamplona
ee449f78f4 Merge pull request #1843 from davotoula/bugfix-multiple-spinners-on-upload
Bugfix: multiple spinners on upload
2026-03-14 14:10:22 -04:00
David Kaspar
9d14a2d840 Merge pull request #1842 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-14 17:30:32 +00:00
davotoula
795da8cf1b Block Add to message button while uploading 2026-03-14 18:00:36 +01:00
davotoula
1fc0ba8628 refactor:
extract MediaUploadTracker with common methods

bugfixes:
 ChatFileUploadState.canPost() was missing isUploadingFile check
 ChatNewMessageViewModel.cancel() was never resetting upload flags
 Double-reset in ChatFileUploader and ChannelNewMessageViewModel
2026-03-14 18:00:36 +01:00
davotoula
42542159d4 bugfix: remove dead code
bugfix: ChannelNewMessageViewModel canPost checks wrong state
2026-03-14 18:00:36 +01:00
davotoula
c290a1c779 - SelectFromGallery and SelectFromFiles now accept an enabled param separate from isUploading
- All ViewModels track isUploadingFile alongside isUploadingImage, set via hasNonMedia()
  - All screen call sites pass source-specific flags so only the initiating button spins
  - canPost() methods block posting during file uploads too
  - ChatNewMessageViewModel delegates to ChatFileUploadState (fixes pre-existing dead state)
  - cancel() methods reset both flags in all ViewModels
2026-03-14 17:59:35 +01:00
Claude
a49a0ac9ae feat: add @Preview composables to EventSyncScreen
One ThemeComparisonColumn preview per card — renders both dark and light
themes stacked — covering all independently previewable composables:

  SyncProgressCardPreview  — mid-sync progress (312/1024 relays, 4821 sent)
  PausedCardPreview        — paused state with relay/event counts
  DoneCardPreview          — completed run (18K events, 187s)
  ErrorCardPreview         — configuration error message
  ActiveRelaysCardPreview  — 5-relay FlowRow with pulsing chips
  DestinationRelaysCardPreview — all three sections (outbox/inbox/DM)
  ActivityLogCardPreview   — mixed log: found events + empty/unreachable

https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
2026-03-14 16:16:36 +00:00
Claude
6aacaa7d41 fix: remove event sync section item from AllRelayListScreen
https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
2026-03-14 15:51:41 +00:00
Crowdin Bot
0dce9542fb New Crowdin translations by GitHub Action 2026-03-14 15:41:38 +00:00
Vitor Pamplona
42574f302c updates change log 2026-03-14 11:39:02 -04:00
Vitor Pamplona
d4ae3411c7 spotless 2026-03-14 11:36:58 -04:00
Vitor Pamplona
6739e66bd3 Merge pull request #1828 from vitorpamplona/claude/implement-nip47-wallet-Fjcds
Implement NIP-47 Wallet Connect protocol with full method support
2026-03-14 11:32:51 -04:00
Claude
78c66b00eb fix: align arrow icons to 40dp to match user picture size
https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
2026-03-14 15:29:28 +00:00
Claude
b7dd460734 feat: live relay activity UI for event sync screen
ViewModel now emits a LiveSyncActivity StateFlow alongside the existing
SyncState, carrying:
  - activeRelays: which relays are currently being queried
  - recentCompletions: last 100 completed relays with event counts
  - outboxTargets / inboxTargets / dmTargets: destination relay sets

downloadFromPool gains onRelayStart/onRelayComplete(relay, eventsFound)
callbacks; downloadFromRelay returns Int (total events across all pages).
Tracking uses ConcurrentHashMap.newKeySet + synchronized ArrayDeque for
thread safety across 50 concurrent workers.

Screen adds three new live cards:
  ActiveRelaysCard  — FlowRow of chips with a shared pulsing dot
                      animation (one InfiniteTransition for all chips)
  DestinationRelaysCard — Outbox / Inbox / DMs sections, color-coded
                          using primary / secondary / tertiary
  ActivityLogCard   — fixed-height (260dp) inner-scrollable list;
                      filled dot + count for active relays, muted for
                      empty/unreachable ones; K/M formatting for counts

String resources updated: event_sync_batch_of → event_sync_relays_progress,
paused_body copy updated to relay language; 7 new strings added.

https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
2026-03-14 15:26:12 +00:00
Vitor Pamplona
03e1c32757 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 11:20:33 -04:00
Claude
84035f887a feat: parse NIP-47 transaction metadata for sender/recipient display
Add NwcTransactionMetadata parser that extracts comment, payer_data,
recipient_data, and nostr zap data from the untyped metadata field.
The transaction list UI now shows the Nostr user profile picture and
name for zap senders/recipients, falls back to payer name/email or
lightning address, and displays comment when it differs from description.

https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
2026-03-14 15:18:53 +00:00
Claude
666c795c93 fix: use correct cancellation checks in suspend functions
isActive is a CoroutineScope extension and has no CoroutineScope
receiver in plain suspend fun bodies (runSync, downloadFromRelay).

- runSync catch block: split into CancellationException (rethrow) +
  Exception, removing the invalid isActive guard entirely
- downloadFromRelay while loop: while(isActive) -> while(true) with
  coroutineContext.ensureActive() as the first statement, which works
  in any suspend fun and throws CancellationException when cancelled

The isActive import is kept for the supervisorScope lambda in
downloadFromPool, where a CoroutineScope receiver is in scope.

https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
2026-03-14 15:03:45 +00:00
Claude
2fd623a97d fix: wallet screen showing "No wallet connected" on first open
The WalletViewModel.init() was called inside LaunchedEffect (async),
but hasWalletSetup() was checked synchronously during the first
composition frame—before init had run. Moved init to run synchronously
during composition and made hasWalletSetup a reactive StateFlow so the
UI updates when wallet state changes.

https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
2026-03-14 14:30:54 +00:00
Vitor Pamplona
120981f8c4 spotless apply 2026-03-14 10:18:16 -04:00
Vitor Pamplona
493fe05ac4 Fixes missing parameter 2026-03-14 10:18:08 -04:00
Claude
5905926d4a feat: sliding window relay pool replaces fixed batch chunks
Instead of waiting for all 50 relays in a batch to finish before
starting the next 50, a Semaphore(MAX_CONCURRENT_RELAYS) keeps the
concurrency window full at all times: the moment one relay is fully
exhausted it releases the semaphore and the next relay starts.

Each relay is handled by downloadFromRelay(), which paginates
independently (until cursor) until the relay returns no events.
supervisorScope ensures a single relay failure does not cancel
the rest of the pool.

Dedup sets and event counter are now thread-safe (ConcurrentHashMap
key sets, AtomicLong) since workers fire concurrently.

SyncState.Running/Paused fields renamed:
  chunkIndex/totalChunks -> relaysCompleted/totalRelays
  nextChunkIndex         -> nextRelayIndex

https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
2026-03-14 13:44:48 +00:00
Claude
1841e47c5c feat: add high-level Nip47Client and Nip47Server APIs for NIP-47
Simplifies the NWC developer experience by providing Nip47Client (for
wallet client apps) and Nip47Server (for wallet service backends) that
handle URI parsing, signer creation, event building, filter construction,
and response decryption. Updates README with quick-start examples.

https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
2026-03-14 11:39:17 +00:00
David Kaspar
43914c0f09 Merge pull request #1841 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-14 11:19:39 +00:00
Crowdin Bot
4fc97f21c5 New Crowdin translations by GitHub Action 2026-03-14 11:10:01 +00:00
David Kaspar
dcf1e4e39b Add Claude Code local settings to .gitignore 2026-03-14 11:08:34 +00:00
David Kaspar
a6d5944bf3 Fix JSON formatting in settings.json 2026-03-14 09:01:55 +00:00
David Kaspar
f2391e698a Merge pull request #1839 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-14 05:06:48 +00:00
Crowdin Bot
f9f4e7716d New Crowdin translations by GitHub Action 2026-03-14 05:06:26 +00:00
David Kaspar
ce3f9bec5c Merge pull request #1838 from davotoula/update-translations
update cz, pt, de, sv
2026-03-14 05:04:49 +00:00
davotoula
3f1e826a10 update cz, pt, de, sv 2026-03-14 06:00:21 +01:00
Claude
580678eed8 feat: add server-side event builders and NIP-47 README for quartz
Adds missing server-side capabilities to the NIP-47 quartz module:

- LnZapPaymentResponseEvent.createResponse(): builds encrypted response
  events (kind 23195) for wallet services to reply to client requests
- NwcNotificationEvent.createNotification(): builds encrypted notification
  events (kind 23197) for wallet services to push payment notifications

Creates comprehensive README.md documenting the full NIP-47 API with
code examples for both wallet client and wallet service implementations,
covering URI parsing, request/response building, encryption, notifications,
error handling, transaction states, and caching.

https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
2026-03-14 04:25:27 +00:00
Claude
4b76c70c29 feat: add NIP-47 wallet interface with balance, send, receive, and transactions
Adds an Alby Go-inspired wallet interface accessible from the left drawer menu.
Uses existing NWC connection from zap settings to communicate with the wallet
via NIP-47 methods (get_balance, get_info, pay_invoice, make_invoice, list_transactions).

New files:
- WalletViewModel: manages wallet state and NWC requests
- WalletScreen: balance display with send/receive action buttons
- WalletSendScreen: paste BOLT-11 invoice and pay
- WalletReceiveScreen: create invoice with QR code display
- WalletTransactionsScreen: scrollable transaction history

Also adds generic sendNwcRequest() to NwcSignerState and Account for
sending arbitrary NIP-47 requests beyond just pay_invoice.

https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
2026-03-14 03:52:41 +00:00
Claude
cf922a064b feat: Strip sensitive metadata from media files before upload
Adds a MetadataStripper that removes GPS location, camera info, timestamps,
and other sensitive EXIF/metadata from images, videos, and audio files before
uploading. This protects user privacy by default.

- Creates MetadataStripper using AndroidX ExifInterface for images and
  MediaMuxer for video/audio remuxing
- Adds "Strip location metadata" toggle to all upload dialogs (NewMediaView,
  ChatFileUploadDialog) so users can disable stripping per upload
- Persists the setting in AccountSettings (stripLocationOnUpload, default true)
- Integrates stripping into UploadOrchestrator before compression
- Covers all upload paths: media posts, DM attachments, channel uploads,
  profile pictures, list/channel metadata images

https://claude.ai/code/session_01974zpAMSRD9GeqBt9ax6XD
2026-03-14 03:50:34 +00:00
Claude
0fe721f426 feat: Add markdown long-form post screen (NIP-23)
Add a new post screen specialized for writing and publishing long-form
content events (kind 30023) with markdown support. Includes:

- MarkdownPostViewModel with title, summary, cover image, and markdown
  body fields, draft support, and LongTextNoteEvent creation
- MarkdownPostScreen with Edit/Preview tabs, monospace editor, markdown
  preview using existing RenderContentAsMarkdown, and media upload that
  inserts markdown image syntax
- Navigation route (Route.NewMarkdownPost) and AppNavigation entry
- String resources for the new screen

https://claude.ai/code/session_012pBNvnWtVtQ6aKSheWJUDt
2026-03-14 03:44:46 +00:00
Claude
83a6feeca4 feat: add Alby JS SDK client interop improvements
- Add NwcBudgetRenewal constants (daily/weekly/monthly/yearly/never)
- Add case-insensitive NwcTransactionState helpers (isSettled, isPending,
  isFailed, isAccepted) for JS SDK lowercase state interop
- Add URI test using Alby JS SDK test vector (69effe7b... pubkey)
- Add JS SDK interop tests: lowercase transaction states in responses,
  notifications, and list_transactions; empty get_budget response;
  all budget renewal periods; structured transaction metadata;
  full 13-method get_info response

https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
2026-03-14 03:28:17 +00:00
Claude
4ee937ff0c test: add Alby Hub real test vectors to interop tests
Add tests using exact JSON payloads from Alby Hub's test suite:
- Real bolt11 invoice strings from Alby's mock data
- pay_keysend with TLV records and preimage
- make_invoice with nested metadata objects
- make_hold_invoice with 64-char payment hash
- settle_hold_invoice with preimage
- list_transactions with unpaid_outgoing filter
- create_connection with isolated=true

https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
2026-03-14 03:00:56 +00:00
Claude
9e4cb446ef feat: add NIP-47 get_budget, sign_message, create_connection methods and Alby Hub interop
- Add get_budget, sign_message, create_connection request/response types
- Add NwcTransactionType (incoming/outgoing) and NwcTransactionState
  (PENDING/SETTLED/FAILED/ACCEPTED) constants
- Add missing error codes: BAD_REQUEST, NOT_FOUND, EXPIRED
- Add settle_deadline field to NwcTransaction
- Add total_count to ListTransactionsResult
- Add metadata and lud16 to GetInfoResult
- Add unpaid_outgoing and unpaid_incoming to ListTransactionsParams
- Update Jackson deserializers for new method types
- Add AlbyInteropTest with 25+ tests verifying compatibility with
  Alby Hub's JSON formats for all request/response types
- Update existing tests for new constants and error codes

https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
2026-03-14 02:58:40 +00:00
Claude
50282f32f4 feat: paginate relay queries with per-relay until cursor
Relays cap event responses at ~500 per request. After each EOSE, the
oldest createdAt seen on a relay becomes the next 'until' cursor and
the relay is re-queried for the next page. This repeats until a relay
returns no events (exhausted) or cannot be reached.

All active relays in a batch are queried in parallel each round; only
the relays that still have pages remaining participate in the next round.
Dead relays (onCannotConnect) are dropped immediately.

ConcurrentHashMap is used for per-relay counters and cursors since
IRequestListener callbacks can arrive from concurrent relay threads.

https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
2026-03-14 02:48:40 +00:00
Claude
0bbbadd494 feat: chunk relays, parallel filters, and pausable sync
- Process relays in batches of 50 instead of one at a time; each batch
  opens a single subscription covering all relays simultaneously, so
  5 000 relays become 100 batches rather than 5 000 sequential round-trips.

- Collapse the three sequential phases into one combined query per batch:
  send both the 'authored by me' filter and the 'p-tagged me' filter in
  the same subscription, then route events to outbox/inbox/DM relays by
  their properties.  This halves the total number of relay connections.

- Make the procedure pausable: cancel() now transitions to SyncState.Paused
  (carrying the next chunk index and events-sent count) instead of Idle.
  The screen shows a Resume button that continues from the saved checkpoint
  and a Start Over button to restart from scratch.

- The mobile-data dialog correctly routes to resume() vs start() depending
  on whether a checkpoint exists.

https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
2026-03-14 02:40:59 +00:00
Vitor Pamplona
da38555035 Merge pull request #1835 from vitorpamplona/claude/add-swipe-delete-cancel-QjI5b
Add cancel button to swipe-to-delete confirmation UI
2026-03-13 20:42:43 -04:00
Claude
bcb5be76c7 feat: Add cancel button to swipe-to-delete confirmation
After swiping a draft to delete, the red confirmation background now
shows both a "Delete" button (left) and a "Cancel" button (right).
Tapping Cancel resets the swipe state, allowing users to dismiss
the delete action without deleting the draft.

https://claude.ai/code/session_01ULVR1fKwka5TgfgSucPE4r
2026-03-13 23:19:47 +00:00
Vitor Pamplona
64118058f2 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  feat: replace dialog with swipe-to-reveal delete button for drafts
  feat: add confirmation dialog when swiping to delete a draft
2026-03-13 19:11:25 -04:00
Vitor Pamplona
474a875167 Fixes padding of the expiration date on NoteCompose 2026-03-13 19:11:10 -04:00
Claude
9d49b2da4f feat: confirm dialog before starting sync on mobile data
Replace the disabled-Start + secondary-Start-Anyway button pattern
with a single always-enabled Start button that shows an AlertDialog
when the connection is metered/mobile, requiring explicit confirmation
before running a potentially gigabyte-scale operation.

https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
2026-03-13 22:49:40 +00:00
Vitor Pamplona
ae0ab0c153 Merge pull request #1826 from vitorpamplona/claude/draft-delete-confirmation-y28xT
Add confirmation dialog to draft deletion via swipe gesture
2026-03-13 18:48:05 -04:00
Claude
90d7b0c678 feat: run EventSync in background by scoping it to AccountViewModel
Move EventSyncViewModel from a screen-scoped ViewModel to a plain class
held by AccountViewModel (activity-scoped). The sync job now runs on
AccountViewModel's viewModelScope, so it continues when the user
navigates away. The back button no longer cancels the sync.

https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
2026-03-13 22:42:09 +00:00
Claude
441dae8e25 feat: move EventSync to left drawer, remove button from AllRelayListScreen
https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
2026-03-13 22:37:16 +00:00
Vitor Pamplona
1b5d7fdc13 Merge pull request #1834 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-13 18:14:40 -04:00
Claude
b3adc56adf feat: replace dialog with swipe-to-reveal delete button for drafts
Instead of showing a confirmation dialog, the swipe now stays open
revealing a red background with a clickable "Request Deletion" button.
Supports swiping from both left-to-right and right-to-left directions.
The user taps the revealed button to confirm deletion.

https://claude.ai/code/session_01JWJxejv2EKrPj2KGEfPPxU
2026-03-13 22:11:54 +00:00
Crowdin Bot
bb88228019 New Crowdin translations by GitHub Action 2026-03-13 22:10:50 +00:00
Vitor Pamplona
1468a41737 Adds expirations to change log 2026-03-13 18:07:33 -04:00
Vitor Pamplona
c4c73a2bb7 Adds expirations in all new post screens.
Adds expirations in the feed and chats.
2026-03-13 18:05:59 -04:00
Vitor Pamplona
42fdedf23b fixes spotless 2026-03-13 17:03:24 -04:00
Vitor Pamplona
9cd779b60c Merge pull request #1817 from vitorpamplona/claude/add-post-expiration-date-Nmedg
Add NIP-40 expiration date support to note creation
2026-03-13 17:01:27 -04:00
Vitor Pamplona
d81609165e Small refactoring of the relay list screen 2026-03-13 16:28:50 -04:00
Vitor Pamplona
d281c2366c Removes the need to pass context down to a composable 2026-03-13 16:04:46 -04:00
Vitor Pamplona
4a5ffb3169 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  feat: add ZIP/JSON relay export and format picker dropdown
  refactor: extract relay export logic into RelayExporter class
  feat: add export button to relay settings screen
2026-03-13 15:31:22 -04:00
Vitor Pamplona
e51967923b adds today's work to change log 2026-03-13 15:20:11 -04:00
Vitor Pamplona
8339413262 Fixes copilot recommendation 2026-03-13 15:20:01 -04:00
Vitor Pamplona
b3bfa8950d Merge pull request #1823 from vitorpamplona/claude/export-relay-settings-v8uQg
Add relay settings export functionality
2026-03-13 15:09:25 -04:00
Vitor Pamplona
9b58a1be11 Fixing IO Dispatchers and scopes of choice. 2026-03-13 15:06:03 -04:00
Claude
0ad63bbfc1 feat: add ZIP/JSON relay export and format picker dropdown
- Create RelayListCollection to bundle all relay lists with section metadata
- Create RelayZipExporter that exports each relay category as a separate
  JSON file (array of relay URL strings) inside a zip archive, shared
  via FileProvider
- Refactor RelayExporter to use RelayListCollection
- Replace single export button with a dropdown menu letting users pick
  between text export and ZIP (JSON) export

https://claude.ai/code/session_013PhuahpBFh6djVHzSsmNKM
2026-03-13 18:24:05 +00:00
Vitor Pamplona
f3eb609eee Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-03-13 13:45:44 -04:00
Vitor Pamplona
00f2d46b53 Refines user status flow and isExpired running 2026-03-13 13:45:22 -04:00
David Kaspar
b9f6017590 Merge pull request #1832 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-13 17:41:23 +00:00
Crowdin Bot
9ec8dc167c New Crowdin translations by GitHub Action 2026-03-13 17:36:41 +00:00
Vitor Pamplona
99eaa37d4c Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  feat: complete NIP-38 implementation with p tag, emoji, and expiration support
2026-03-13 13:33:11 -04:00
Vitor Pamplona
6f57652455 Merge pull request #1827 from vitorpamplona/claude/implement-nip-38-q8DOF
Enhance user status display with emoji support and metadata tags
2026-03-13 13:32:41 -04:00
Vitor Pamplona
1966cc2034 Bitcoin first then namecoin 2026-03-13 13:29:55 -04:00
Claude
e83ec94461 refactor: extract relay export logic into RelayExporter class
Moves the text formatting and share intent logic from AllRelayListScreen
into a dedicated RelayExporter class in the common package.

https://claude.ai/code/session_013PhuahpBFh6djVHzSsmNKM
2026-03-13 17:28:39 +00:00
Vitor Pamplona
8bc14b52b2 Spotless update 2026-03-13 13:25:23 -04:00
Vitor Pamplona
34f3692d9a Merge pull request #1815 from vitorpamplona/claude/add-explorer-settings-page-zXosk
feat: add blockchain explorer settings page for OTS verification
2026-03-13 13:22:57 -04:00
Vitor Pamplona
f4d6e3cb3e Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-13 13:22:44 -04:00
Vitor Pamplona
bcdc8386a0 Merge pull request #1830 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-13 13:18:50 -04:00
Crowdin Bot
178e47ebf5 New Crowdin translations by GitHub Action 2026-03-13 13:28:43 +00:00
Vitor Pamplona
4bf7f982ba Makes the new translation settings work 2026-03-13 09:24:12 -04:00
Vitor Pamplona
3e0b8a5f8f Fixes gradle build on MacOS and adds a Clibsodium.def file 2026-03-13 08:40:51 -04:00
Vitor Pamplona
c778c9f109 Adds a hook to get claude web to compile and check android code 2026-03-13 08:40:28 -04:00
Vitor Pamplona
ae41a956ac Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-03-13 08:22:28 -04:00
Vitor Pamplona
e8a031251a Merge pull request #1829 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-13 08:22:12 -04:00
Vitor Pamplona
9ca79acbe4 lint 2026-03-13 08:21:55 -04:00
Crowdin Bot
c0bd88278c New Crowdin translations by GitHub Action 2026-03-13 12:20:55 +00:00
Vitor Pamplona
c33ba239d0 Merge pull request #1824 from vitorpamplona/claude/improve-translation-settings-dDwmL
Redesign language settings UI with searchable language pickers
2026-03-13 08:16:58 -04:00
Vitor Pamplona
3a6d199962 Merge pull request #1821 from KotlinGeekDev/kmp-completeness
Kmp completeness, Part 2 - Libsodium
2026-03-13 08:01:33 -04:00
KotlinGeekDev
10f5799014 Merge branch 'vitorpamplona:main' into kmp-completeness 2026-03-13 09:20:58 +01:00
Claude
8c73dd8f14 test: add comprehensive test suite for NIP-47 Wallet Connect implementation
Covers all 10 request methods (create, serialize, deserialize), all success
and error response types, notification deserialization, EncryptionTag and
NotificationsTag parse/assemble/isTag, NwcInfoEvent build and capabilities,
NwcNotificationEvent structure, LnZapPaymentRequestEvent create/decrypt
with NIP-04 and NIP-44, and Nip47WalletConnect URI parsing and serialization.

https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
2026-03-13 03:45:50 +00:00
Claude
da26ab14ab feat: complete NIP-47 Nostr Wallet Connect implementation in Quartz
Implements the full NIP-47 specification with all request methods,
response types, notification events, and JSON serialization.

New request methods:
- pay_invoice (updated with amount, metadata params)
- pay_keysend (amount, pubkey, preimage, TLV records)
- make_invoice, lookup_invoice, list_transactions
- get_balance, get_info
- make_hold_invoice, cancel_hold_invoice, settle_hold_invoice

New response types:
- Success responses for all methods above
- NwcErrorResponse (generic error for any method)
- NwcErrorCode enum with UNSUPPORTED_ENCRYPTION

New event kinds:
- NwcInfoEvent (kind 13194) - wallet service capabilities
- NwcNotificationEvent (kind 23197) - NIP-44 encrypted notifications
- Legacy kind 23196 support for NIP-04 notifications

New data models:
- NwcTransaction (shared invoice/payment object)
- TlvRecord (for keysend TLV records)
- Notification types: payment_received, payment_sent, hold_invoice_accepted
- HoldInvoiceAcceptedData with settle_deadline

Tags (following NIP-88 pattern):
- EncryptionTag for encryption scheme negotiation
- NotificationsTag for supported notification types

Updated:
- LnZapPaymentRequestEvent: generic createRequest() with NIP-44 support
- URI parser: lud16 parameter support
- Jackson deserializers: all methods routed correctly
- EventFactory: new kinds registered

https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
2026-03-13 03:15:21 +00:00
Claude
82e500eee1 feat: complete NIP-38 implementation with p tag, emoji, and expiration support
- Add optional r/p/e/a/emoji tags to StatusEvent.create() for full spec compliance
- Add GENERAL and MUSIC type constants to StatusEvent
- Filter expired statuses in UserStatusCache.addStatus() and add removeExpired()
- Add p tag (profile reference) display in DisplayStatusInner
- Add NIP-30 custom emoji rendering in status display via CreateTextWithEmoji
- Allow status type parameter in UserStatusAction.create()

https://claude.ai/code/session_0117asp4mDiR65dxbAGKi2hg
2026-03-13 03:06:22 +00:00
Claude
48485ce933 feat: add confirmation dialog when swiping to delete a draft
The swipe-to-delete gesture on the drafts screen was too sensitive,
causing accidental deletions. Added a confirmation dialog that appears
after the swipe gesture completes, requiring the user to confirm before
the draft is actually deleted.

https://claude.ai/code/session_01JWJxejv2EKrPj2KGEfPPxU
2026-03-13 02:49:07 +00:00
Claude
0da3e562fb style: spotless apply on EventSync files
https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
2026-03-13 02:40:08 +00:00
Claude
997a270a84 feat: add export button to relay settings screen
Adds a share icon button in the top bar of the relay settings screen
that exports all relay configurations (except Connected Relays) as
human-readable text via Android's share intent. Each section includes
a header and description, with one relay URL per line.

https://claude.ai/code/session_013PhuahpBFh6djVHzSsmNKM
2026-03-13 02:30:33 +00:00
Claude
7746561360 feat: refresh translation settings UI with modern components
- Translate To: shows all available languages with searchable list instead
  of only device locales
- Don't Translate From: uses InputChip FlowRow with an Add button and
  searchable language picker to add new languages
- Language Preferences: displays as OutlinedCards with radio buttons for
  choosing display language, includes delete and add-new-pair functionality
  with guided source/target selection
- Added new string resources for the refreshed UI

https://claude.ai/code/session_011iTJ1gg8weaxaALiUQNH4C
2026-03-13 02:29:35 +00:00
Claude
83e79cf857 feat: add EventSync screen for relay event synchronization
- Add EventSyncScreen composable with UI for event sync configuration
- Add EventSyncViewModel for managing sync state and business logic
- Add route and navigation entry for EventSync screen
- Add entry point in AllRelayListScreen
- Add string resources for EventSync UI

https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
2026-03-13 02:15:16 +00:00
Vitor Pamplona
198aa0971c adds ./gradlew spotlessApply request to claude
improves description of the .md file
2026-03-12 22:13:33 -04:00
KotlinGeekDev
5cdf8bdd86 Merge branch 'vitorpamplona:main' into kmp-completeness 2026-03-13 01:12:49 +01:00
Vitor Pamplona
d9eb44df1f Improving the spacing in the new translation settings screen 2026-03-12 19:52:36 -04:00
Vitor Pamplona
0d9f390cd9 missing tests fix 2026-03-12 19:44:51 -04:00
Vitor Pamplona
e6eb421e79 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  New Crowdin translations by GitHub Action
  feat: add missing translation settings to Translation settings menu
2026-03-12 19:43:11 -04:00
Vitor Pamplona
21e29bbb3c Fixes test cases. 2026-03-12 19:42:46 -04:00
Vitor Pamplona
deeac97082 Merge pull request #1814 from vitorpamplona/claude/add-translation-settings-MIoUT
feat: add missing translation settings to Translation settings menu
2026-03-12 19:41:40 -04:00
Vitor Pamplona
ebbd7e1a77 adds today's work to the change log 2026-03-12 19:06:17 -04:00
Vitor Pamplona
fa67030b47 Adds support for BUD-10 Blossom URIs 2026-03-12 18:57:46 -04:00
Vitor Pamplona
d4dd5a65e6 anySync was returning either on true or false. it was not doing what it was supposed to do which is returning after the first true. 2026-03-12 18:56:17 -04:00
KotlinGeekDev
aa172dbfbc Merge branch 'vitorpamplona:main' into kmp-completeness 2026-03-12 22:17:46 +01:00
KotlinGeekDev
3838b6b815 Some fixes. 2026-03-12 22:16:34 +01:00
KotlinGeekDev
42f48e11fc Make CI happy(attempt 2). 2026-03-12 21:49:08 +01:00
David Kaspar
4b304ce8d5 Merge pull request #1822 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-12 20:46:11 +00:00
Crowdin Bot
bfd595ea00 New Crowdin translations by GitHub Action 2026-03-12 20:45:10 +00:00
David Kaspar
c477745fbd Merge pull request #1820 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-12 20:43:26 +00:00
KotlinGeekDev
a463a9802f Slightly modify defFile generation, to stop CI cries. 2026-03-12 21:36:25 +01:00
KotlinGeekDev
750bedbabd Foundations(iOS Sourceset): Reduce the header files only to those needed, plus utilities. 2026-03-12 21:06:02 +01:00
KotlinGeekDev
0b928d29a6 Make the libsodium cinterop file generation more reliable. Add in(and comment out) some test code for extracting the libsodium headers from zip file. 2026-03-12 20:28:02 +01:00
KotlinGeekDev
527130a53c Merge remote-tracking branch 'origin/kmp-completeness' into kmp-completeness 2026-03-12 20:22:55 +01:00
KotlinGeekDev
d957efb1b8 Foundations(iOS Sourceset): Streamline libsodium bindings configuration. 2026-03-12 16:55:19 +01:00
Crowdin Bot
f31e73f182 New Crowdin translations by GitHub Action 2026-03-12 14:53:15 +00:00
Vitor Pamplona
ad17924622 Making sure UrlDetector returns a schema even if the parser points to some user:password scheme. 2026-03-12 10:48:11 -04:00
Vitor Pamplona
3eb2fcbcb8 Solves the sorting contract crash by precaching all values before sorting. 2026-03-12 10:45:54 -04:00
KotlinGeekDev
2b61f4a3d6 Function rename for digest provider. 2026-03-12 15:33:07 +01:00
KotlinGeekDev
759b0c8b96 Foundations(iOS Sourceset): Use alternative implementation for MacInstance, due to complications with mac length under previous implementation, which makes NIP49 tests(with all other relevant tests) pass. Some spotless fixes. 2026-03-12 15:31:33 +01:00
KotlinGeekDev
8f02ea0cf6 Foundations(iOS Sourceset): Provide implementation for LibSodiumInstance.ios.kt, using the native library(shipped in project). Generate cinterop definition file with custom Gradle task. Defines bindings for use in actual code(may be streamlined later). Adds NIP44 tests and makes sure they pass. 2026-03-12 15:13:32 +01:00
nrobi144
327bb1efc8 fix(nav): preserve screen state across overlay navigation
RootContent now stays composed when overlays are shown, preventing
search results and other state from being destroyed on navigation.
Overlay wrapped in opaque Surface to cover root content visually.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:32:05 +02:00
nrobi144
5a437ed5ac feat(search): collapsible section headers + sorting + search improvements
- Collapsible sticky section headers with animated chevron
- Full header row clickable to toggle collapse
- SearchResultSorter, SearchSortOrder, pseudo-kind filtering
- TextFieldValue cursor preservation, relay timeout improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:23:27 +02:00
nrobi144
7180edf162 Merge upstream/main into feat/desktop-advanced-search
Resolve conflicts:
- RelayStatus.kt: keep both relay URLs
- SearchScreen.kt: keep advanced search implementation
- SinglePaneLayout.kt: add missing Spacer import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:22:15 +02:00
Claude
e07d42e3da feat: add NIP-40 expiration date to short note post screen
Allows users to add an expiration date to posts via NIP-40. The feature
follows the same pattern as content warning (toggle button in the bottom
action bar, expandable section in the post body) and uses the same
calendar/time picker UI as poll deadlines.

- ExpirationDateButton: timer icon toggles orange when active
- ExpirationDatePicker: date+time picker with "Expires in X" display
- ViewModel: wantsExpirationDate + expirationDate state, wired into
  both TextNoteEvent and PollEvent builders, loaded from drafts
- Strings: add_expiration_date, expiration_date_label, etc.

https://claude.ai/code/session_016QjmArr6zfM1Qx58iNrntA
2026-03-12 10:38:23 +00:00
Claude
117c903e7c feat: add missing translation settings to Translation settings menu
Expose the translateTo and languagePreferences fields from
AccountLanguagePreferences in the Translation settings screen
(UserSettingsScreen), which previously only showed the
dontTranslateFrom setting.

New settings added:
- Translate To: dropdown to select the target translation language
  from the device's configured locales, with a checkmark on the
  currently selected language.
- Language Display Preferences: for each saved language-pair
  preference (source → target), a dropdown to choose which language
  to show first; only visible when at least one preference exists.

https://claude.ai/code/session_01BheCJbDDZDAAYo6GAJBtFf
2026-03-12 09:46:07 +00:00
Claude
3e16d317fc feat: add blockchain explorer settings page for OTS verification
Adds a new "Bitcoin Explorer (OTS)" settings page in App Settings,
modeled after the existing Namecoin settings page.

Users can configure a custom Mempool-compatible blockchain explorer
API URL for OpenTimestamps proof verification. When a custom URL is
set it takes priority over the automatic Tor-aware selection (mempool.space
when Tor is active, blockstream.info otherwise).

Changes:
- OtsSettings: immutable data class for explorer config
- OtsSharedPreferences: DataStore-backed persistence (global/app-level)
- TorAwareOkHttpOtsResolverBuilder: accepts customExplorerUrl lambda
- OtsSettingsSection: UI section with active explorer display, URL input,
  known-explorers reference list, and reset button
- OtsSettingsScreen: full screen wrapping the section
- Routes.OtsSettings: new navigation route
- AllSettingsScreen: new entry under App Settings
- AppModules: wires otsPrefs and passes custom URL to resolver builder

https://claude.ai/code/session_01GX7k36uepGxU3U1fBKYFrx
2026-03-12 09:35:16 +00:00
Vitor Pamplona
a94d5bfd07 Fixes BUD 10 tests 2026-03-11 15:29:16 -04:00
Vitor Pamplona
548ce0a69d spotless apply 2026-03-11 15:24:02 -04:00
Vitor Pamplona
3712448c3f Merge pull request #1812 from vitorpamplona/claude/kotlin-blossom-uri-parser-lrQXn
Add BUD-10 Blossom URI parser and serializer
2026-03-11 15:19:22 -04:00
David Kaspar
853cdddc99 Merge pull request #1811 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-11 19:19:15 +00:00
Claude
ac69a0fa74 feat(nipB7Blossom): add BUD-10 Blossom URI parser
Implements a pure-Kotlin (commonMain) parser for the BUD-10 blossom: URI
scheme, which allows sharing blob references with discovery hints.

Format: blossom:<sha256>.<ext>[?xs=<server>&as=<pubkey>&sz=<bytes>]

- BlossomUri data class holds sha256, extension, servers (xs), authors (as), size (sz)
- BlossomUri.parse() returns null for non-blossom URIs or invalid SHA-256
- Supports repeated xs/as params (multiple servers and authors)
- Percent-encodes server URLs on serialisation; decodes on parse
- BlossomUri.toUriString() round-trips back to a canonical URI
- Includes commonTest coverage for all parsing cases

https://claude.ai/code/session_01T4jyLbkNWd3f7i6MwsBcD4
2026-03-11 19:16:45 +00:00
Crowdin Bot
6aef5df6c8 New Crowdin translations by GitHub Action 2026-03-11 19:11:37 +00:00
David Kaspar
3bfd3f4314 Merge pull request #1810 from davotoula/update-translations
update cz, pt, de, sv
2026-03-11 19:09:52 +00:00
David Kaspar
61421f1393 Merge pull request #1809 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-11 19:05:03 +00:00
davotoula
d7f9d584d6 update cz, pt, de, sv 2026-03-11 20:04:05 +01:00
Crowdin Bot
a81b98a63a New Crowdin translations by GitHub Action 2026-03-11 19:03:42 +00:00
Vitor Pamplona
0c040d8816 addresses long domain names for blossom and nostr profiles 2026-03-11 14:59:52 -04:00
Vitor Pamplona
6a2cbb3a3a Using a real hex key in preview 2026-03-11 14:59:22 -04:00
Vitor Pamplona
1385feb066 fixes non-lowercase schemas on posts 2026-03-11 14:38:19 -04:00
Vitor Pamplona
d521e29824 Shows all users cited in subscriptions for each relay. 2026-03-11 14:27:51 -04:00
Vitor Pamplona
1c044c9072 If warning is blank, revert to defaults 2026-03-11 14:12:02 -04:00
Vitor Pamplona
ffe5e14b2b Display content warning reason 2026-03-11 14:10:55 -04:00
Vitor Pamplona
a4010189cd Adds content warning reason to all new post screens 2026-03-11 13:52:18 -04:00
Vitor Pamplona
7ce13b5ba5 Adds content sensitivity descriptors to all screens 2026-03-11 13:47:10 -04:00
Vitor Pamplona
9bb87b38f5 Merge pull request #1808 from vitorpamplona/claude/add-content-warning-description-5BF4B
Add optional description field for sensitive content warnings
2026-03-11 13:43:47 -04:00
Vitor Pamplona
eedfc61fad Fixes lingering relays on loading follows outbox relays. 2026-03-11 13:17:06 -04:00
Claude
2b0518a675 feat: allow users to add a description to the sensitive content warning
- Add contentWarningReason() helper to TagArrayExt and EventExt for reading the reason from existing events
- Add contentWarningDescription state to ShortNotePostViewModel, wired into event building and draft load/save/reset
- Update ContentSensitivityExplainer to accept description state and render an OutlinedTextField for user input
- Pass description state from ShortNotePostScreen to ContentSensitivityExplainer
- Add "Reason (optional)" placeholder string resource

https://claude.ai/code/session_01P2nkYdNiWXiAcfMPTRTVEW
2026-03-11 16:51:37 +00:00
Vitor Pamplona
84da81b62f Adds calendar events and snippets to the search query 2026-03-11 12:44:29 -04:00
Vitor Pamplona
1bd96707e5 Improving the looks of namecoin settings 2026-03-11 12:33:52 -04:00
Vitor Pamplona
3bd2f4251b Increasing sha256 pool size 2026-03-11 12:33:11 -04:00
Vitor Pamplona
2312bea10d Merge pull request #1807 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-11 12:30:59 -04:00
Vitor Pamplona
5437ee98b4 updates readme 2026-03-11 12:29:00 -04:00
Crowdin Bot
b6829523a7 New Crowdin translations by GitHub Action 2026-03-11 16:28:06 +00:00
Vitor Pamplona
bba11b1813 Merge pull request #1805 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-11 12:26:36 -04:00
Vitor Pamplona
2649d05e25 merge with spotless apply 2026-03-11 12:24:25 -04:00
Crowdin Bot
60db83ecad New Crowdin translations by GitHub Action 2026-03-11 16:24:10 +00:00
Vitor Pamplona
0f934ee461 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  feat: add CodeSnippetEvent (NIP-C0) rendering to NoteCompose and ThreadFeedView
2026-03-11 12:22:44 -04:00
Vitor Pamplona
8d968c2fc0 Merge pull request #1806 from vitorpamplona/claude/namecoin-settings-route-qFW0S
Extract Namecoin settings to dedicated screen
2026-03-11 12:21:57 -04:00
Vitor Pamplona
c1705507a8 Adds 1.06 change log 2026-03-11 12:21:03 -04:00
Claude
3b3482e753 feat: move NamecoinSettingsSection to its own route and screen
Extract the Namecoin settings from PrivacyOptionsScreen into a
dedicated NamecoinSettingsScreen with its own Route.NamecoinSettings
route. Add a "Namecoin Settings" entry to AllSettingsScreen so users
can navigate to it directly from the settings list.

https://claude.ai/code/session_013gEw6fJYFiFoe4zETo8dpC
2026-03-11 16:19:07 +00:00
Vitor Pamplona
dccc644a9e Merge pull request #1804 from vitorpamplona/claude/add-code-snippet-rendering-0G1Mv
Add support for rendering NIP-23 Code Snippet events
2026-03-11 12:12:21 -04:00
Claude
74e44f1256 feat: add CodeSnippetEvent (NIP-C0) rendering to NoteCompose and ThreadFeedView
- Create CodeSnippet.kt with RenderCodeSnippetEvent (feed card view) and
  RenderCodeSnippetHeaderForThread (full thread view)
- Feed card shows: filename/language badge, description, 5-line code preview
  in a monospace box with rounded border
- Thread header shows: full code in scrollable monospace block plus metadata
  (license, runtime, deps)
- Add is CodeSnippetEvent branch to NoteCompose.kt RenderNoteRow when-statement
- Add is CodeSnippetEvent branch to ThreadFeedView.kt header when-statement

https://claude.ai/code/session_01X6jjsMLGBUmcaWXHJKmrEn
2026-03-11 15:46:18 +00:00
Vitor Pamplona
76090077b6 Displays % of uptime in relay settings 2026-03-11 10:59:43 -04:00
Vitor Pamplona
76e86ee5f7 adds calendar rendering to the thread view 2026-03-11 10:59:21 -04:00
Vitor Pamplona
3fdde60a37 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  fix: resolve conflicts with upstream PR #1789 merge
  fix(desktop): remove noisy debug logs and silence SLF4J warnings
  feat(desktop): add bunker heartbeat indicator to sidebar navigation
  fix(desktop): verify user pubkey via get_public_key after nostrconnect login
  fix(desktop): address code review findings for NIP-46 bunker login
  feat(desktop): add nostrconnect:// login flow for QR-based signer pairing
  revert: remove QR code scanning from login
  feat(desktop): webcam QR scanning via ffmpeg for bunker login
  feat(desktop): add webcam QR code scanning for bunker login
  feat(desktop): add QR code scanning for bunker login
  fix(desktop): clear stored credentials on logout
  feat(desktop): update login hints to mention bunker:// option
  fix(desktop): simplify settings logout to just a button
  feat(desktop): add logout option to menu bar and settings
  test: add NIP-46 test suite for desktop, quartz, and fix pre-existing chess test errors
  feat(desktop): add NIP-46 remote signer (bunker://) login
2026-03-11 08:42:44 -04:00
Vitor Pamplona
71574a5ce0 tracks and displays connection tentatives on relay settings 2026-03-11 08:42:32 -04:00
Vitor Pamplona
ca9074e6f5 better relay parsing messaging 2026-03-11 08:17:14 -04:00
Vitor Pamplona
4576204c1d Fixes relay list backup 2026-03-11 08:10:50 -04:00
Vitor Pamplona
190bdb0795 Merge pull request #1803 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-11 07:51:20 -04:00
Crowdin Bot
c67781f87d New Crowdin translations by GitHub Action 2026-03-11 11:49:38 +00:00
Vitor Pamplona
d01b5aba4e Merge pull request #1791 from nrobi144/feat/nip46-bunker-login
feat(desktop): NIP-46 bunker login with heartbeat indicator
2026-03-11 07:47:38 -04:00
nrobi144
f0ffe3cbe3 fix(ui): remove duplicate back button from SinglePaneLayout
Overlay screens (UserProfileScreen, ThreadScreen) already render their
own back+title header. Remove the redundant SinglePaneHeader bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:36:46 +02:00
nrobi144
3c6c368582 refactor(search): extract thread-safe EventDeduplicator, unify dedup
Replace raw MutableSet with synchronized EventDeduplicator class.
Remove dual dedup in addNoteResults — trackRelayEvent now gates all
event processing. SearchScreen callbacks skip dupes early via return
value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:36:39 +02:00
nrobi144
edf764709d fix: resolve conflicts with upstream PR #1789 merge
- Add missing commons domain files (BunkerLoginUseCase, NostrConnectLoginUseCase)
- Restore CoroutineScope in NostrSignerRemote for suspend decrypt call
- Update isolation test: upstream removed `since` filter (PR #1789)
- Add new INostrClient interface methods to TrackingNostrClient mock

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:35:56 +02:00
nrobi144
36efbd1544 fix(desktop): remove noisy debug logs and silence SLF4J warnings
Remove feedSub/contactList debug prints from FeedScreen, suppress
GiftWrapEvent decryption failure log (expected for others' wraps),
and add slf4j-nop to silence "No SLF4J providers" console warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:20:19 +02:00
nrobi144
4d1d92db39 feat(desktop): add bunker heartbeat indicator to sidebar navigation
Pulsating green heart icon shows NIP-46 signer connection status in both
DeckSidebar and SinglePaneLayout NavigationRail. Tooltip displays last
ping time. SignerConnectionState moved to commons for KMP sharing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:20:18 +02:00
nrobi144
5475d6c2bc fix(desktop): verify user pubkey via get_public_key after nostrconnect login
The nostrconnect flow was trusting params[0] from the signer's connect
message as the user's pubkey. Some signers (e.g. nsec.app) don't put
the actual user pubkey there, causing all relay subscriptions to query
the wrong identity — contact lists, profiles, and DMs all returned empty.

Now calls remoteSigner.getPublicKey() after the handshake to get the
verified pubkey from the signer. Also stabilizes FeedScreen relay
subscriptions with distinctUntilChanged() and adds relay.primal.net
to defaults.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:20:18 +02:00
nrobi144
3a1e38a2f2 fix(desktop): address code review findings for NIP-46 bunker login
- Add bounds check in fromBunkerUri for missing query params (P2-1)
- Preserve bunker keys on transient force-logout (P2-3)
- Replace debug printlns with DebugConfig.log() (P2-4)
- Replace unused Unstable with Disconnected state (P2-5)
- Move validateBunkerUri to account package (P3-9)
- Fix Compose state update from IO thread (P3-11)
- Add disconnectNip46Client() to onDispose cleanup (P3-12)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:16:10 +02:00
nrobi144
12c91aae3d feat(desktop): add nostrconnect:// login flow for QR-based signer pairing
Adds client-initiated NIP-46 (nostrconnect://) flow so users can pair
with a remote signer (e.g. Amber) by scanning a QR code instead of
manually copying a bunker:// URI.

Changes:
- Fix RemoteSignerManager to decrypt NIP-44 content before parsing
- Add CoroutineScope to NostrSignerRemote for async event callbacks
- Add loginWithNostrConnect() to AccountManager with 120s timeout
- Add QrCodeCanvas composable using ZXing for QR generation
- Add "Connect" tab to LoginCard showing QR + copyable URI + waiting state
- Wire nostrconnect flow through LoginScreen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:16:10 +02:00
nrobi144
bff6723ba6 revert: remove QR code scanning from login
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:16:10 +02:00
nrobi144
d030843522 feat(desktop): webcam QR scanning via ffmpeg for bunker login
Replaces webcam-capture (broken on Apple Silicon) with ffmpeg
avfoundation capture. Opens a live preview dialog that scans
for QR codes from the FaceTime camera. Works natively on arm64.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:16:10 +02:00
nrobi144
6e04482981 feat(desktop): add webcam QR code scanning for bunker login
Uses webcam-capture + ZXing to scan QR codes from the camera.
Click the QR icon on the login screen to open a webcam scanner
dialog that auto-detects bunker:// URIs from Amber's QR display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:16:10 +02:00
nrobi144
fd1b435020 feat(desktop): add QR code scanning for bunker login
Adds a QR scan button to the login card that decodes QR codes from
clipboard images or image files using ZXing. Useful for scanning
bunker:// URIs from Amber's QR display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:16:09 +02:00
nrobi144
5661c6df27 fix(desktop): clear stored credentials on logout
All user-initiated logouts now pass deleteKey=true to remove
nsec from secure storage, clear last_npub, and delete bunker state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:16:09 +02:00
nrobi144
eafedda99e feat(desktop): update login hints to mention bunker:// option
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:16:09 +02:00
nrobi144
26ea0be4c7 fix(desktop): simplify settings logout to just a button
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:16:09 +02:00
nrobi144
2346d92f16 feat(desktop): add logout option to menu bar and settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:16:09 +02:00
nrobi144
1821b9ff71 test: add NIP-46 test suite for desktop, quartz, and fix pre-existing chess test errors
Add comprehensive test coverage for NIP-46 bunker login across quartz and desktopApp:

Quartz (4 files, 48 tests):
- ResponseParserTest: all 7 response parsers (success/error/unexpected)
- FromBunkerUriTest: URI parsing, validation, edge cases
- ConvertExceptionsTest: SignerResult→Exception mapping
- NostrConnectEventTest: canDecrypt, talkingWith, verifiedRecipientPubKey

Desktop (6 files, 45 tests):
- BunkerUriUtilsTest: validateBunkerUri + stripBunkerSecret
- AccountManagerKeyLoginTest: nsec/npub/invalid login, save, generate
- AccountManagerLogoutTest: logout, forceLogout, state transitions
- AccountManagerLoadAccountTest: internal/bunker/missing-key scenarios
- AccountManagerBunkerLoginTest: hasBunkerAccount, setConnectingRelays
- AccountManagerHeartbeatTest: start/stop, no-crash with internal signer

Production changes:
- AccountManager: constructor private→internal, add homeDir param for test injection, extract stripBunkerSecret to internal top-level, constants internal
- desktopApp/build.gradle.kts: add mockk test dependency

Fix pre-existing chess test compilation errors:
- ChessStateReconstructorTest: add missing jester subpackage imports
- ChessGameEventTest: altText()→alt() + add nip31Alts import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:16:09 +02:00
nrobi144
6c24c52104 feat(desktop): add NIP-46 remote signer (bunker://) login
Support bunker:// URI login for desktop, enabling private key delegation
to remote signers (nsec.app, Amber). Includes heartbeat monitoring,
force-logout on revocation, and ConnectingRelays startup state.

- AccountManager: bunker login/save/load, heartbeat ping, force logout
- LoginCard: auto-detect bunker:// URI, validation, connecting state
- LoginScreen: wire bunker callback, ConnectingRelays screen
- Main.kt: relay timeout, error recovery, scope cleanup, data objects
- ForceLogoutDialog: alert on signer revocation/disconnect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:15:24 +02:00
nrobi144
979d07679c fix(search): loading indicator, result delivery, and history race
- Replace boolean isSearching with counter-based activeSubscriptionCount
  so loading state stays true until both subs (people + notes) complete
- Add indeterminate LinearProgressIndicator overlaid on search bar bottom
  border with matching 12dp rounding, replacing "Searching relays..." text
- Send search REQs to all configured relays (relayStatuses) instead of
  only connected ones — fixes NIP-50 relays never receiving search queries
- Move clearResults/startSearching out of remember() into LaunchedEffect
  to avoid side effects during composition
- Fix history saving partial queries by replacing LaunchedEffect with
  snapshotFlow to properly observe isSearching transitions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:29:27 +02:00
nrobi144
a702dcc0ee fix(search): cleanup review findings — dead code, FQN refs, naming
- Remove unused `key` param from ExpandableSection
- Replace FQN references with imports in AdvancedSearchPanel and SearchScreen
- Use MetadataEvent.KIND instead of magic `0`
- Remove dead `relayStatuses` collection (only `connectedRelays` used)
- Remove CachedUserResult sealed variant (never constructed)
- Rename SearchHint `identifier` param to `example`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 07:28:04 +02:00
nrobi144
e8e2650fa9 refactor(search): extract shared code to commons, remove dead code
- Extract DateUtils (isLeapYear, dateToUnix, timestampToDate) to deduplicate
  identical implementations in QueryParser and QuerySerializer
- Move SavedSearch data class from desktopApp to commons/search
- Replace local formatTimestamp with existing toTimeAgo from commons/util
- Remove dead code: resolveAuthorName, _authorSuggestions (never called)
- Remove dead code: createOrFilters (never wired into SearchScreen)
- Remove unused ICacheProvider dependency from AdvancedSearchBarState

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 07:21:17 +02:00
Vitor Pamplona
9f903709e7 Move location permission watcher to the outside of the screens to avoid recreating them 2026-03-10 20:01:55 -04:00
Vitor Pamplona
8cd9449ba6 Improves upload of music and documents. 2026-03-10 19:49:15 -04:00
Vitor Pamplona
8a03f0b501 Replaces Aboutme for NIP-05 in the user search 2026-03-10 19:12:27 -04:00
Vitor Pamplona
2567a7c022 Better alignment and options in the UI. 2026-03-10 18:56:00 -04:00
Vitor Pamplona
178fcbb4c1 removes unused imports 2026-03-10 18:47:00 -04:00
Vitor Pamplona
cc4e81146f Saves the position of the screen on import follows dialog 2026-03-10 18:46:18 -04:00
Vitor Pamplona
7a3794eabb New string resource 2026-03-10 18:19:10 -04:00
Vitor Pamplona
a8d4705fa6 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-03-10 16:58:24 -04:00
Vitor Pamplona
a1e90b8c9f save the lazy list state and make it scroll in the search view model to avoid coming back on another feed position 2026-03-10 16:56:31 -04:00
Vitor Pamplona
26b7efafe8 enforces specific keys when using nip05/namecoin 2026-03-10 16:41:59 -04:00
Vitor Pamplona
ef1d7e1249 Merge pull request #1800 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-10 16:29:14 -04:00
Vitor Pamplona
9a3b11e522 Adds the ability to upload any file form any new post screen 2026-03-10 16:23:02 -04:00
Crowdin Bot
ffff1399b9 New Crowdin translations by GitHub Action 2026-03-10 20:18:49 +00:00
Vitor Pamplona
087e5878a6 Fixes some compilation issues 2026-03-10 16:14:38 -04:00
Vitor Pamplona
c0e97c19c0 Improves wording to download to the phone as opposed to the profile gallery 2026-03-10 16:07:45 -04:00
Vitor Pamplona
b4184d2235 Moves the upload file button closer to the image upload 2026-03-10 15:54:03 -04:00
Vitor Pamplona
b2a8a422b2 - Breaks the new Import Follow interface into two screens
- Improves user suggestion search by evaluating specific nip-05s and npubs
2026-03-10 15:48:04 -04:00
Vitor Pamplona
dbaa02cdb0 merge 2026-03-10 15:29:28 -04:00
Vitor Pamplona
cdc9ff783d Accepts INIP05Client on the check and update function 2026-03-10 14:39:33 -04:00
David Kaspar
8f5e69e5ba Merge pull request #1799 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-10 18:38:29 +00:00
Vitor Pamplona
558e8a2dd2 Adds constructor to DualCase class 2026-03-10 14:37:55 -04:00
Vitor Pamplona
1de38ad9ec Creates an interface for Nip05Client 2026-03-10 14:37:40 -04:00
Crowdin Bot
132959a5cf New Crowdin translations by GitHub Action 2026-03-10 18:23:38 +00:00
Vitor Pamplona
9b233c8679 no message 2026-03-10 14:23:31 -04:00
Vitor Pamplona
835001bd6c Making nip05 client stable 2026-03-10 14:22:06 -04:00
Vitor Pamplona
ccb3ca69dc Merge pull request #1798 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-10 14:21:39 -04:00
Vitor Pamplona
d69a9fdde1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  fix: preserve search screen scroll position across navigation
  feat: add audio and PDF file upload support to ShortNotePostScreen
  New Crowdin translations by GitHub Action
2026-03-10 13:50:13 -04:00
Vitor Pamplona
9e105e017d Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  update cz, pt, de, sv
  New Crowdin translations by GitHub Action
2026-03-10 13:49:26 -04:00
Crowdin Bot
98a09db9b0 New Crowdin translations by GitHub Action 2026-03-10 17:47:16 +00:00
Vitor Pamplona
7b8352afe2 Merge pull request #1795 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-10 13:45:34 -04:00
Vitor Pamplona
cdecc2087c Merge pull request #1796 from vitorpamplona/claude/add-audio-pdf-upload-dy1dy
Add file upload support for audio and PDF documents
2026-03-10 13:45:23 -04:00
Vitor Pamplona
4ad9e435e0 Merge pull request #1797 from vitorpamplona/claude/fix-search-scroll-position-U10Lv
Persist search screen scroll position across navigation
2026-03-10 13:44:24 -04:00
Claude
a64e9bb3cf fix: preserve search screen scroll position across navigation
Replace ephemeral rememberLazyListState() with rememberForeverLazyListState()
so the scroll position is persisted when navigating to a search result and back.

https://claude.ai/code/session_01PmSFQqSMzP51mtqPLwFEtp
2026-03-10 15:15:27 +00:00
Vitor Pamplona
ab9ecdc939 Adds methods to follow a bunch of users at the same time 2026-03-10 11:12:23 -04:00
Claude
a7cec6f5fd feat: add audio and PDF file upload support to ShortNotePostScreen
Add a new "attach file" button that opens a document picker filtered to
audio/* and application/pdf MIME types. This uses OpenMultipleDocuments
contract since PickVisualMedia only supports images/videos.

- Add SelectFromFiles composable with AttachFile icon button
- Add isAudio() and isDocument() helpers to SelectedMedia
- Add FilePreviewPlaceholder for audio/PDF thumbnail display
- Add upload_file string resource

https://claude.ai/code/session_018naEzfHjLRQLgaWAtY4aSz
2026-03-10 14:48:47 +00:00
Vitor Pamplona
b8f9b18506 Moves the account import idea to a route after login 2026-03-10 10:19:18 -04:00
Crowdin Bot
bc7d0fd758 New Crowdin translations by GitHub Action 2026-03-10 14:07:56 +00:00
David Kaspar
f42939c3b0 Merge pull request #1794 from davotoula/update-translations
update cz, pt, de, sv
2026-03-10 14:04:37 +00:00
davotoula
1e65228488 update cz, pt, de, sv 2026-03-10 14:56:33 +01:00
David Kaspar
b749fbdb88 Merge pull request #1793 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-10 13:37:53 +00:00
Crowdin Bot
1aa368221a New Crowdin translations by GitHub Action 2026-03-10 13:30:51 +00:00
Vitor Pamplona
ff4356ce31 Moves away from calling relay feeds "favorites" 2026-03-10 09:26:09 -04:00
Vitor Pamplona
3146b91c59 Make user import stable 2026-03-10 09:13:16 -04:00
Vitor Pamplona
55477429be removes old null mute lists from top nav bar 2026-03-10 09:13:05 -04:00
Vitor Pamplona
c4b4e1b5c8 adds a button to see the feed in the RelayInformationScreen 2026-03-10 08:51:10 -04:00
Vitor Pamplona
50a578cfdc Fixes preview of relay information screen 2026-03-10 08:50:51 -04:00
Vitor Pamplona
741a2e13b5 Fixes imports in NoteCompose 2026-03-10 08:44:53 -04:00
Vitor Pamplona
9ca5fc0496 Fixed rendering of calendar event previews 2026-03-10 08:43:59 -04:00
Vitor Pamplona
9c8bac7b0b Merge pull request #1792 from vitorpamplona/claude/add-calendar-event-rendering-HsY4W
Add support for rendering NIP-52 calendar events
2026-03-10 08:28:30 -04:00
Claude
fec2f2dfc8 feat: add @Preview functions for RenderCalendarTimeSlotEvent and RenderCalendarDateSlotEvent 2026-03-10 12:26:42 +00:00
Claude
2a2bdc8026 feat: add calendar event rendering for NIP-52 CalendarTimeSlotEvent and CalendarDateSlotEvent
Adds UI rendering for kind 31922 (CalendarDateSlotEvent) and kind 31923
(CalendarTimeSlotEvent) in the note feed. Each event renders with a header
image, title, date/time range, location, and summary.
2026-03-10 12:20:07 +00:00
Vitor Pamplona
376e34bde6 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  fix(quartz): connect() returns Unit, caller calls getPublicKey separately
  fix(quartz): fix NIP-44 key mutation and NIP-46 connect response handling
  feat: add custom ElectrumX server settings for Namecoin resolution
  feat: import follow list with Namecoin resolution during signup
2026-03-10 08:20:03 -04:00
Vitor Pamplona
70af50b74f Adds default Relay Feeds 2026-03-10 08:19:49 -04:00
Vitor Pamplona
21d1734365 Merge pull request #1786 from mstrofnone/feat/custom-electrumx-settings
feat: Custom ElectrumX server settings for Namecoin resolution
2026-03-10 08:14:36 -04:00
Vitor Pamplona
97a3fcc9df Merge pull request #1785 from mstrofnone/feat/import-follow-list-namecoin
feat: import follow list with Namecoin resolution during signup
2026-03-10 08:14:24 -04:00
Vitor Pamplona
88e6825028 Merge pull request #1789 from nrobi144/fix/nip46-quartz-bugs
fix(quartz): fix NIP-44 key mutation and NIP-46 connect response handling
2026-03-10 08:08:08 -04:00
nrobi144
b3952ce9db feat(search): add keyboard shortcuts, search history UI, and operator hints
Phase 6: Integration and polish.
- Escape key closes advanced panel or clears search
- Search history shown in empty state (recent + saved searches)
- Auto-save to history when search completes
- Updated empty state with operator hint examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:15:14 +02:00
nrobi144
e1abb037be feat(search): add SearchHistoryStore with recent queries and saved searches
Phase 5: Persistent search history using Java Preferences API.
- Last 20 queries stored, deduped by serialized text
- Saved searches with labels and timestamps
- Uses QuerySerializer/QueryParser for round-trip serialization (no new deps)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:13:34 +02:00
nrobi144
55154dddb2 feat(search): add advanced search UI with panel, results list, and SearchScreen rewrite
Phase 4: Desktop UI composables for advanced search.
- Rewrite SearchScreen.kt to use AdvancedSearchBarState with TextFieldValue
- Add AdvancedSearchPanel with kind presets, author chips, date range, hashtags, language
- Add SearchResultsList with sticky headers for People/Notes/Articles/Other sections
- Make QueryParser.parseDateToTimestamp and QuerySerializer.timestampToDate public

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:12:17 +02:00
nrobi144
8265d3f70a feat(search): add AdvancedSearchBarState with bidirectional sync
Phase 3: State holder with MutableStateFlow<SearchQuery> as SSOT,
sourceOfChange discriminator to prevent parse/serialize loops,
debounced query for subscriptions, results management, author
name resolution via cache autocomplete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:44:57 +02:00
nrobi144
c3a45822d5 feat(search): add SearchFilterFactory and SearchResultFilter
Phase 2: SearchFilterFactory converts SearchQuery → List<Filter> with
default 3-group kind splitting (Android parity), NIP-50 inline
extensions, OR query support. SearchResultFilter handles client-side
exclusions, reply/media pseudo-kind detection, dedup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:43:48 +02:00
nrobi144
ae76b86ab6 feat(search): add query engine — SearchQuery, QueryParser, QuerySerializer, KindRegistry
Phase 1 of advanced search. Recursive descent parser for operators
(from:, kind:, since:, until:, lang:, domain:, #tag, -exclude, OR),
serializer for bidirectional sync, kind alias registry with pseudo-kind
support. 68 unit tests all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:42:13 +02:00
nrobi144
e56b0d2b83 fix(quartz): connect() returns Unit, caller calls getPublicKey separately
Per Amber maintainer: connect never returns a pubkey — response is
"ack" or the secret string. Rewrote ConnectResponse.parse() to check
result/error fields directly on base BunkerResponse (matching Jackson
deserialization). connect() is now a pure protocol handshake; callers
call getPublicKey() separately as a domain concern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:24:57 +02:00
Vitor Pamplona
e47299cd6b Rendering a fake waveform when playing music without any video frames. 2026-03-09 16:32:50 -04:00
Vitor Pamplona
328fd5221c Reducing the distance from the center to the side seek buttons on video playback 2026-03-09 13:02:15 -04:00
Vitor Pamplona
eafbd450ea changes player's min height to match the gradients on controllers. 2026-03-09 13:00:02 -04:00
Vitor Pamplona
68997b8033 Always render video playback center buttons on the top 2026-03-09 12:59:10 -04:00
Vitor Pamplona
05b3a122f5 Positions search relays after users in the search result list 2026-03-09 12:48:48 -04:00
Vitor Pamplona
7b3a745548 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-03-09 12:41:16 -04:00
Vitor Pamplona
1aa61a156d If tying a relay in the search screen, adds it to the results. 2026-03-09 12:37:58 -04:00
Vitor Pamplona
bdf4add64f Adds a list of users of a relay to the relay information dialog. 2026-03-09 12:24:10 -04:00
Vitor Pamplona
a34ba7cea8 Adds relay search to the search page 2026-03-09 11:46:21 -04:00
Vitor Pamplona
2387a1bb91 Improves the order of search results 2026-03-09 11:25:00 -04:00
Vitor Pamplona
76598c304e Improvements to profile page 2026-03-09 10:44:33 -04:00
David Kaspar
8e10bd1911 Merge pull request #1790 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-09 13:12:51 +00:00
Crowdin Bot
40bbd62725 New Crowdin translations by GitHub Action 2026-03-09 12:47:21 +00:00
Vitor Pamplona
961b8cf19e Fixes user search/list not resetting
Fixes modifier bug
2026-03-09 08:43:29 -04:00
nrobi144
39393d328b fix(quartz): fix NIP-44 key mutation and NIP-46 connect response handling
Two bugs in quartz's NIP-46 remote signer:

1. FixedKey.getEncoded() returned the raw byte array reference. javax.crypto.Mac
   zeroes the key via destroy() after HMAC computation, corrupting the NIP-44
   saltPrefix ("nip44-v2") after the first computeConversationKey call. All
   subsequent NIP-44 decryptions with different key pairs fail with Invalid Mac.
   Fix: return key.copyOf() instead of key.

2. NostrSignerRemote.connect() only handled pubkey responses. Amber (and other
   signers) may respond with "ack" or "already connected" error. Added
   ConnectResponse parser that maps these to success and falls back to
   getPublicKey() to retrieve the actual pubkey.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 07:49:58 +02:00
Vitor Pamplona
af4404c86e Improves display of the drop down when adding relays. 2026-03-08 13:47:11 -04:00
Vitor Pamplona
7fa9fdff9e When adding new relays, show full information dialog 2026-03-08 13:07:29 -04:00
Vitor Pamplona
693931ed15 Removes the need to start user searches with @ in the user fields across the app. 2026-03-08 12:43:59 -04:00
Vitor Pamplona
785ef8f2f3 Final touches 2026-03-08 12:31:10 -04:00
Vitor Pamplona
92da66059b Opens the amount dialog when pressing the zap button without any zap setup 2026-03-08 12:31:03 -04:00
Vitor Pamplona
db27b876ff Refines new Zap dialog screen 2026-03-08 12:17:41 -04:00
Vitor Pamplona
8912e55269 text improvements 2026-03-08 12:12:08 -04:00
Vitor Pamplona
c1e8231ed9 Merge pull request #1784 from vitorpamplona/claude/improve-zap-settings-ui-D36RD
feat: improve Zap Settings UI with sections, chips, and animations
2026-03-08 11:59:13 -04:00
Vitor Pamplona
f39de5a078 Merge pull request #1788 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-08 10:04:24 -04:00
Crowdin Bot
0b59a6233e New Crowdin translations by GitHub Action 2026-03-08 14:00:37 +00:00
Vitor Pamplona
44a2f11963 Merge pull request #1787 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-08 09:59:38 -04:00
Crowdin Bot
5cf661dd8b New Crowdin translations by GitHub Action 2026-03-08 13:59:11 +00:00
Vitor Pamplona
991f1dfca5 spotless apply 2026-03-08 09:58:41 -04:00
Vitor Pamplona
be09cc8aa3 Merge pull request #1783 from vitorpamplona/claude/improve-urldetector-readability-lqyci
refactor: improve URLDetector readability
2026-03-08 09:57:56 -04:00
M
55c45480b4 feat: add custom ElectrumX server settings for Namecoin resolution
Add a 'Namecoin Resolution' section that lets users configure custom
ElectrumX servers for .bit / d/ / id/ name resolution. When custom
servers are configured, they are used EXCLUSIVELY — the hardcoded
public defaults are completely ignored. This gives privacy-conscious
users full control over which servers observe their name lookups.

Server format: host:port (TLS default) or host:port:tcp (plaintext).
Supports .onion addresses for Tor-routed lookups.

New files:
  NamecoinSettings.kt
    Immutable settings data class with server string parsing/formatting.

  NamecoinSharedPreferences.kt
    DataStore-backed persistence following the TorSharedPreferences
    pattern. Provides a synchronous snapshot for the serverListProvider
    lambda and observable StateFlow for the UI.

  NamecoinSettingsSection.kt
    Compose UI: enable/disable toggle, active server display with
    DEFAULT/CUSTOM badge, add/remove servers with validation, reset.

  NamecoinSettingsTest.kt
    Unit tests for parsing, formatting, round-trips, and edge cases.

Integration:
  AppModules.kt — added namecoinPrefs (NamecoinSharedPreferences) and
  wired customServersOrNull into the existing serverListProvider so
  user-configured servers take priority over the Tor/default logic.

Refactored from an earlier patch to avoid duplicating code already in
main. The quartz-layer ElectrumXClient, NamecoinNameResolver, and
supporting types are reused unchanged.
2026-03-08 16:16:22 +11:00
M
8456b38dea feat: import follow list with Namecoin resolution during signup
Add a new signup step that lets users bootstrap their feed by importing
another profile's follow list. Supports all identifier types:

  - npub1... (NIP-19 bech32)
  - 64-char hex pubkey
  - alice@example.com (NIP-05 HTTP)
  - alice@example.bit / d/name / id/name (Namecoin blockchain)

Flow: Enter identifier → Resolve → Fetch kind 3 → Preview with
select/deselect → Apply follows → Done. Users can search multiple
profiles before continuing.

Changes to existing files:
  - NamecoinNameResolver: add resolveDetailed() + NamecoinResolveOutcome
  - NamecoinNameService: add resolveDetailed(), resolvePubkey()
  - AccountSessionManager: add isNewAccount flag, finishNewAccountSetup()
  - AccountScreen: route new accounts to ImportFollowListSection

New files:
  - FollowListImporter: identifier resolution + kind 3 fetch + parsing
  - ImportFollowListViewModel: state machine for the import flow
  - ImportFollowListScreen: UI with preview, select/deselect, search again
  - FollowListImporterTest: 14 unit tests
2026-03-08 15:38:54 +11:00
Claude
233d0bf089 feat: improve Zap Settings UI with sections, chips, and animations
- Organize zap settings into three clear sections with primary-colored
  headers: Quick Zap Amounts, Zap Privacy, and Nostr Wallet Connect
- Replace plain Buttons with InputChip for amount selection — each chip
  shows a lightning bolt, the amount, and a close icon to remove it
- Add animateContentSize() to the chips FlowRow so adding/removing
  amounts animates smoothly
- Add descriptive sub-text under each section header explaining what
  the control does
- NWC section: show an animated "Connected / Not Connected" status badge
  (green CheckCircle vs grey RadioButtonUnchecked) that cross-fades via
  AnimatedContent when the connection state changes
- NWC section: add animateColorAsState for the status color transition
- Replace the bare icon-only Add button with an OutlinedButton labelled
  "Connect Wallet" for better discoverability
- Move the manual pubkey/relay/secret fields into a collapsible
  "Advanced" row animated with AnimatedVisibility (expandVertically +
  fadeIn/fadeOut). The section auto-expands when a connection already
  exists or when a QR/clipboard import populates the pubkey field
- Add 9 new string resources for the new UI copy

https://claude.ai/code/session_01QzBq319fsxhrT91bGxws6N
2026-03-08 02:09:28 +00:00
Claude
812f465608 refactor: improve URLDetector readability
- UrlDetector: simplify space handler in readDefault() — both branches
  of if/else were calling the same readEnd(InvalidUrl), making the
  conditional dead code; now we always call readEnd after attempting
  to read the domain
- UrlDetector: remove redundant inner buffer.isNotEmpty() check in
  readEnd(), already guarded by the outer condition
- UrlDetector: rename processColon() parameter from length to
  startLength to eliminate the var length = length self-shadowing
- UrlDetector: replace ArrayList<Url>() with mutableListOf<Url>()
  for idiomatic Kotlin
- DomainNameReader: replace lastSection.deleteRange(0, lastSection.length)
  with lastSection.clear() which expresses the intent directly
- DomainNameReader: move isDotPercent() and isXn() extension functions
  from inside the class body to private file-level functions — extension
  functions defined on a class instance are unusual and confusing

https://claude.ai/code/session_01Pci2AC45yQxQw6F6g7jWdd
2026-03-08 00:54:38 +00:00
David Kaspar
46d54d2826 Merge pull request #1782 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-07 22:09:58 +00:00
Crowdin Bot
e94985c614 New Crowdin translations by GitHub Action 2026-03-07 22:03:53 +00:00
Vitor Pamplona
9054e45310 Fixes test case 2026-03-07 16:55:12 -05:00
Vitor Pamplona
2059f46c42 Make Urls Stable 2026-03-07 16:55:04 -05:00
Vitor Pamplona
e6e697a5f8 Adds the full list of URLs to the Desktop app 2026-03-07 16:54:47 -05:00
Vitor Pamplona
0e3ca7ac35 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst: (35 commits)
  New Crowdin translations by GitHub Action
  fix chess tests
  Replace subsequent checks with '!isNullOrEmpty()' call
  use forEach: no intermediate list created.
  Use filterIsInstance to avoid dual iteration
  Lambda argument should be moved out of parentheses
  replace null checks with Elvis
  merge call chain with flatMap (slight performance improvement)
  New Crowdin translations by GitHub Action
  New Crowdin translations by GitHub Action
  update cz, pt, de, sv
  was this meant to be withContext?
  add names to boolean params
  remove unused imports
  Explicit type arguments can be inferred
  remove redundant modifiers
  correct package name
  If-Null return/break/... foldable to '?:'
  Declaration has type inferred from a platform call, which can lead to unchecked nullability issues. Specify type explicitly as nullable or non-nullable.
  Declaration has type inferred from a platform call, which can lead to unchecked nullability issues. Specify type explicitly as nullable or non-nullable.
  ...

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt
2026-03-07 16:47:16 -05:00
Vitor Pamplona
966d02d89e Removes top level domain number of char limitations to accept nostr: uris 2026-03-07 16:41:11 -05:00
Vitor Pamplona
e3f99051db Applies new UrlDetector to the RichTextViewer 2026-03-07 16:38:25 -05:00
Vitor Pamplona
030921c963 Adds treatment for the transition between ASCII and non-ASCII in the middle of urls 2026-03-07 15:19:24 -05:00
Vitor Pamplona
5e1a193f78 only http for the url previews 2026-03-07 11:08:38 -05:00
Vitor Pamplona
109a5e7cd7 Adds single label domains to parse nostr: urls too 2026-03-07 10:51:19 -05:00
David Kaspar
864555bc0f Merge pull request #1781 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-07 07:31:54 +00:00
Crowdin Bot
ec0fca24c6 New Crowdin translations by GitHub Action 2026-03-07 01:23:23 +00:00
Vitor Pamplona
feeba9bf90 Merge pull request #1780 from davotoula/lint-fixes-pt2
Lint fixes pt2
2026-03-06 20:21:54 -05:00
Vitor Pamplona
6318c54214 Merge pull request #1779 from davotoula/quartz-fix-jvm-chess-tests
fix chess tests
2026-03-06 20:21:12 -05:00
davotoula
5658b14ad4 fix chess tests 2026-03-06 23:26:40 +01:00
davotoula
b713b01af1 Replace subsequent checks with '!isNullOrEmpty()' call 2026-03-06 22:47:29 +01:00
davotoula
f8187bad14 use forEach: no intermediate list created. 2026-03-06 22:38:05 +01:00
davotoula
03171f97a5 Use filterIsInstance to avoid dual iteration 2026-03-06 22:21:03 +01:00
davotoula
6bfb508ddc Lambda argument should be moved out of parentheses 2026-03-06 22:17:32 +01:00
davotoula
d7d9352a90 replace null checks with Elvis 2026-03-06 22:09:19 +01:00
davotoula
4e614a3179 merge call chain with flatMap (slight performance improvement) 2026-03-06 21:51:26 +01:00
David Kaspar
a89bfd0b05 Merge pull request #1778 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-06 20:47:40 +00:00
Crowdin Bot
3bf79355b8 New Crowdin translations by GitHub Action 2026-03-06 20:46:29 +00:00
David Kaspar
b61ba78eb3 Merge pull request #1777 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-06 20:45:03 +00:00
Crowdin Bot
82fd15272d New Crowdin translations by GitHub Action 2026-03-06 20:43:36 +00:00
David Kaspar
069a19b3f5 Merge pull request #1776 from davotoula/update-translations
update cz, pt, de, sv
2026-03-06 20:42:07 +00:00
Vitor Pamplona
5d9687fb47 Merge pull request #1771 from mstrofnone/namecoin-nip05-improvements
fix: address race conditions and improve Namecoin NIP-05 resolution
2026-03-06 15:13:20 -05:00
Vitor Pamplona
976a9d5c77 Merge pull request #1774 from davotoula/lint-fixes
Lint fixes
2026-03-06 15:12:43 -05:00
Vitor Pamplona
1729a55add Merge pull request #1775 from davotoula/potential-bug-note-compose
Typo / potential bug?
2026-03-06 15:12:19 -05:00
davotoula
8d212b8f03 update cz, pt, de, sv 2026-03-06 21:04:26 +01:00
davotoula
aec0101769 was this meant to be withContext? 2026-03-06 20:10:35 +01:00
davotoula
7f0b2c500f add names to boolean params 2026-03-06 19:59:20 +01:00
davotoula
582153b193 remove unused imports 2026-03-06 19:59:20 +01:00
davotoula
8868b1b0e6 Explicit type arguments can be inferred 2026-03-06 19:59:20 +01:00
davotoula
bd71e9ae8f remove redundant modifiers 2026-03-06 19:59:20 +01:00
davotoula
8329184d99 correct package name 2026-03-06 19:46:48 +01:00
davotoula
c8c42b5d70 If-Null return/break/... foldable to '?:' 2026-03-06 19:46:47 +01:00
davotoula
34e1ea92b2 Declaration has type inferred from a platform call, which can lead to unchecked nullability issues. Specify type explicitly as nullable or non-nullable. 2026-03-06 19:46:47 +01:00
davotoula
99e8ffcdeb Declaration has type inferred from a platform call, which can lead to unchecked nullability issues. Specify type explicitly as nullable or non-nullable. 2026-03-06 19:46:47 +01:00
davotoula
7de098063f Specifying : ExoPlayer makes the contract explicit 2026-03-06 19:46:47 +01:00
davotoula
953e8c1749 supress false positive 2026-03-06 19:46:47 +01:00
davotoula
5ac8b9ad62 Prefer mutableLongStateOf instead of mutableStateOf 2026-03-06 19:46:47 +01:00
davotoula
80fb171e0a Unnecessary; SDK_INT is always >= 26 2026-03-06 19:46:47 +01:00
davotoula
8876c3c1d7 use multi-dollar string interpolation
avoid default locale
2026-03-06 19:46:47 +01:00
davotoula
ccb5738826 Replace the API-branched calls with ContextCompat.registerReceiver() which handles the flags transparently across all API levels 2026-03-06 19:46:47 +01:00
davotoula
4ee4a00b58 Add a nested comment explaining why this function is empty 2026-03-06 19:46:47 +01:00
Vitor Pamplona
f4d401bc56 Switches to our own version of the Url Detector 2026-03-06 10:58:59 -05:00
mstrofnone
a817a2cc79 remove dead namecoinNameService declaration from AppModules
The NamecoinNameService.init() singleton was the only consumer of
the companion object removed in the previous commit. The
namecoinNameService val itself was never referenced — namecoinResolver
is what's wired into Nip05Client.
2026-03-07 00:49:05 +11:00
mstrofnone
24a03b5d20 remove unused singleton companion object from NamecoinNameService
The companion object (instance, getInstance, init) was dead code —
nothing outside the class referenced it. Koin handles instantiation
via AppModules.
2026-03-07 00:38:58 +11:00
Vitor Pamplona
ddc4daaa28 Merge pull request #1773 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-06 08:12:46 -05:00
Crowdin Bot
998e6c690b New Crowdin translations by GitHub Action 2026-03-06 13:12:29 +00:00
Vitor Pamplona
dd062f6e38 Merge pull request #1772 from greenart7c3/main
perf: optimize state management and clean up styling in ReactionsSettingsScreen
2026-03-06 08:10:13 -05:00
greenart7c3
0320ccf4a0 perf: optimize state management and clean up styling in ReactionsSettingsScreen
- Change `items` state initialization to use `toList()` instead of `toMutableList()` to ensure a fresh immutable snapshot from the flow.
- Reformat chained modifiers in `DraggableItem` for better readability.
2026-03-06 08:15:33 -03:00
M
4ecfbbb844 fix: add missing StateFlow import, merge duplicate KDoc blocks, and fix rebase conflicts
- Add missing 'import kotlinx.coroutines.flow.StateFlow' in SearchBarViewModel.kt
- Merge duplicate KDoc blocks on nameShowWithFallback in ElectrumXClient.kt
- Move NamecoinLookupException to ElectrumXServer.kt (avoid redeclaration)
- Remove duplicate NameShowResult/ElectrumxServer from ElectrumXClient.kt
- Fix ElectrumxClient -> ElectrumXClient renames in NamecoinNameService
- Extract namecoinElectrumxClient in AppModules.kt for NamecoinNameService.init()
- Update package paths from nip05.namecoin to nip05DnsIdentifiers.namecoin
2026-03-06 14:22:49 +11:00
M
050fd3a412 feat: propagate distinct error types from ElectrumX to search UI
Add NamecoinLookupException sealed class to distinguish:
- NameNotFound: name queried successfully but doesn't exist on blockchain
- NameExpired: name exists but expired (>36000 blocks since last update)
- ServersUnreachable: all ElectrumX servers failed with connection errors

Previously, all three cases returned null from nameShowWithFallback,
making it impossible for the UI to show meaningful feedback.

Changes:
- ElectrumxClient.connectAndQuery: throws NameNotFound/NameExpired
  instead of returning null for definitive blockchain answers
- ElectrumxClient.nameShow: lets NamecoinLookupException propagate
  (only catches connection/IO errors as null)
- ElectrumxClient.nameShowWithFallback: short-circuits on NameNotFound/
  NameExpired (no point trying other servers for definitive answers),
  throws ServersUnreachable when all servers fail with connection errors
- SearchBarViewModel: new namecoinSearchState flow with typed states
  (Idle/Loading/Resolved/NotFound/Expired/ServersUnreachable) for UI
  to show loading spinners, resolved profiles, and specific error
  messages. Derived namecoinResolvedUser from this flow.

Discovered while porting to notedeck (damus-io/notedeck#1314).
2026-03-06 14:15:11 +11:00
M
ee418bdd0a fix: address race conditions and improve Namecoin NIP-05 resolution
Fixes discovered while porting Namecoin NIP-05 to Primal Android
(PrimalHQ/primal-android-app#934):

1. Race condition: stale intermediate lookups overwrite final result
   - Changed map to mapLatest in SearchBarViewModel so that previous
     in-flight ElectrumX lookups are cancelled when the query changes
   - Without this, typing 'd/testls' character by character causes
     stale results from 'd/test', 'd/testl' etc. to arrive after
     the correct 'd/testls' result and overwrite it with null

2. CancellationException swallowed in catch block
   - mapLatest cancels previous coroutines via CancellationException
   - The generic catch was catching it and emitting null, wiping the
     valid result. Now re-throws CancellationException

3. Root lookup fails when names object has no underscore key
   - extractFromDomainValue tried names[localPart] then names[underscore]
     which is the same thing for root lookups
   - Now falls back to first available entry for root lookups when
     no underscore key exists
   - Non-root lookups still fail correctly (no false matches)

4. Global Mutex serializes all lookups
   - Single Mutex in ElectrumxClient blocked unrelated lookups to
     different servers. A 25s timeout on one server stalled everything
   - Now uses per-server mutexes via ConcurrentHashMap

5. customServers set but never read
   - NamecoinNameService.setCustomServers() stored the list but the
     resolver was constructed with the default serverListProvider
   - Now wires customServers through to the resolver

6. resolveLive uses orphaned CoroutineScope
   - Now accepts an optional external scope parameter so callers can
     tie resolution to their own lifecycle

Adds 2 new tests: root fallback to first entry, non-root no-fallback.
2026-03-06 14:14:29 +11:00
Vitor Pamplona
13f401df8c Moves test cases to commonTest 2026-03-05 08:34:18 -05:00
Vitor Pamplona
73c5418114 Fixes bug on Show More calculations for very long texts. 2026-03-05 08:31:01 -05:00
Vitor Pamplona
cb6391085a Improving the ui for reaction row settings 2026-03-04 17:37:52 -05:00
Vitor Pamplona
f4ec5735d3 Better description of the settings 2026-03-04 17:21:32 -05:00
Vitor Pamplona
0e124f3017 Merge pull request #1769 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-04 17:18:51 -05:00
Crowdin Bot
52ec6eded8 New Crowdin translations by GitHub Action 2026-03-04 22:09:54 +00:00
Vitor Pamplona
6be2c7cbdf Merge pull request #1768 from vitorpamplona/claude/add-drag-drop-reactions-43YJ2
Replace move buttons with drag-to-reorder in reactions settings
2026-03-04 17:08:14 -05:00
Claude
c2384d2082 feat: add drag-and-drop reordering for reaction row items
Replace up/down arrow buttons with a drag handle icon that supports
long-press drag gestures for reordering reaction items in settings.
Items visually lift with elevation and scale during drag, and swap
positions when dragged past neighboring item thresholds.

https://claude.ai/code/session_01RYsPFgnoeJ3BmwjFPMvTuU
2026-03-04 21:32:09 +00:00
David Kaspar
13dadeda37 Merge pull request #1767 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-04 20:13:57 +00:00
Crowdin Bot
9f772b0e13 New Crowdin translations by GitHub Action 2026-03-04 20:12:55 +00:00
Vitor Pamplona
9b9c2a722a Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-03-04 15:09:40 -05:00
Vitor Pamplona
c95a9842a4 Adds the latin1 characters to the ascii block to avoid splitting words 2026-03-04 15:09:26 -05:00
David Kaspar
82ff8010c5 Merge pull request #1766 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-04 19:57:11 +00:00
Crowdin Bot
3a97f89199 New Crowdin translations by GitHub Action 2026-03-04 19:42:16 +00:00
Vitor Pamplona
2f94b3cb15 Adds the latin1 characters to the ascii block to avoid splitting words 2026-03-04 14:38:48 -05:00
Vitor Pamplona
be5c1f4832 Removes encrypted data from search 2026-03-04 13:56:06 -05:00
Vitor Pamplona
a536cc6f82 Merge pull request #1764 from greenart7c3/claude/reactions-settings-HhrO0
Add customizable reactions row settings screen
2026-03-04 13:35:03 -05:00
Vitor Pamplona
4839535355 Fixes test case for new class names 2026-03-04 13:29:09 -05:00
greenart7c3
4bd0ed97aa Explicit imports 2026-03-04 14:46:44 -03:00
Claude
d2d887a782 feat: Add reactions row settings (enable/disable, order, show/hide counters)
Add a new Reactions Settings screen that allows users to:
- Enable or disable each reaction button (Reply, Boost, Like, Zap, Share)
- Reorder reaction buttons with up/down arrows
- Show or hide the counter for each reaction type

Changes:
- AccountSyncedSettingsInternal: Add ReactionRowAction enum, ReactionRowItem
  data class, and reactionRowItems field to AccountReactionPreferencesInternal
- AccountSyncedSettings: Wrap reactionRowItems in MutableStateFlow
- AccountSettings/Account: Add changeReactionRowItems methods
- AccountViewModel: Add reactionRowItemsFlow() and changeReactionRowItems()
- ReactionsRow: Add showCounter param to BoostReaction, LikeReaction,
  ZapReaction, ReplyReaction variants; refactor GenericInnerReactionRow to
  render dynamically based on settings
- New ReactionsSettingsScreen with per-button enable/counter toggles and
  move-up/move-down ordering
- Routes, AppNavigation, AllSettingsScreen: wire up new settings screen

https://claude.ai/code/session_01QizUFfWDeKjLK2SuhanipJ
2026-03-04 17:24:25 +00:00
Vitor Pamplona
fbbc506e84 updates all dependencies 2026-03-04 12:19:57 -05:00
Vitor Pamplona
1fa93788db android target seems to be creating some issues between plugin dependencies after adding spm 2026-03-04 12:19:47 -05:00
Vitor Pamplona
da01c6ccd1 Moves smp plugin to version catalog 2026-03-04 12:18:55 -05:00
Vitor Pamplona
67390bb8ec Updating Coil 2026-03-04 12:18:11 -05:00
Vitor Pamplona
d662cd6b64 Merge pull request #1757 from KotlinGeekDev/kmp-completeness
KMP Completeness, Part 1.
2026-03-04 10:27:24 -05:00
Vitor Pamplona
089600d7f0 Fixes test case 2026-03-04 10:26:21 -05:00
Vitor Pamplona
8501c86663 Further organizes Calendar events 2026-03-04 10:25:51 -05:00
Vitor Pamplona
d3bab2f8cb Separate generics functions 2026-03-04 10:19:10 -05:00
Vitor Pamplona
b563ab44b0 Merge pull request #1762 from vitorpamplona/claude/nip52-calendar-compliance-yXoXR
Add NIP-52 Calendar event builders and tag helpers
2026-03-04 10:06:03 -05:00
Vitor Pamplona
ac936dc673 minor adjustment 2026-03-04 10:02:23 -05:00
Vitor Pamplona
470b1680d0 Moves electrum client to the jvmAndroid scope 2026-03-04 10:00:33 -05:00
Vitor Pamplona
193561c336 Migrates to LruCache 2026-03-04 09:32:31 -05:00
Vitor Pamplona
c10f7a0ef2 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-03-04 09:32:20 -05:00
Vitor Pamplona
9abd61e2de Improves package layout for nip64 chess 2026-03-04 09:25:38 -05:00
Vitor Pamplona
46dcd105c8 Organizes Chess class and common tags 2026-03-04 09:19:39 -05:00
Vitor Pamplona
4060bef2be Lint 2026-03-04 09:19:21 -05:00
KotlinGeekDev
ff7aa3df79 Merge branch 'vitorpamplona:main' into kmp-completeness 2026-03-04 14:40:12 +01:00
David Kaspar
aab0785626 Merge pull request #1763 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-04 13:37:57 +00:00
Crowdin Bot
af589afa4d New Crowdin translations by GitHub Action 2026-03-04 13:34:05 +00:00
Vitor Pamplona
01a45d3b65 Merge pull request #1761 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-04 08:30:46 -05:00
Vitor Pamplona
ad578b394d Merge pull request #1759 from vitorpamplona/claude/refactor-nip64chess-structure-3iFIB
refactor: restructure nip64Chess to match nip88Polls pattern
2026-03-04 08:30:35 -05:00
Vitor Pamplona
0ebbd54498 Merge pull request #1734 from mstrofnone/feature/namecoin-nip05-resolution
feat: Namecoin NIP-05 identity verification via ElectrumX
2026-03-04 08:30:21 -05:00
Vitor Pamplona
e065cbc63b Merge branch 'main' into feature/namecoin-nip05-resolution 2026-03-04 08:29:48 -05:00
Claude
c00295f6f2 feat: implement full NIP-52 calendar spec compliance
- CalendarDateSlotEvent (31922): Add title, summary, image, geohash,
  hashtags, participants, references, multiple locations accessors and
  build() method with DSL builder pattern
- CalendarTimeSlotEvent (31923): Fix timezone bug (startTzId/endTzId
  were parsed as Long instead of String), add all accessors like
  DateSlot, rename startTmz/endTmz to startTzId/endTzId, add build()
- CalendarRSVPEvent (31925): Remove incorrect location/start/end
  accessors (not in spec), add status/freebusy/calendarEventAddress/
  calendarEventId/calendarEventAuthor accessors, update from outdated
  L/l label format to current status/fb tag format, add build()
- CalendarEvent (31924): Add title and calendarEventAddresses accessors,
  add build() method
- Create NIP-52 tag classes: LocationTag, RSVPStatusTag (with enum),
  FreeBusyTag (with enum)
- Create TagArrayBuilderExt.kt with DSL builder extensions for all
  four calendar event types

https://claude.ai/code/session_01UMXgE3tNftmqNZqcYwF2kL
2026-03-04 13:29:04 +00:00
Crowdin Bot
a5fe5836e5 New Crowdin translations by GitHub Action 2026-03-04 13:28:27 +00:00
Vitor Pamplona
755df5e60e Merge pull request #1760 from nrobi144/feat/desktop-deck-layout
feat(desktop): multi-column deck layout with single-pane toggle
2026-03-04 08:26:32 -05:00
KotlinGeekDev
4cea4e4f5d Fix issues needing a fix caught by review. 2026-03-04 13:51:46 +01:00
KotlinGeekDev
7b6db9404c Merge branch 'vitorpamplona:main' into kmp-completeness 2026-03-04 13:21:27 +01:00
nrobi144
db530a6820 fix(desktop): audit fixes for deck layout
Critical:
- GlobalFeed now renders global feed (was identical to HomeFeed)
- DeckState uses atomic _columns.update{} instead of non-atomic .value=
- lastKnownWidth marked @Volatile for thread safety

Medium:
- save() debounced 500ms via coroutine (was sync disk I/O per drag frame)
- Width redistribution fills gaps after clamping on column remove
- Hashtag column pre-fills search query
- ColumnNavigationState.stack exposed as StateFlow not MutableStateFlow
- Settings column deduped (focuses existing instead of adding duplicate)
- Exceptions logged in save/load instead of silently swallowed

Low:
- Thread column icon fixed (was Home, now Article)
- Drag divider hit target widened to 12dp (visual stays 4dp)
- Horizontal scroll added for column overflow
- Auto-fit columns on first composition / window resize
- Column shortcuts limited to actual column count
- Deterministic default column IDs
- Overlay fallback shows message instead of blank

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:02:22 +02:00
nrobi144
73b8059b55 feat(desktop): collapsible info panel in chess deck column
Hide GameInfo/MoveHistory/Actions panel by default in deck view
with a chevron toggle to expand. Add Chess to AddColumnDialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:45:01 +02:00
nrobi144
991ac3b31b merge: integrate upstream main with chess PR #1702
Resolves merge conflicts in Main.kt, adds Chess column type
to deck layout (DeckColumnType, ColumnHeader, DeckColumnContainer,
DeckState, SinglePaneLayout, View menu). Updates DesktopMessagesScreen
calls for new relayManager/localCache params.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:33:44 +02:00
David Kaspar
931ea33694 Merge pull request #1758 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-04 05:21:06 +00:00
KotlinGeekDev
b145a14ea3 Merge branch 'vitorpamplona:main' into kmp-completeness 2026-03-04 02:02:12 +01:00
Claude
2df8a106d4 refactor: restructure nip64Chess to match nip88Polls pattern
Reorganize nip64Chess event classes into individual packages with
dedicated tag classes, TagArrayBuilderExt, and TagArrayExt files,
following the same structure used by nip88Polls.

New package structure:
- challenge/ - LiveChessGameChallengeEvent with PlayerColorTag, TimeControlTag
- accept/ - LiveChessGameAcceptEvent with ChallengeEventTag
- move/ - LiveChessMoveEvent with GameIdTag, MoveNumberTag, SanTag, FenTag
- end/ - LiveChessGameEndEvent with ResultTag, TerminationTag, WinnerTag
- draw/ - LiveChessDrawOfferEvent
- game/ - ChessGameEvent (Kind 64)
- jester/ - JesterEvent, JesterProtocol, JesterContent, JesterGameEvents

Each tag class follows the companion object pattern with isTag(),
parse(), and assemble() methods. Each event package includes
TagArrayBuilderExt for building and TagArrayExt for parsing.

https://claude.ai/code/session_01Qtzhka3p5N3Hbns4xrRbsv
2026-03-04 00:51:43 +00:00
Crowdin Bot
a37c6b5e6f New Crowdin translations by GitHub Action 2026-03-04 00:32:36 +00:00
Vitor Pamplona
546d1e9e73 Protects against invalid URLs 2026-03-03 19:30:39 -05:00
KotlinGeekDev
a820082a6a Fix linter issues. 2026-03-04 01:30:38 +01:00
Vitor Pamplona
5cf306683a Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  update cz, pt, de, sv
  New Crowdin translations by GitHub Action
  update cz, pt, de, sv
  New Crowdin translations by GitHub Action
2026-03-03 19:26:25 -05:00
Vitor Pamplona
96967b9e30 Clearing AccountViewModels more precisely 2026-03-03 19:17:15 -05:00
KotlinGeekDev
bbd17e4e64 Fix build issues. 2026-03-04 01:06:33 +01:00
KotlinGeekDev
a23850f002 Merge branch 'upstream-main' into kmp-completeness 2026-03-04 00:44:47 +01:00
KotlinGeekDev
68a262fa88 Merge branch 'main' of https://github.com/vitorpamplona/amethyst into upstream-main
# Conflicts:
#	gradle/libs.versions.toml
#	quartz/build.gradle.kts
#	quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt
#	quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt
#	quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt
2026-03-04 00:37:10 +01:00
KotlinGeekDev
aaf3be98b7 Foundations(iOS Sourceset): Update SpmForKmp. Fix UnicodeNormalizer code. 2026-03-04 00:27:00 +01:00
David Kaspar
ea8ed31ba4 Merge pull request #1756 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-03 22:43:07 +00:00
Crowdin Bot
b1504da012 New Crowdin translations by GitHub Action 2026-03-03 22:40:22 +00:00
David Kaspar
9b6d2e9c3a Merge pull request #1755 from davotoula/update-translations
update cz, pt, de, sv
2026-03-03 22:39:09 +00:00
davotoula
8ad1cddb42 update cz, pt, de, sv 2026-03-03 22:36:06 +00:00
David Kaspar
c04d93fcea Merge pull request #1754 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-03 22:24:51 +00:00
Crowdin Bot
8c8fdbc27c New Crowdin translations by GitHub Action 2026-03-03 22:23:46 +00:00
David Kaspar
43133b70f4 Merge pull request #1753 from davotoula/update-translations
update cz, pt, de, sv
2026-03-03 22:22:21 +00:00
davotoula
382273647f update cz, pt, de, sv 2026-03-03 22:18:20 +00:00
Vitor Pamplona
14e6f1bdb1 Merge pull request #1752 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-03 17:07:27 -05:00
Crowdin Bot
dc817abe2c New Crowdin translations by GitHub Action 2026-03-03 21:44:54 +00:00
Vitor Pamplona
d8911ef69c Account State is more than just a view model. 2026-03-03 16:42:45 -05:00
Vitor Pamplona
2e71401342 Deletes the default scope form App Modules 2026-03-03 15:17:01 -05:00
M
cb1031705b Remove dead ElectrumX servers ulrichard.ch and nmc2.lelux.fi
ulrichard.ch:50006 — connects but never responds (timeout)
nmc2.lelux.fi:50006 — DNS resolution fails (ENOTFOUND)
2026-03-04 06:34:39 +11:00
M
c649f2163b Add nmc2.bitcoins.sk and 46.229.238.187 as backup ElectrumX servers
Both verified to resolve Namecoin names via scripthash lookups.
Port 57002, TLS with self-signed certs.
2026-03-04 06:30:50 +11:00
Vitor Pamplona
a417eb46f7 Fixes the need to have tags and kinds for inbox.nostr.wine to work 2026-03-03 13:40:26 -05:00
Vitor Pamplona
264b988b41 Fixing sending inside this function 2026-03-03 13:37:55 -05:00
Vitor Pamplona
7b80ee8bf0 Fixes the need to specify route in the check if navigation is going to the same route or not 2026-03-03 12:29:17 -05:00
Vitor Pamplona
cdbee03e93 Fixes animation for settings pages 2026-03-03 12:10:57 -05:00
Vitor Pamplona
cd6cdf7add Switch Surface use on KeyBackup to Scaffod 2026-03-03 12:10:45 -05:00
Vitor Pamplona
edb73b3556 Updates AGP 2026-03-03 11:52:03 -05:00
Vitor Pamplona
5d8452af06 lint 2026-03-03 10:54:44 -05:00
Vitor Pamplona
386afdbe8d Merge pull request #1751 from vitorpamplona/claude/improve-zapcustomdialog-ui-2Fzic
Redesign Zap Custom Dialog with improved UI/UX
2026-03-03 10:54:05 -05:00
Vitor Pamplona
7695416ca0 Merge pull request #1750 from vitorpamplona/claude/convert-dialog-to-route-9lrlG
Convert UpdateZapAmountDialog to full-screen navigation route
2026-03-03 10:51:07 -05:00
Vitor Pamplona
583891a145 Merge branch 'main' into claude/convert-dialog-to-route-9lrlG 2026-03-03 10:51:00 -05:00
Vitor Pamplona
be0e53fa08 Merge pull request #1748 from vitorpamplona/claude/convert-backup-dialog-route-ngbkf
Convert AccountBackupDialog to full-screen navigation route
2026-03-03 10:48:20 -05:00
Vitor Pamplona
1fb4860d68 Merge pull request #1749 from vitorpamplona/claude/convert-dialog-to-route-7Co1n
Convert UpdateReactionTypeDialog to screen-based navigation
2026-03-03 10:48:05 -05:00
Claude
6f80232c32 feat: improve ZapCustomDialog UI with chips and cleaner layout
- Add dialog title "Send Zap" with close button in header
- Add preset amount SuggestionChips from user's saved zap amounts
- Replace TextSpinner dropdown with FilterChip row for zap type selection
- Make amount field full-width with "sats" suffix
- Move ZapButton to bottom as full-width call-to-action
- Add imePadding for keyboard avoidance
- Add RoundedCornerShape(16dp) to dialog Surface
- Add HorizontalDivider separating header from content
- Change message keyboard capitalization to Sentences
- Add send_zap string resource

https://claude.ai/code/session_017gXfNSaqGshRth2x5tdTKz
2026-03-03 15:46:19 +00:00
Claude
9910bd0dfa feat: convert AccountBackupDialog to a navigation Route
Replaces the local dialog state in AllSettingsScreen with a proper
Route.AccountBackup, presented as a bottom-slide composable.

- Add `Route.AccountBackup` to Routes.kt (sealed class + getRouteWithArguments)
- Rename `AccountBackupDialog` → `AccountBackupScreen` accepting `INav` instead of `onClose`; remove Dialog wrapper
- Register `composableFromBottom<Route.AccountBackup>` in AppNavigation
- Update AllSettingsScreen to navigate to Route.AccountBackup instead of managing local dialog state

https://claude.ai/code/session_01KRRANB1b7wV7mpxEvpfjc7
2026-03-03 15:43:18 +00:00
Claude
fb066b3b6f feat: convert UpdateReactionTypeDialog to a route
Replace the dialog-based UpdateReactionTypeDialog with a
navigation route (Route.UpdateReactionType) that slides up
from the bottom. UpdateReactionTypeScreen replaces the Dialog
wrapper with a plain Scaffold. Call sites in AllSettingsScreen
and ReactionsRow now navigate to the route instead of managing
local dialog state.

https://claude.ai/code/session_01TSjuYdBADTRXwT4w8Yx1vU
2026-03-03 15:42:51 +00:00
Claude
c3a0141a47 feat: convert UpdateZapAmountDialog to Route
- Add Route.UpdateZapAmount to Routes.kt with optional nip47 parameter
- Create UpdateZapAmountScreen.kt as a Scaffold-based screen using SavingTopBar
- Register composableFromBottomArgs<Route.UpdateZapAmount> in AppNavigation.kt
- Replace dialog state in AllSettingsScreen with nav.nav(Route.UpdateZapAmount())
- Replace dialog state in ReactionsRow with nav.nav(Route.UpdateZapAmount())
- Remove UpdateZapAmountDialog composables, keeping UpdateZapAmountContent and authenticate helpers

https://claude.ai/code/session_01N95sLM14khkJupTfvJcoKq
2026-03-03 15:42:27 +00:00
nrobi144
afe126f393 feat(desktop): add single-pane/deck toggle with smart column resizing
- Add LayoutMode enum (SINGLE_PANE/DECK) persisted in DesktopPreferences
- SinglePaneLayout: NavigationRail + single content area reusing deck routing
- View menu "Deck Layout" toggle with Cmd+Shift+D shortcut
- Column resize: redistribute width on add/remove, double-click header to expand
- Constrain drag resize to window bounds via BoxWithConstraints
- FlowRow headers in FeedScreen/ReadsScreen to wrap on narrow columns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:38:32 +02:00
Vitor Pamplona
3c32dbc701 lint 2026-03-03 10:23:27 -05:00
Vitor Pamplona
2b80ab52b7 Merge pull request #1747 from vitorpamplona/claude/nip39-kind-10011-support-EACQk
Implement NIP-39 External Identities (kind 10011) event support
2026-03-03 10:01:52 -05:00
Claude
79b06d35b9 feat: add NIP-39 kind 10011 (ExternalIdentitiesEvent) support
- Add ExternalIdentitiesEvent (kind 10011) to quartz/nip39ExtIdentities
  with createNew/updateFromPast factory methods and identityClaims()
- Generalize TagArrayBuilderExt to work with any Event type (not just
  MetadataEvent) so claims/twitterClaim etc. work with both kinds
- Extract replaceClaims logic to List<IdentityClaimTag>.replaceClaims()
  shared extension used by both MetadataEvent and ExternalIdentitiesEvent
- Register ExternalIdentitiesEvent in EventFactory (kind 10011)
- Add consume(ExternalIdentitiesEvent) to LocalCache and dispatch in
  justConsumeInnerInner
- Add ExternalIdentitiesEvent.KIND to UserMetadataForKeyKinds relay filter
  so kind 10011 is fetched alongside kind 0 when loading user profiles
- Add UserExternalIdentitiesViewModel that observes the kind 10011
  AddressableNote for a user and exposes List<IdentityClaimTag> as a Flow
- Thread UserExternalIdentitiesViewModel through ProfileScreen →
  RenderScreen → ProfileHeader → DrawAdditionalInfo
- DrawAdditionalInfo now shows kind 10011 identities, falling back to
  kind 0 for backwards compatibility
- UserMetadataState: add sendNewUserIdentities() for kind 10011 and
  remove identity claim params from sendNewUserMetadata() (kind 0)
- NewUserMetadataViewModel: load identities from kind 10011 first
  (fallback to kind 0), save identities to kind 10011 separately

https://claude.ai/code/session_017iU6ZdRu5qRXPCeDztVeeV
2026-03-03 13:47:27 +00:00
Vitor Pamplona
a005ec5354 Merge pull request #1746 from davotoula/cleaner-code
Cleaner code
2026-03-03 06:46:34 -05:00
Vitor Pamplona
d2ddb9ded5 Merge pull request #1745 from davotoula/defensive-backup-rules
defensive backup rules
2026-03-03 06:45:28 -05:00
davotoula
169416b548 Remove unnecessary suspend modifier from sentToTop() and simplify getNoteIfExists call 2026-03-03 10:06:32 +00:00
davotoula
2d42556b03 Functions shouldn't be empty 2026-03-03 10:03:01 +00:00
davotoula
b1a1ccc145 Define a constants instead of duplicating String literals 2026-03-03 09:54:54 +00:00
davotoula
b3911fedd9 defensive backup rules 2026-03-03 09:42:43 +00:00
Vitor Pamplona
0370e51e57 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  feat: add NIP-C0 code snippet support (kind:1337)
2026-03-02 19:27:27 -05:00
Vitor Pamplona
88f5843e08 A bit more of a refactoring 2026-03-02 19:25:49 -05:00
Vitor Pamplona
1c3e58303d Merge pull request #1744 from vitorpamplona/claude/add-c0-protocol-support-GAos6
feat: add NIP-C0 code snippet support (kind:1337)
2026-03-02 19:17:53 -05:00
Claude
8c12d4d692 feat: add NIP-C0 code snippet support (kind:1337)
Adds full protocol support for NIP-C0 code snippet events following the
same structure used for NIP-88 polls.

Quartz (quartz/nipC0CodeSnippets/):
- CodeSnippetEvent (kind:1337) with accessors for all optional metadata
- TagArrayExt: parse language, extension, name, description, runtime,
  license, deps (repeatable), and repo from tag arrays
- TagArrayBuilderExt: typed builder functions for CodeSnippetEvent
- Tag classes: LanguageTag (l), ExtensionTag, SnippetNameTag (name),
  SnippetDescriptionTag (description), RuntimeTag, LicenseTag, DepTag,
  RepoTag

EventFactory: register kind 1337 → CodeSnippetEvent
LocalCache: add consume(CodeSnippetEvent) and dispatch in when block

https://claude.ai/code/session_013ykLNfJNdwWpXh8ZhZaSXY
2026-03-02 23:48:43 +00:00
Vitor Pamplona
27d1a00196 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-03-02 18:35:34 -05:00
Vitor Pamplona
624cf26c29 Switches the public message API to use quoted posts on replies 2026-03-02 18:22:45 -05:00
Vitor Pamplona
934b1fd5ed First chess refactoring 2026-03-02 18:14:08 -05:00
David Kaspar
fb6d582560 Merge pull request #1743 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-02 22:44:41 +00:00
Vitor Pamplona
df72a6e094 Fix floating action 2026-03-02 17:43:52 -05:00
Vitor Pamplona
d3343c2747 Fix wrong to string usage 2026-03-02 17:43:34 -05:00
Crowdin Bot
f72838139c New Crowdin translations by GitHub Action 2026-03-02 22:35:03 +00:00
Vitor Pamplona
254ef7081b Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  feat: add NIP-66 relay monitor and discovery support to quartz
2026-03-02 17:32:37 -05:00
Vitor Pamplona
c5cbc1887f Moves all event kind names to strings.xml 2026-03-02 17:29:42 -05:00
Vitor Pamplona
15c961670e Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  always show playback speed and position/duration text in white regardless of theme
  New Crowdin translations by GitHub Action
  extract rememberSaveMediaAction to eliminate toast/permission duplication
  restore download toast and permission check fix overflow button background to solid
  Code review fixes: reduce duplicate code
  Code review fixes: Skip-forward clamps against unknown duration Share dropdown is no longer anchored to the overflow button simplify: keep skip amount fixed at 10
  Code review fixes: Redundant Box in SkipButton Unused string resources remember { fadeIn() } pattern
  Code review fixes: skipSeconds duplication + stale duration read pointerInput(Unit) stale capture ShareMediaAction composed unconditionally Overflow menu background opaque in dark theme
  simplify top buttons, move to overflow add skip buttons add gradient overlays and double-tap gesture support
  add skip button component, overflow menu component, gradient overlay
2026-03-02 17:18:40 -05:00
Vitor Pamplona
85330f932c Merge pull request #1742 from vitorpamplona/claude/add-nip66-support-GmCWu
feat: add NIP-66 relay monitor and discovery support to quartz
2026-03-02 17:17:33 -05:00
Vitor Pamplona
7ed0909169 Blocks the size of RelayAuthStatus arrays from growing forever with auth messages 2026-03-02 17:09:52 -05:00
Vitor Pamplona
c3de179124 Merge pull request #1714 from davotoula/media3-updated-player-controls
Media3 updated player controls
2026-03-02 17:08:05 -05:00
davotoula
c335d3acd4 always show playback speed and position/duration text in white regardless of theme 2026-03-02 21:35:32 +00:00
Claude
49e2f2c7c9 feat: add NIP-66 relay monitor and discovery support to quartz
Implements both event kinds from NIP-66 (Relay Monitor Protocol):

- Kind 10166 (RelayMonitorEvent) - replaceable event published by
  monitoring services, declaring check frequency, timeout values, and
  supported check types (open, read, write, auth, nip11, dns, geo)

- Kind 30166 (RelayDiscoveryEvent) - addressable event (keyed by relay
  URL in d-tag) published per monitored relay, carrying RTT metrics,
  network type, relay type, supported NIPs, accepted kinds, access
  requirements, topics, and geohash

Package: nip66RelayMonitor/{monitor,discovery} following existing NIP-88
patterns with tag classes, TagArrayBuilderExt, and TagArrayExt. Both
events registered in EventFactory.

https://claude.ai/code/session_01Wtg56Vudthgfyz8ASTb4p7
2026-03-02 21:20:43 +00:00
Vitor Pamplona
399c9463bf - Removing unnecessary feed definitions
- Adds favorite relays to the top filter
2026-03-02 16:16:23 -05:00
David Kaspar
858d06380e Merge pull request #1741 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-02 21:09:46 +00:00
Crowdin Bot
db380cfe24 New Crowdin translations by GitHub Action 2026-03-02 20:58:37 +00:00
Vitor Pamplona
bb6f052f32 Remove unnecessary params for FeedDefinitions 2026-03-02 15:56:18 -05:00
Vitor Pamplona
333480c29b Moves TopFilter markers from Strings to full objects. 2026-03-02 15:41:44 -05:00
Vitor Pamplona
7fabb6b554 Makes addresses serializable 2026-03-02 15:39:58 -05:00
Vitor Pamplona
88294de509 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  localize hardcoded Navigate string in UserSearchCard
  add user avatar content descriptions in shared components
  add content description to poll deadline date icon
  correct pause button accessibility label
  unrelated code cleanup
  New Crowdin translations by GitHub Action
  New Crowdin translations by GitHub Action
2026-03-02 15:39:44 -05:00
davotoula
3220b4a1a7 extract rememberSaveMediaAction to eliminate toast/permission duplication 2026-03-02 20:07:53 +00:00
davotoula
08f389230e restore download toast and permission check
fix overflow button background to solid
2026-03-02 20:07:43 +00:00
M
69e95159e9 fix: check Namecoin name expiry before resolving
Query current block height via blockchain.headers.subscribe and reject
names that have expired (>= 36000 blocks since last update). Populate
expiresIn field in NameShowResult for downstream use.

Unconfirmed transactions (height <= 0) are treated as active.
2026-03-03 06:56:59 +11:00
M
3f39f96e81 fix: route ElectrumX through Tor proxy, add onion server
- ElectrumxClient accepts injected SocketFactory (lambda) so connections
  respect the user's Tor/proxy settings instead of leaking their IP
  through raw sockets
- Add ProxiedSocketFactory for SOCKS5 proxy routing
- Add .onion ElectrumX server as primary when Tor is enabled, with
  electrumx.testls.space as clearnet fallback
- NamecoinNameResolver accepts serverListProvider lambda for dynamic
  server selection based on current Tor settings
- RoleBasedHttpClientBuilder.socketFactoryForNip05() bridges Amethyst's
  Tor settings to the socket factory
- NamecoinNameService now requires explicit init with proxy-aware client
- Remove dead code: Nip05NamecoinAdapter (never referenced),
  NamecoinVerificationDisplay (never called)
- Update design documentation
2026-03-03 06:28:01 +11:00
David Kaspar
5984292ea5 Merge pull request #1740 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-02 18:03:49 +00:00
Crowdin Bot
408bc86e1d New Crowdin translations by GitHub Action 2026-03-02 18:02:41 +00:00
David Kaspar
95353b5077 Merge pull request #1739 from davotoula/acessibility-issues
Fix minor accessibility issues
2026-03-02 17:59:17 +00:00
davotoula
49ea2a9a5a localize hardcoded Navigate string in UserSearchCard 2026-03-02 16:57:12 +00:00
davotoula
daebea262a add user avatar content descriptions in shared components 2026-03-02 16:56:28 +00:00
davotoula
937f30b48d add content description to poll deadline date icon 2026-03-02 16:54:24 +00:00
davotoula
434a7dcf52 correct pause button accessibility label 2026-03-02 16:53:40 +00:00
davotoula
79e39e7e4f unrelated code cleanup 2026-03-02 16:52:50 +00:00
David Kaspar
d87f62c330 Merge pull request #1738 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-02 16:28:55 +00:00
Crowdin Bot
7b5a7b8e0f New Crowdin translations by GitHub Action 2026-03-02 16:27:51 +00:00
David Kaspar
f207bf14d7 Merge pull request #1737 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-02 16:26:19 +00:00
David Kaspar
381c3144b6 Merge branch 'main' into l10n_crowdin_translations 2026-03-02 16:26:10 +00:00
Vitor Pamplona
5b0f7b5a02 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  update cz, pt, de, sv
  remove unused imports
  remove unused imports
  remove unused imports
2026-03-02 11:25:18 -05:00
Vitor Pamplona
e6000cd3e9 Fixes spacing for replying to text 2026-03-02 11:20:09 -05:00
Vitor Pamplona
379dfedf05 Fixes link colors 2026-03-02 11:19:59 -05:00
Crowdin Bot
35090fcdfb New Crowdin translations by GitHub Action 2026-03-02 16:16:16 +00:00
Vitor Pamplona
f18b87db5d Merge pull request #1736 from davotoula/update-translations
update cz, pt, de, sv
2026-03-02 11:12:23 -05:00
Vitor Pamplona
c9b8cb677b Merge pull request #1735 from davotoula/remove-unused-imports
Remove unused imports
2026-03-02 11:12:09 -05:00
davotoula
0d513dd63e update cz, pt, de, sv 2026-03-02 16:02:40 +00:00
davotoula
59309c2b1d remove unused imports 2026-03-02 15:53:21 +00:00
davotoula
51ba076f26 remove unused imports 2026-03-02 15:52:03 +00:00
davotoula
8846dd7edb remove unused imports 2026-03-02 15:47:30 +00:00
Vitor Pamplona
89579aa35e Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  feat: add RelayFeedScreen for browsing and following relay-specific feeds
2026-03-02 09:31:39 -05:00
Vitor Pamplona
6b1e6ef267 Fixing the "replying to" line appearing in every reply in the threadview 2026-03-02 09:29:11 -05:00
Vitor Pamplona
2c2102f6e6 refactoring 2026-03-02 08:47:28 -05:00
Vitor Pamplona
0072dd3481 Merge pull request #1733 from vitorpamplona/claude/add-relay-filter-screen-aXgh6
Add relay feed screen with filtering and follow/unfollow functionality
2026-03-02 08:34:08 -05:00
M
f6447d2020 feat: wire Namecoin search into SearchBarViewModel
Resolve .bit, d/, and id/ identifiers from the search bar via
ElectrumX blockchain lookups. Typing any Namecoin identifier format
(m@testls.bit, testls.bit, d/testls, id/alice) now queries the
Namecoin blockchain and shows the resolved user at the top of results.

The namecoinResolvedUser flow in SearchBarViewModel detects Namecoin
identifiers, resolves them through NamecoinNameService, and prepends
the result to the standard local cache search results.

Updated docs with search integration details and manual testing guide.
2026-03-02 19:43:24 +11:00
M
d82ace2f63 feat: Namecoin NIP-05 identity verification via ElectrumX
Add censorship-resistant NIP-05 verification using the Namecoin blockchain.
Users can set their nip05 field to a .bit domain (e.g. alice@example.bit)
or direct Namecoin name (d/example, id/alice) and Amethyst will resolve
the pubkey mapping via ElectrumX instead of HTTP.

Resolution uses the standard Electrum protocol (scripthash-based lookups):
- Build canonical name index script matching ElectrumX-NMC indexing
- Query blockchain.scripthash.get_history for the name's tx history
- Parse NAME_UPDATE script from the latest transaction output
- Extract Nostr pubkey from the name's JSON value

Supports both d/ (domain) and id/ (identity) Namecoin namespaces,
simple and extended NIP-05-like value formats with relay hints,
LRU caching with 1h TTL, and self-signed TLS certificates.

New files:
- quartz: ElectrumxClient, NamecoinNameResolver, NamecoinLookupCache
- amethyst: NamecoinNameService, Nip05NamecoinAdapter, NamecoinVerificationDisplay
- docs: namecoin-nip05-design.md
- tests: NamecoinNameResolverTest

Modified:
- Nip05Client: optional namecoinResolver routes .bit to blockchain
- AppModules: wire up resolver

See docs/namecoin-nip05-design.md for full architecture and protocol details.
2026-03-02 13:07:46 +11:00
Claude
3a9622fb1e feat: add RelayFeedScreen for browsing and following relay-specific feeds
- Add RelayFeedScreen similar to HashtagScreen but filtered by relay URL
- Follow/Unfollow buttons add/remove the relay from Favorite Relay list
- New post FAB opens the standard note composer (Route.NewShortNote)
- Add RelayFeedFilter (DAL) to show notes seen from a specific relay
- Add SingleRelayFeedViewModel backed by RelayFeedFilter
- Add datasource layer: RelayFeedFilterAssembler, SubAssembler, and
  FilterPostsByRelay for subscribing to relay-specific note streams
- Add Route.RelayFeed(url) and register in AppNavigation
- Add relayFeed to RelaySubscriptionsCoordinator
- Add FavoriteRelayListState.addRelay/removeRelay for single-relay ops
- Add Account.followFavoriteRelay/unfollowFavoriteRelay
- Add AccountViewModel.followFavoriteRelay/unfollowFavoriteRelay
- Add observeUserIsFollowingRelay composable observer

https://claude.ai/code/session_013gNQjBzeSSmesYow7HfAjW
2026-03-02 01:09:52 +00:00
Vitor Pamplona
050ef73530 adds support for NIP-51 favorite relay lists. 2026-03-01 19:50:07 -05:00
Vitor Pamplona
7fd163d6d4 - Adds reactions and zap settings to the settings screen
- Creates a better distinction between settings for the user and for the whole app.
2026-03-01 19:25:13 -05:00
Vitor Pamplona
69e061e4c4 Fixes Stability of the Update Reaction view model 2026-03-01 19:15:36 -05:00
Vitor Pamplona
7889e3a624 Minimizes subs by merging PayTo with the Metadata sub 2026-03-01 18:59:46 -05:00
Vitor Pamplona
4b82ec4022 Removes unecessary subId names 2026-03-01 17:02:12 -05:00
Vitor Pamplona
0cba056932 formatting 2026-03-01 16:50:17 -05:00
Vitor Pamplona
bb5e09fe8f Fixes duplicated strings 2026-03-01 16:50:09 -05:00
Vitor Pamplona
7f3d01ce0e Merge pull request #1731 from vitorpamplona/claude/move-strings-to-xml-qav0M
Externalize hardcoded strings to string resources
2026-03-01 16:42:27 -05:00
Claude
ba1d61d8d9 feat: move hardcoded UI strings to strings.xml
Replaces hardcoded string literals in composables with string resource
references for proper i18n support. Adds 20 new strings to strings.xml.

Files updated:
- NewUserMetadataScreen: "Social proof" section title
- NoteCompose: "Approve" community post button
- Badge: " and N others" badge awardees text
- Chess: "Decline"/"Accept" challenge buttons
- InteractiveStory: "Restart" story button
- Poll: "Submit" poll button
- ChessGameScreen: "Back", "Relay Settings", "Loading game...",
  "Game Not Found", "This game may have ended...", "Game ID: ...",
  "Go Back"
- ChessLobbyScreen: "Back", "Relay Settings", "New Game",
  "Dismiss", "Connected"
- chess/NewChessGameButton: "New Chess Game" content description
- home/NewChessGameButton: "New Chess Game" content description
- UserSettingsScreen: "Remove <language>" content description

https://claude.ai/code/session_0151CDmy5k2pEWnxhRxet41h
2026-03-01 20:44:43 +00:00
Vitor Pamplona
16088be597 Correct zap colors 2026-03-01 14:40:47 -05:00
Vitor Pamplona
1a2940fba2 Fixes bug that was not recording these EOSEs 2026-03-01 14:40:25 -05:00
Vitor Pamplona
918108118a Adding more event kind names to the relay settings 2026-03-01 14:26:08 -05:00
Vitor Pamplona
f8a0f17384 Making filter stable 2026-03-01 14:22:54 -05:00
Vitor Pamplona
fbdc8791cd Fixes missing empty paragraphs 2026-03-01 12:59:29 -05:00
Vitor Pamplona
cd18fdf79a Merge pull request #1728 from vitorpamplona/claude/add-relay-subscriptions-display-WHbsT
Add active subscriptions and outbox display to relay info screen
2026-03-01 12:48:25 -05:00
Vitor Pamplona
566bdb8a74 Merge pull request #1729 from vitorpamplona/claude/relay-search-suggestions-T1gpP
Add relay URL suggestions to relay input field
2026-03-01 12:47:04 -05:00
Vitor Pamplona
7212e9a879 Adds Relay Url to the Rich Text viewer 2026-03-01 12:45:47 -05:00
Vitor Pamplona
ca6af92411 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  feat: add RelayUrlSegment for ws:// and wss:// relay URIs
  fix: treat multibyte characters as URL terminators in RichTextParser
  Simplify profile edit screen layout
  feat: show "Replying to" label in quoted notes
  fix: suppress parent thread in quoted notes
2026-03-01 11:48:46 -05:00
Vitor Pamplona
2ddcf41201 Merge pull request #1730 from vitorpamplona/claude/add-relay-uri-detection-vOdNR
Add RelayUrlSegment for WebSocket relay URL parsing
2026-03-01 11:48:09 -05:00
Claude
b472be3a1a feat: add relay search suggestions to all Add a Relay fields
When the user types in any "Add a Relay" field in AllRelayListScreen,
the app now searches HintIndexer's relayDB for matching relay URLs
and displays them as clickable suggestions below the text field.

Clicking a suggestion fills the field with the relay URL, matching
the design pattern of ShowUserSuggestionList in ShortNotePostScreen.

New files:
- RelaySuggestionState: debounced Flow-based state filtering relayDB
- ShowRelaySuggestionList: Column of clickable RelayUrlLine rows

https://claude.ai/code/session_0115sYZGVQBZLX7s9oCDcKoi
2026-03-01 16:39:14 +00:00
Claude
304c348736 feat: add RelayUrlSegment for ws:// and wss:// relay URIs
Adds a new RelayUrlSegment type to RichTextParser that detects and
labels relay URIs (ws:// and wss:// schemes) as a distinct segment
type, separate from generic LinkSegments.

https://claude.ai/code/session_01U21sGdEEMLo4hY8dwxNzPr
2026-03-01 16:33:58 +00:00
Claude
ce56f49369 feat: add active relay subscriptions and outbox display to RelayInformationScreen
Adds a new section before Errors and Notices that shows live relay state:
- REQ subscriptions: each subscription card shows its ID and a visual
  breakdown of all active filters using colored chips by kind category
  (notes/social/DM/economic), author counts, tag values, and time bounds
- COUNT subscriptions: same display for count queries
- Pending outbox events: chip list of event IDs awaiting delivery

Filter chips use Material3 color roles to indicate kind categories:
primaryContainer for text events (note/repost/reaction), secondaryContainer
for social/list events, tertiaryContainer for DMs/gift wraps, and
errorContainer for economic events (zap requests/receipts/NWC).

https://claude.ai/code/session_012zzv2j63ibraPNrQx8fGeu
2026-03-01 16:32:41 +00:00
Vitor Pamplona
f5a7461fed Adds an emoji name to the preview to see if we can replicate the alignment when loading the username dynamically 2026-03-01 11:02:14 -05:00
Vitor Pamplona
678a97d01b Merge pull request #1727 from kojira/fix/multibyte-url-terminators
fix: treat multibyte characters as URL terminators in RichTextParser
2026-03-01 11:00:21 -05:00
kojira
fd28d354e7 fix: treat multibyte characters as URL terminators in RichTextParser 2026-03-01 23:54:12 +09:00
Vitor Pamplona
e7a9241833 Merge pull request #1724 from dmnyc/feat/quote-repost-reply-to-label
feat: show "Replying to" label in quoted notes
2026-02-28 20:39:37 -05:00
Vitor Pamplona
e7c5727f75 Merge pull request #1725 from dmnyc/feat/profile-edit-cleanup
Simplify profile edit screen layout
2026-02-28 20:37:39 -05:00
The Daniel
2b36858c12 Simplify profile edit screen layout
Reduce visual clutter by reordering fields, improving labels, and
collapsing rarely-used social proof fields into an expandable section.

- Rename "Name" field to "Username" with @ prefix
- Reorder: Lightning Address, Nostr Address (NIP-05), Website, Pronouns
- Remove deprecated LN URL (lud06) field
- Move social proof fields (Twitter, GitHub, Mastodon) into a
  collapsible "Social proof" section (auto-expands if data exists)
- Clearer labels: "Lightning Address", "Nostr Address (NIP-05)"
2026-02-28 20:10:17 -05:00
The Daniel
eb3b7077fd feat: show "Replying to" label in quoted notes
Follow-up to #1723. When a quoted note is itself a reply, display a
compact "Replying to [username]" label above the content instead of
showing nothing. Uses the event's p-tags to resolve reply targets
directly, matching the Damus-style display.
2026-02-28 18:53:32 -05:00
Vitor Pamplona
5fc7bab885 Merge pull request #1723 from dmnyc/fix/quote-repost-without-parent-display
fix: Suppress parent thread in quoted notes
2026-02-28 17:52:56 -05:00
The Daniel
ace1401a27 fix: suppress parent thread in quoted notes
Adds unPackReply = false to both DisplayFullNote (nostr: bech links)
and DisplayNoteFromTag (tag references) so that quoted notes only
show the quoted post itself without its parent thread context.
2026-02-28 17:44:04 -05:00
Vitor Pamplona
3b0d7c7f09 Reverses claude new rendering scheme because we need to break into new paragraphs before coming to the rendering stage. 2026-02-28 16:34:53 -05:00
Vitor Pamplona
80921b8367 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  refactor: replace FlowRow-based RenderTextParagraph with single Text() composable
2026-02-28 15:41:59 -05:00
Vitor Pamplona
de3f790e0d Merge pull request #1722 from vitorpamplona/claude/refactor-render-text-paragraph-yKHu8
Refactor RichTextViewer to use inline content instead of FlowRow
2026-02-28 15:38:47 -05:00
Claude
956ae21926 refactor: replace FlowRow-based RenderTextParagraph with single Text() composable
RenderTextParagraph previously assembled each word/segment as a separate
composable inside a FlowRow. It now outputs a single Text() composable
with a built AnnotatedString and an InlineTextContent map so images,
videos, and custom emojis are shown inline within the text flow:

- Images and videos (with preview): InlineTextContent with a fixed-size
  media placeholder (300×200 sp) containing ZoomableContentView
- Custom emojis: InlineTextContent with emoji-sized placeholder per
  image glyph via CustomEmoji.assembleAnnotatedList
- Hashtags: LinkAnnotation.Clickable span + optional InlineTextContent
  icon for decorated hashtags (#bitcoin, #nostr, etc.)
- Clickable URLs, emails (mailto:), phones (tel:): LinkAnnotation.Url
  spans with primary-colour styling
- Block-level widgets (URL previews, invoices, LNURL withdraw, Cashu,
  secret emojis, Nostr note/profile references): InlineTextContent with
  a 300×300 sp block placeholder
- User/event tag references without quote preview: InlineTextContent
  with a narrow text-height reference placeholder (150 sp × emojiSize)

The spaceWidth / measureSpaceWidth plumbing is removed from
RenderTextParagraph and its callers (RenderRegular callback no longer
receives Dp). measureSpaceWidth is kept because Highlight.kt still uses
it for its own FlowRow layout.

RenderWordWithPreview, RenderWordWithoutPreview, the private
ZoomableContentView string-URL wrapper, RenderCustomEmoji, and
NoProtocolUrlRenderer are all inlined into RenderTextParagraph and
removed. HashtagIcon.kt @Preview is updated to the new API.

https://claude.ai/code/session_01TtfLvFoR5Qpr2bHBWr5z3f
2026-02-28 20:29:14 +00:00
Vitor Pamplona
8f73a37a53 Fixes wrapping a non-observable value 2026-02-28 12:29:33 -05:00
Vitor Pamplona
e58cbc9c8a Modifier.weight(1f) is stateless. It doesn't need to be keyed on noteEvent 2026-02-28 12:28:23 -05:00
Vitor Pamplona
21ff7c8502 avoids recreating modifiers 2026-02-28 12:21:46 -05:00
Vitor Pamplona
a5edef2db8 No need to recreate the modifier on background changes 2026-02-28 12:20:47 -05:00
Vitor Pamplona
1bff239183 Fixes need for suspending function on uploading 2026-02-28 11:43:31 -05:00
Vitor Pamplona
30ffeaed34 Merge pull request #1720 from vitorpamplona/claude/add-profile-upload-button-RzGz4
feat: add profile picture upload button when user has no picture
2026-02-28 10:53:48 -05:00
Vitor Pamplona
f08297c36c Merge pull request #1718 from vitorpamplona/claude/review-amethyst-issues-VFf0H
fix: render audio MIME types in imeta tags as playable media
2026-02-28 10:52:05 -05:00
Claude
11e86b5805 feat: add profile picture upload button when user has no picture
When a user visits their own profile and has no profile picture set,
show a semi-transparent upload button overlay directly on the profile
picture area. Tapping it opens the gallery picker, uploads the image
to the configured server (NIP-96 or Blossom), and immediately saves
the metadata — without requiring the user to navigate to the edit screen.

- Add uploadPictureAndSave() to NewUserMetadataViewModel: loads current
  metadata, uploads the image, then saves all metadata with the new picture
- Add ProfilePictureWithUploadOverlay composable that wraps ZoomableUserPicture
  and conditionally shows the upload overlay when isMe && no picture
- Add ProfilePictureUploadButton composable for the overlay UI with
  loading state support
- Use ProfilePictureWithUploadOverlay in ProfileHeader instead of
  ZoomableUserPicture directly

https://claude.ai/code/session_01XemJ8Hx9q4mMd1StZNY9CE
2026-02-28 02:06:43 +00:00
Claude
1efcaefe94 fix: render audio MIME types in imeta tags as playable media
Audio URLs with `audio/*` MIME types in NIP-92 imeta tags were silently
dropped (createMediaContent returned null) because only `image/` and
`video/` prefixes were checked. They now map to MediaUrlVideo, which
Media3/ExoPlayer already handles correctly for audio playback.

Also adds common audio extensions (ogg, wav, flac, aac, opus, m4a) to
the extension-based fallback so audio files without imeta tags are also
detected.

Fixes https://github.com/vitorpamplona/amethyst/issues/1701

https://claude.ai/code/session_01YSi64eXRokSy2GzYHoNFES
2026-02-28 00:34:32 +00:00
Vitor Pamplona
3762c8d6c3 Fixes the activity cast 2026-02-27 08:54:30 -05:00
Vitor Pamplona
9675f59040 Fixes lack of import and new kotlin rules 2026-02-27 08:53:49 -05:00
Vitor Pamplona
972c1ac71d Merge pull request #1717 from vitorpamplona/claude/ios-gzip-compression-iHfKC
Implement GZip compression/decompression for iOS using zlib
2026-02-27 07:55:37 -05:00
Claude
b8cda402cb test(quartz/utils): add GZipTest to commonTest
Covers the full expect/actual contract across all platforms (Android,
JVM, iOS):

- round-trip for empty, single-char, simple, unicode, and JSON strings
- repetitive input compresses to a smaller size
- gzip magic number (0x1F 0x8B) present in compressed output
- compressed bytes differ from raw input bytes
- decompress of invalid data throws an exception
- special/control characters survive a round-trip

https://claude.ai/code/session_0125CGfu6aMAnSv6ZzAxFHvV
2026-02-27 01:57:10 +00:00
Claude
5cd05ab008 feat(quartz/ios): implement GZip compress/decompress for iOS
Uses platform.zlib (built-in on all Apple targets) via Kotlin/Native
cinterop to provide the actual GZip implementation:

- compress: deflateInit2 with windowBits=31 (MAX_WBITS+16) for gzip
  format; deflateBound gives a tight upper-bound so a single Z_FINISH
  call is always enough – no output loop required.
- decompress: inflateInit2 with windowBits=47 (MAX_WBITS+32) for
  automatic gzip/zlib format detection; output is collected in
  fixed-size chunks to handle arbitrary decompressed sizes.

https://claude.ai/code/session_0125CGfu6aMAnSv6ZzAxFHvV
2026-02-27 01:18:47 +00:00
Vitor Pamplona
c2868d4100 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  feat: consolidate drawer settings into a single Settings hub screen
  New Crowdin translations by GitHub Action
2026-02-26 20:11:11 -05:00
Vitor Pamplona
027d932b63 Merge pull request #1716 from vitorpamplona/claude/consolidate-drawer-settings-CBYo3
Consolidate settings screens into unified AllSettingsScreen
2026-02-26 20:08:25 -05:00
Claude
e5eb21168b feat: consolidate drawer settings into a single Settings hub screen
Replace six individual drawer items (Relays, Media Servers, Security
Filters, Privacy Options, App Preferences, User Preferences) with a
single "Settings" entry that navigates to a new AllSettingsScreen hub.
The hub lists all six options as clickable rows, each navigating to
their existing screens unchanged.

https://claude.ai/code/session_017Z9x4PNJuMtMQgfYSxTVSU
2026-02-27 01:06:02 +00:00
Vitor Pamplona
fe8dd3fb51 Fixing incorrect cast 2026-02-26 20:05:30 -05:00
Vitor Pamplona
18270f8020 Adds a reqUntilEoseAsFlow extension to the Nostr Client 2026-02-26 19:56:39 -05:00
Vitor Pamplona
1b4044814f Merge pull request #1715 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-02-26 19:49:09 -05:00
Vitor Pamplona
0e659b5b07 Fixes instruction to use event database 2026-02-26 19:42:45 -05:00
Vitor Pamplona
abf977b791 Removes common tags 2026-02-26 19:39:53 -05:00
Crowdin Bot
6197cc6a7a New Crowdin translations by GitHub Action 2026-02-27 00:39:49 +00:00
Vitor Pamplona
23c3edbec8 docs: add quartz-integration skill for using Quartz in external KMP projects
Adds a comprehensive Claude skill covering Gradle setup, key management,
event creation/signing, relay client, subscriptions, NIP builders, and
platform-specific notes. Also updates the legacy quartz-kmp.md to redirect
to the new skill.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 19:37:44 -05:00
Vitor Pamplona
5b156c4530 Basic (ugly) rendering of Zap events when quoted inside of posts. 2026-02-26 19:26:33 -05:00
davotoula
87bf52b59e Code review fixes:
reduce duplicate code
2026-02-26 18:22:52 +00:00
davotoula
3822197830 Code review fixes:
Skip-forward clamps against unknown duration
Share dropdown is no longer anchored to the overflow button
simplify: keep skip amount fixed at 10
2026-02-26 18:22:28 +00:00
davotoula
ce76f8fb27 Code review fixes:
Redundant Box in SkipButton
Unused string resources
remember { fadeIn() } pattern
2026-02-26 18:22:11 +00:00
davotoula
ebbc7b5526 Code review fixes:
skipSeconds duplication + stale duration read
pointerInput(Unit) stale capture
ShareMediaAction composed unconditionally
Overflow menu background opaque in dark theme
2026-02-26 18:21:43 +00:00
davotoula
3e9475087e simplify top buttons, move to overflow
add skip buttons
add gradient overlays and double-tap gesture support
2026-02-26 18:17:13 +00:00
davotoula
eb57811654 add skip button component, overflow menu component, gradient overlay 2026-02-26 18:13:34 +00:00
Vitor Pamplona
591c32ceb3 Solving some more stability issues 2026-02-25 16:53:50 -05:00
Vitor Pamplona
105dfa21fb Resolving some stability issues 2026-02-25 16:45:03 -05:00
Vitor Pamplona
b523505c05 fixes spotless check 2026-02-24 18:00:04 -05:00
Vitor Pamplona
89cf71d555 Merge pull request #1710 from nrobi144/feat/desktop-private-dms
feat(desktop): encrypted private DMs (NIP-04/NIP-17)
2026-02-24 17:50:55 -05:00
Vitor Pamplona
b1fd450c91 Merge branch 'main' into feat/desktop-private-dms 2026-02-24 17:50:48 -05:00
Vitor Pamplona
f0d62a8ea2 Merge pull request #1713 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-02-24 17:40:36 -05:00
Crowdin Bot
80ca7d05ea New Crowdin translations by GitHub Action 2026-02-24 22:31:46 +00:00
Vitor Pamplona
f2410a6921 - Turn video controller creation into a flow, removing most of the little hacks to get the controller to work in the lifecycle
- Uses new Media3 content view frames
- Redesigns video playback interface to remove unused buttons and modernize it.
- Adds our own buttons and indicators for the video playback
- Checks if PiP is supported and active before showing the button.
- Improves click interactions with the image dialog
- Fixes Surface release bugs
- Creates new interface for the Picture in Picture view
- Fixes muting and play buttons on the Picture in Picture
2026-02-24 17:28:41 -05:00
Vitor Pamplona
3942a38f0e Fixes missing Video Decoder for the Gallery 2026-02-24 17:22:18 -05:00
Vitor Pamplona
6c63ef60f3 Adds a filter for multiple addressable kinds but one key 2026-02-24 17:21:57 -05:00
Vitor Pamplona
4e39df2707 Initializes the disk cache on an io thread after 3 seconds. 2026-02-24 17:21:37 -05:00
Vitor Pamplona
4c81ef19df Fixes build.gradle to add dependencies for the Gallery 2026-02-24 17:02:03 -05:00
Vitor Pamplona
bdc708e658 Fixes stability of the feedstate 2026-02-24 17:01:25 -05:00
Vitor Pamplona
5e2a4b2cef - Migrates Gallery to not play videos and simply display a tumbnail when it is a video
- Fixes incorrect aspect ratios of the AutoNonLazyGrid when multiple images exist for a single entry
- Adds Play buttons when the image fails to load so that people click and the post appears.
2026-02-23 19:28:53 -05:00
Vitor Pamplona
6c12476902 Fixes bug when showing multiple versions of replaceable videos in the gallery. 2026-02-23 19:21:58 -05:00
KotlinGeekDev
3817fb04f0 Merge remote-tracking branch 'origin/kmp-completeness' into kmp-completeness 2026-02-23 20:46:09 +01:00
KotlinGeekDev
d48dfce3a5 Foundations(iOS Sourceset): Provide implementation for DigestInstance. Make note about KECCAK256(not supported, until other solution is found). 2026-02-23 19:44:55 +01:00
KotlinGeekDev
fd1b1ae635 Foundations(iOS Sourceset): Duplicate Nip-04 tests for iOS, in order to test the AESCBC implementation used underneath. 2026-02-23 18:05:20 +01:00
KotlinGeekDev
265fef0a93 Foundations(iOS Sourceset): Provide implementation for AESGCM. Duplicate AESGCM tests for iOS. Move dependency to 'cryptography-optimal' make use of all iOS crypto providers. 2026-02-23 17:57:54 +01:00
nrobi144
e7239980c6 feat(desktop): add multi-column deck layout replacing NavigationRail
Replace the 80dp NavigationRail + single content area with a TweetDeck-style
multi-column deck. Default layout: Home Feed + Notifications + Messages.

New deck system:
- DeckColumnType sealed class with 12 column types
- DeckState with StateFlow-based column management
- Per-column navigation stack for in-column drill-down
- Draggable dividers for column resize (300-800dp)
- Column config persisted via Jackson JSON in DesktopPreferences
- 48dp thin sidebar with add-column and settings buttons
- AddColumnDialog for picking column types

Keyboard shortcuts:
- Cmd+T: Add column
- Cmd+W: Close focused column
- Cmd+1-9: Focus column by index
- Cmd+Shift+Left/Right: Move column left/right

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:53:31 +02:00
nrobi144
78aa7962e9 feat(desktop): add DM broadcast status banner and audit fixes
- Show relay confirmation URLs in broadcast banner
- Add relayUrls field to DmBroadcastStatus.Sent
- Wire send progress tracking with per-relay confirmations
- Fix empty chatroom Loading state transition
- Improve NewDmDialog npub metadata resolution
- Harden sendAndWaitForResponse timeout handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 06:53:53 +02:00
nrobi144
35a15f0462 feat(desktop): add DM broadcast banner and fix empty room loading
- Fix ListChangeFeedViewModel stuck on Loading for empty chatrooms by
  adding invalidateData() call in init block
- Add DmBroadcastStatus sealed class and DmBroadcastBanner composable
  in commons for relay send progress display
- Add DmSendTracker using sendAndWaitForResponse for confirmed delivery
- Wire send tracking through DesktopIAccount into ChatPane banner
- Fix audit risks: unsubscribe DMs on account change, wait for relay
  connect before subscribing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:20:04 +02:00
nrobi144
5d983c5d27 fix(desktop): wire gift wrap broadcast for DM reactions
The sendWrappedReaction function was creating NIP-17 gift wraps via
NIP17Factory but never broadcasting them. Added sendGiftWraps() to
IAccount interface with implementations in both Desktop and Android.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:34:33 +02:00
nrobi144
3f5ad8f7be feat(desktop): wire NIP-17 emoji reactions in DM chat
Replace TODO stub with sendWrappedReaction using NIP17Factory.
createReactionWithinGroup creates gift-wrapped reactions for group privacy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 07:34:53 +02:00
nrobi144
9a30b935d2 feat(desktop): wire DM sending, subscriptions, and new DM dialog
- DesktopIAccount: sign + broadcast NIP-04/NIP-17 messages via relay layer
- Main.kt: activate DM subscriptions on login, consume events into cache
- NewDmDialog: user search dialog with SearchBarState + UserSearchCard
- DesktopMessagesScreen: wire Cmd+Shift+N and "+" button to NewDmDialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 07:33:04 +02:00
Vitor Pamplona
2f1eeea77b Merge pull request #1708 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-02-18 15:00:31 -05:00
Crowdin Bot
bae16f0b52 New Crowdin translations by GitHub Action 2026-02-18 19:58:57 +00:00
Vitor Pamplona
fc502d81d4 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  update cz, pt, de, sv
2026-02-18 14:56:21 -05:00
Vitor Pamplona
bcd2bc066b - Removes nip96 and updates Blossom recommendations 2026-02-18 14:56:07 -05:00
Vitor Pamplona
9000a11377 Merge pull request #1707 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-02-18 13:08:20 -05:00
Crowdin Bot
b1dad24393 New Crowdin translations by GitHub Action 2026-02-18 18:05:59 +00:00
David Kaspar
69a7ceef6c Merge pull request #1706 from davotoula/update-translations
update cz, pt, de, sv
2026-02-18 18:03:38 +00:00
Vitor Pamplona
086e2046db Fixes test file loader for iOS 2026-02-18 12:34:55 -05:00
Vitor Pamplona
ee7d20e939 Minor adjustment in class names 2026-02-18 12:25:48 -05:00
Vitor Pamplona
64c8c0edfd Improves the name of the NIP01 Crypto object 2026-02-18 11:29:56 -05:00
Vitor Pamplona
907ff6e844 Moves deterministic signer to the test package since it's only used there. 2026-02-18 11:24:33 -05:00
Vitor Pamplona
cf7b4ed4e3 Fixes warning in chess test cases 2026-02-18 10:49:59 -05:00
Vitor Pamplona
ab6560d47d Fixes Chess's need for clone functions that are not available on iOS 2026-02-18 10:46:06 -05:00
Vitor Pamplona
2c3ca7f906 - Finishes the transition to EventHint objects for building events.
- Removes the "a-tag" dependency on Addressable interface because the `a` can have different names in different objects.
2026-02-18 10:45:50 -05:00
davotoula
2af1dfbcf4 update cz, pt, de, sv 2026-02-18 11:36:20 +00:00
nrobi144
5d81da2486 feat(desktop): add encrypted DMs with split-pane layout (NIP-04/NIP-17)
Full desktop DM implementation with shared components extracted to commons:
- Relay layer: DM relay state, filter construction, subscription coordinator
- Shared: ChatroomFeedFilter, ChatroomFeedViewModel, ChatNewMessageState
- Shared UI: ChatBubbleLayout, ChatMessageCompose, ChatroomHeader, ChatTheme
- Desktop: split-pane (conversation list + chat), keyboard shortcuts, hover reactions
- IAccount extended with signer, sendNip04/17, chatroomList, isAcceptable

Refs: vitorpamplona/amethyst#1704

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:19:46 +02:00
Vitor Pamplona
46b17628ac Adds ditto as search relay 2026-02-17 19:30:04 -05:00
Vitor Pamplona
7bd4698073 Updates Tor 2026-02-17 19:28:53 -05:00
Vitor Pamplona
fa4180e4ac Updates readme with the current list of supported NIPs 2026-02-17 19:19:29 -05:00
Vitor Pamplona
c755908db5 Removes string-concat compiler argument since it is not supported anymore 2026-02-17 19:02:53 -05:00
Vitor Pamplona
862ce9e2c2 Move to AGP 9.0.1 2026-02-17 19:01:25 -05:00
Vitor Pamplona
cf7bdef028 Adds missing actual implementations on iOS 2026-02-17 19:00:24 -05:00
Vitor Pamplona
c4e3d99392 moves chess lib to version catalog 2026-02-17 18:45:35 -05:00
Vitor Pamplona
bb3937edb4 Makes 24h the default end time for polls 2026-02-17 18:41:25 -05:00
Vitor Pamplona
a6ff66b8ae Rounds time formatting 2026-02-17 18:39:17 -05:00
Vitor Pamplona
d6c9462b46 Removing unnecessary class. 2026-02-17 17:09:45 -05:00
Vitor Pamplona
277a311fdc In Compose, a UI element with a height of 0 generally cannot receive pointer events (clicks), even if it visually scales back up later via the graphicsLayer. This commit blocks the progress from going to zero 2026-02-17 16:50:43 -05:00
Vitor Pamplona
46bc0dd750 Fixes poll rendering with images are included in the options 2026-02-17 16:40:46 -05:00
Vitor Pamplona
713e91185b Merge pull request #1702 from nrobi144/feat/chess-pgn-display-878
chess: live chess game implementation with Jester protocol
2026-02-17 16:12:26 -05:00
Vitor Pamplona
b16fd2c4d1 Merge pull request #1705 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-02-17 16:05:45 -05:00
Crowdin Bot
24e4b86065 New Crowdin translations by GitHub Action 2026-02-17 21:04:02 +00:00
Vitor Pamplona
f665bec63d adds basic support for NIP85 Polls 2026-02-17 16:00:54 -05:00
KotlinGeekDev
460be8cbf3 Foundations(iOS Sourceset): Provide implementation for AESCBC. 2026-02-17 01:03:19 +01:00
KotlinGeekDev
2d2ddda2c5 Merge branch 'vitorpamplona:main' into kmp-completeness 2026-02-13 04:20:01 +01:00
Vitor Pamplona
893a318fbd Correctly refreshes the Live bubbles when switching top filters. 2026-02-12 16:20:03 -05:00
Vitor Pamplona
e84e748959 updating libraries 2026-02-12 15:13:07 -05:00
KotlinGeekDev
bb5d785cb3 Enable running tests with resources for the androidHostTest sourceset, specifically HintIndexerTest. Replace non-null assertion with exception throw, to precisely identify test resource errors. 2026-02-12 15:02:02 +01:00
KotlinGeekDev
68c7f5e884 Linter fixes, plus a lint suppression for Buffer.kt. 2026-02-12 14:28:59 +01:00
Vitor Pamplona
e0db24e9d7 Fixes the blinking and multi-broadcasting issues of the track broadcast snackbar 2026-02-11 17:43:55 -05:00
KotlinGeekDev
9caba9dcd4 Merge upstream changes. 2026-02-11 14:26:36 +01:00
KotlinGeekDev
3f9614dd8f Merge branch 'main' of https://github.com/vitorpamplona/amethyst into upstream-main
# Conflicts:
#	gradle/libs.versions.toml
2026-02-11 13:57:01 +01:00
nrobi144
5f15f0c3b1 fix: address Copilot review comments
- ChessPosition: include all fields in equals/hashCode
- AcceptedGamesRegistry: add thread safety with synchronized
- ChessEventBroadcaster: use valid filter for connection trigger
- ChessEventBroadcaster: fix misleading relayResults
- Remove println debug logging from ChessSubscription/Broadcaster
- UserProfileScreen: use proper JSON parsing via MetadataEvent

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 16:39:24 +02:00
nrobi144
8d098cf86d feat(chess): focused polling, promotion fix, and game end celebration
- Add focused game mode to ChessPollingDelegate: when viewing a game,
  only that game is polled instead of all active games
- Fix pawn promotion by parsing promotion suffix in publishMove
  (e.g., "e8q" -> "e8" + QUEEN)
- Add game end overlay with victory/defeat/draw celebration
- Remove all debug println statements for production readiness

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 12:14:34 +02:00
nrobi144
3c89c442bc feat(chess): add slim ViewModels and shared broadcast banner (Steps 7-10)
Step 7: ChessViewModelNew.kt - Slim Android ViewModel (~130 lines)
- Delegates all business logic to ChessLobbyLogic
- Exposes StateFlows from logic.state
- Platform adapter creation only

Step 8: DesktopChessViewModelNew.kt - Slim Desktop ViewModel (~120 lines)
- Same pattern as Android, delegates to ChessLobbyLogic
- UserMetadataCache for profile display
- External CoroutineScope injection

Step 9: ChessBroadcastBanner.kt - Shared Compose banner in commons
- Uses ChessBroadcastStatus from ChessLobbyState
- Works on both Android and Desktop via Compose Multiplatform
- Shows broadcasting progress, sync status, errors

Step 10: UI consumers ready for migration
- New ViewModels use ChessChallenge (enriched display data)
- Shared banner ready to replace Android-only ChessStatusBanner
- Existing screens continue to work with old ViewModel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 12:14:34 +02:00
nrobi144
42b33ca04c feat(chess): add platform adapters and enhance lobby state (steps 5-6)
- Add ChessLobbyState with completedGames, replaceGameState(), moveToCompleted()
- Add challengerAvatarUrl to ChessChallenge
- Add CompletedGame data class for game history

Platform Adapters:
- AndroidChessAdapter: AndroidChessPublisher, AndroidRelayFetcher, AndroidMetadataProvider
- DesktopChessAdapter: DesktopChessPublisher, DesktopRelayFetcher, DesktopMetadataProvider

Shared Infrastructure:
- ChessRelayFetchHelper for one-shot relay queries
- IUserMetadataProvider interface for platform-specific metadata
- ChessFilterBuilder with simple filter methods for fetchers
- ChessSubscriptionController interface for subscription management

Fix ChessSubscription.kt to remove broken dataSources().chess reference
(subscriptions now managed by ChessLobbyLogic)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 12:14:34 +02:00
nrobi144
e0d0e33408 refactor impl 2026-02-10 12:14:05 +02:00
nrobi144
c2f035acc0 docs: update chess implementation status
- Mark ChessViewModel, navigation, relay subscriptions as complete
- Update file structure to reflect KMP commons migration
- Update checklist with completed items
- Revise next steps to focus on FAB and badge display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 12:10:39 +02:00
nrobi144
de2ebb2f7b feat(chess): add relay subscriptions for live game events
- Subscribe to LocalCache.live.newEventBundles for chess events
- Handle incoming opponent moves (LiveChessMoveEvent)
- Handle game acceptance (LiveChessGameAcceptEvent)
- Handle game endings (LiveChessGameEndEvent)
- Track new challenges directed at user (LiveChessGameChallengeEvent)
- Auto-update badge count on relevant events

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 12:10:38 +02:00
nrobi144
4a02c12acd feat(chess): implement ChessViewModel with event publishing
- Add state management for active games, challenges, badges
- Implement createChallenge for new games
- Add acceptChallenge for joining games
- Add publishMove with both simple (from/to) and full (ChessMoveEvent) APIs
- Add resign and offerDraw for game termination
- Wire up to existing ChessGameScreen and Chess.kt UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 12:10:38 +02:00
nrobi144
607cdf0c17 refactor(commons): migrate chess UI to KMP commonMain
- Move chess UI components from src/main/java to src/commonMain/kotlin
- Add consume functions for chess events in LocalCache
- Resolve merge conflicts with main branch KMP migration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 12:10:38 +02:00
nrobi144
63fc5e86f6 fixes 2026-02-10 12:10:38 +02:00
nrobi144
06accf5831 full chess implementation 2026-02-10 11:58:00 +02:00
nrobi144
18c8156ab2 integrate chess events 2026-02-10 11:56:43 +02:00
nrobi144
19c36c2979 initial nip64 implementation 2026-02-10 11:54:36 +02:00
Vitor Pamplona
a70101f5e5 cleans up broadcasting tracker 2026-02-09 20:28:35 -05:00
KotlinGeekDev
295f980584 Merge remote-tracking branch 'origin/kmp-completeness' into kmp-completeness 2026-02-10 00:23:37 +01:00
Vitor Pamplona
89b058fff3 Adds yabu and nostr1 profile relays 2026-02-09 17:54:04 -05:00
Vitor Pamplona
97251b345a Correcting relay url 2026-02-09 17:41:27 -05:00
Vitor Pamplona
9d32c314d1 Switching nostr.band to antiprimal on app defaults. 2026-02-09 16:27:19 -05:00
Vitor Pamplona
bc9adc8352 Breaks the search filter down into two subscriptions to prioritize Metadata without punishing content. 2026-02-09 16:12:11 -05:00
KotlinGeekDev
dcb10b249a Foundations(iOS Sourceset): Provide implementation for MacInstance(using external library). Bring some tests into commonTest to make sure it works. 2026-02-09 21:57:54 +01:00
KotlinGeekDev
aa4d586025 Linter fixes for GZip.ios.kt 2026-02-09 20:42:18 +01:00
KotlinGeekDev
656af17b1c Implement fix for URLs with absolute paths. 2026-02-09 20:41:18 +01:00
Vitor Pamplona
ae84a64b71 Fixes reformatting bug 2026-02-09 14:09:07 -05:00
Vitor Pamplona
eb66182211 - Updates several libraries
- Reformats code to the newest Klint
- Fixes isEmpty bug on Filters
2026-02-09 14:06:07 -05:00
Vitor Pamplona
710f15f790 - Fixes copyright using KDoc patterns instead of simpler comments
- Moves spotless and git-hooks to hidden folders
2026-02-09 13:06:41 -05:00
Vitor Pamplona
6125394354 Migrates to AGP 9 2026-02-08 16:33:39 -05:00
Vitor Pamplona
f8a232385d Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-02-08 12:05:02 -05:00
Vitor Pamplona
9d8c1f3ffe Avoid dependency of AccountSettings for NwcSignerState 2026-02-08 12:04:51 -05:00
Vitor Pamplona
c319b25077 Merge pull request #1700 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-02-08 11:52:00 -05:00
Crowdin Bot
234ca70c4e New Crowdin translations by GitHub Action 2026-02-08 16:43:29 +00:00
Vitor Pamplona
913b694f78 Clean up on unnecessary methods on Note 2026-02-08 11:35:26 -05:00
Vitor Pamplona
aa4cfdc237 Small refactoring 2026-02-07 18:54:48 -05:00
Vitor Pamplona
393d3b33f1 deletes old test 2026-02-07 18:52:17 -05:00
Vitor Pamplona
f37ab8e656 Moves to LruCache from androidx 2026-02-07 18:25:23 -05:00
Vitor Pamplona
2e64d36f39 Moves Blossom server list to use the ICacheProvider 2026-02-07 18:23:42 -05:00
Vitor Pamplona
571fb6af4b Moves the new observables to commons 2026-02-07 18:20:50 -05:00
Vitor Pamplona
7813d20f8e Increases the limit of Zap downloads for profiles to 1000 2026-02-07 18:20:23 -05:00
Vitor Pamplona
838fb3ed7e Groups zaps in profile by user
Moves author's zap cache to the Profile viewmodel
Redesigns composables to match.
Updates Notifications to not use the old zap cache.
2026-02-07 17:52:26 -05:00
Vitor Pamplona
452d549e43 Fixes the fact that SortedSets can have duplicated objects in parallel adds.
Moves to concurrent sets with a Comparator that checks the object's reference to remove duplications in the set when events from addressables differ
2026-02-07 17:50:39 -05:00
Vitor Pamplona
2e85d1356e Fixes Addressable vs Replaceable bases 2026-02-07 17:48:37 -05:00
Vitor Pamplona
93a17366a7 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  Add SKILL.md for AI agent customization
2026-02-07 14:29:35 -05:00
Vitor Pamplona
ea34125fad Moves relays used by each user cache to the new architecture 2026-02-07 14:29:03 -05:00
Vitor Pamplona
03e8316db4 Moves lud06 to lud16 mapping to Quartz 2026-02-07 12:29:13 -05:00
Vitor Pamplona
44b635c9e4 Merge pull request #1699 from CentauriAgent/add-agent-skill
Add SKILL.md for AI agent customization
2026-02-07 12:00:18 -05:00
Centauri
9c9a4f2e7d Add SKILL.md for AI agent customization
This file provides instructions for AI agents to fork, customize,
and build branded versions of Amethyst.

Includes:
- Prerequisites and environment setup
- Step-by-step build workflow
- Common customizations (app name, package ID, icons, client tags)
- Troubleshooting guide
- Distribution options
2026-02-07 11:17:57 -05:00
Vitor Pamplona
4e39a531b7 Moves metadata methods from User to UserCache object 2026-02-07 10:18:13 -05:00
Vitor Pamplona
823624f8f2 adds a longer crop for npubs so that we can see vanity keys better when they don't have nip05s or statuses 2026-02-07 10:17:37 -05:00
Vitor Pamplona
bab881e75d Moves status to a user property to avoid searching on scrolling 2026-02-06 18:15:12 -05:00
Vitor Pamplona
ec629ff081 Migrates contact list management to addressable notes
Creates new observable flows for LocalCache.
2026-02-06 16:12:11 -05:00
Vitor Pamplona
130a83f0b9 Fixes stability of Profile viewmodels 2026-02-06 15:54:08 -05:00
Vitor Pamplona
e97a30fcb1 Migrates to use "title" instead of "name" tags for NIP-51 lists. 2026-02-05 18:20:04 -05:00
Vitor Pamplona
4ced055035 Switches the metadata relay for the relay being used with the most amount of posts to be the relayhint 2026-02-04 17:38:01 -05:00
Vitor Pamplona
a67fe859e8 Merge pull request #1697 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-02-04 16:45:42 -05:00
Crowdin Bot
5de551078f New Crowdin translations by GitHub Action 2026-02-04 21:41:27 +00:00
Vitor Pamplona
f89833cc72 Fix fdroid build 2026-02-04 16:37:29 -05:00
Vitor Pamplona
0225fde2a5 Fixes NIP05 username on Profiles 2026-02-04 16:25:40 -05:00
Vitor Pamplona
e6f7a543cd Migrates Desktop App to the new metadata cache. 2026-02-04 15:38:16 -05:00
Vitor Pamplona
d2f77ed521 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst: (48 commits)
  Use accountViewModel.viewModelScope instead of rememberCoroutineScope() to allow download/share to finish even when controls auto-close.
  Add content parameter to allow sharing of video from video player
  New Crowdin translations by GitHub Action
  - use a valid hex key - check the value of the tags
  Fixed NullPointerException when filter contain tags
  Fix assertEquals order
  fix Jackson deserialization for empty Filters and add regression test
  New Crowdin translations by GitHub Action
  Consume File.delete() return values Extract duplicated "https://" literal into a private const val HTTPS_PREFIX
  New Crowdin translations by GitHub Action
  New Crowdin translations by GitHub Action
  New Crowdin translations by GitHub Action
  Load and save payment targets
  refactor(commons): migrate from Moko to Compose Multiplatform Resources
  feat(broadcast): Enhance retry, multi-broadcast UI, and post tracking (#1682)
  wip: Add BroadcastTracker to AccountViewModel (#1682)
  feat(broadcast): Add transparent event broadcasting feedback (#1682)
  Enabled the reactions expand control for zapraisers even when there are no zaps
  New Crowdin translations by GitHub Action
  refactor to reduce complexity encodePcmToAac
  ...

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedState.kt
2026-02-04 14:35:35 -05:00
Vitor Pamplona
7bc7265757 Refactors the old NIP-05 code on Quartz
New Caching system for User metadata
New Caching system for NIP-05 verifications
2026-02-04 14:31:30 -05:00
KotlinGeekDev
1f94602404 Merge branch 'vitorpamplona:main' into kmp-completeness 2026-02-02 16:43:30 +01:00
Vitor Pamplona
8085d3ead6 Merge pull request #1695 from davotoula/share-videos-from-video-view
Share videos from video view
2026-01-30 17:38:55 -05:00
davotoula
f7b815bedb Use accountViewModel.viewModelScope instead of rememberCoroutineScope() to allow download/share to finish even when controls auto-close. 2026-01-30 22:30:56 +01:00
davotoula
1cd8eeda4e Add content parameter to allow sharing of video from video player 2026-01-30 21:49:30 +01:00
David Kaspar
96ff0e59f6 Merge pull request #1694 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-30 14:45:52 +00:00
Crowdin Bot
863f93a621 New Crowdin translations by GitHub Action 2026-01-30 13:48:47 +00:00
David Kaspar
d38450424b Merge pull request #1693 from greenart7c3/main
fix crash in Jackson deserialization for empty Filters
2026-01-30 13:46:52 +00:00
greenart7c3
577b4c0e0a - use a valid hex key
- check the value of the tags
2026-01-30 09:38:09 -03:00
greenart7c3
f2d7b115d9 Fixed NullPointerException when filter contain tags 2026-01-30 09:00:52 -03:00
greenart7c3
8bfd670f8b Fix assertEquals order 2026-01-30 08:50:09 -03:00
greenart7c3
247e30beed fix Jackson deserialization for empty Filters and add regression test 2026-01-30 07:31:38 -03:00
David Kaspar
85e9984153 Merge pull request #1692 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-29 07:46:29 +00:00
Crowdin Bot
781da12475 New Crowdin translations by GitHub Action 2026-01-29 07:23:27 +00:00
David Kaspar
0f286f68c8 Merge pull request #1691 from davotoula/sonar-fixes
chore: sonar fixes
2026-01-29 07:21:57 +00:00
davotoula
7793d2e2a3 Consume File.delete() return values
Extract duplicated "https://" literal into a private const val HTTPS_PREFIX
2026-01-28 23:02:38 +01:00
KotlinGeekDev
b965ff1cc6 Merge remote-tracking branch 'origin/kmp-completeness' into kmp-completeness 2026-01-28 15:09:57 +01:00
KotlinGeekDev
3ee600ea28 Merge branch 'vitorpamplona:main' into kmp-completeness 2026-01-28 00:09:19 +01:00
KotlinGeekDev
506996b382 Foundations(iOS Sourceset): Provide implementation for GZip compression/decompression. Some small fixes in URLs.ios.kt . 2026-01-27 20:52:43 +01:00
KotlinGeekDev
3f9f496c8b Foundations(iOS Sourceset): Provide implementation for UnicodeNormalizer. 2026-01-27 17:14:32 +01:00
KotlinGeekDev
4e2f779b54 Foundations(iOS Sourceset): Provide implementation for UrlEncoder. 2026-01-27 16:31:04 +01:00
KotlinGeekDev
fb5288d5f1 Foundations(iOS Sourceset): Provide implementation for makeAbsoluteIfRelativeUrl() in ServerInfoParser.ios.kt 2026-01-26 20:08:44 +01:00
KotlinGeekDev
94a5237eaa Foundations(iOS Sourceset): Provide implementation for fastFindURLs(). Make a test for it. 2026-01-26 16:56:11 +01:00
Vitor Pamplona
53d0aa8e42 Merge pull request #1689 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-26 10:02:02 -05:00
Crowdin Bot
f2c27dbdc8 New Crowdin translations by GitHub Action 2026-01-26 15:00:22 +00:00
Vitor Pamplona
e52817f8e9 Merge pull request #1688 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-26 09:58:01 -05:00
Vitor Pamplona
596b8aafba Merge pull request #1683 from nrobi144/feat/1682-transparent-broadcast-feedback
add transparent event broadcasting feedback
2026-01-26 09:57:41 -05:00
Crowdin Bot
6fff7dc17b New Crowdin translations by GitHub Action 2026-01-26 14:55:12 +00:00
Vitor Pamplona
c3f09987aa Merge pull request #1676 from nrobi144/nrobi144/phase-2A
Desktop: Basic Search + Bookmarks + Zaps
2026-01-26 09:53:35 -05:00
Vitor Pamplona
671a102c7c Merge pull request #1673 from davotoula/share-videos-to-other-apps
Share videos to other apps (via intent)
2026-01-26 09:47:14 -05:00
Vitor Pamplona
c91bacaa91 Merge pull request #1687 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-26 09:46:11 -05:00
Vitor Pamplona
e8cf5fb60d Merge pull request #1686 from greenart7c3/payto_part2
Payment targets part two
2026-01-26 09:45:54 -05:00
Crowdin Bot
f618741211 New Crowdin translations by GitHub Action 2026-01-26 14:44:00 +00:00
Vitor Pamplona
7a4386da81 Merge pull request #1684 from nrobi144/feat/1675-migrate-moko-to-cmp-resources
refactor(commons): migrate from Moko to Compose Multiplatform Resources
2026-01-26 09:42:12 -05:00
greenart7c3
058cdc782a Load and save payment targets 2026-01-26 11:18:48 -03:00
nrobi144
71e68dc35b refactor(commons): migrate from Moko to Compose Multiplatform Resources
Remove Moko Resources dependency that blocks AGP 9.0 migration.
Migrate to built-in Compose Multiplatform Resources which is compatible
with the new KMP Android plugin.

- Remove moko-resources plugin and libraries from commons module
- Move strings.xml from moko-resources/ to composeResources/values/
- Update imports from SharedRes.strings.* to Res.string.*
- Add compose.components.resources dependency to desktopApp
- Configure resource package as com.vitorpamplona.amethyst.commons.resources

Closes #1675

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:29:55 +02:00
nrobi144
dedfd59d0b feat(broadcast): Enhance retry, multi-broadcast UI, and post tracking (#1682)
- Add Retrying state to RelayResult for in-flight retry tracking
- Implement synced 360° rotating animation for pending/retrying icons
- Refactor BroadcastDetailsSheet for multi-broadcast sections (max 2 expanded)
- Add overflow summary for additional broadcasts beyond 2
- Fix sheet text colors using theme colors for better visibility
- Make sheet fully expandable (skipPartiallyExpanded)
- Add event cache to BroadcastTracker for retry support
- Implement retry that updates existing broadcast in-place
- Add tracked broadcasting for posts and quotes via createPostEvent/consumePostEvent
- Fix relay list computation for new posts (use event-based, not empty note)
- Make post editor close immediately, broadcast continues in background
- Style CompletedBroadcastIndicator consistently with BroadcastBanner

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:27:29 +02:00
David Kaspar
8ebba994e8 Merge pull request #1681 from davotoula/feature/display-zapraise
Enabled the reactions expand control for zapraisers even when there are no zaps
2026-01-23 06:35:13 +00:00
nrobi144
c5d9fc3c15 wip: Add BroadcastTracker to AccountViewModel (#1682)
Phase 2 progress - added BroadcastTracker instance to AccountViewModel.
Integration with reaction buttons pending.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 06:55:13 +02:00
nrobi144
3ac8ba669b feat(broadcast): Add transparent event broadcasting feedback (#1682)
Phase 1 - Core infrastructure:
- BroadcastModels: BroadcastEvent, RelayResult, BroadcastStatus data classes
- BroadcastTracker: State management with live progress updates
- BroadcastBanner: Global progress indicator (animated)
- BroadcastSnackbar: Result notifications with actions
- BroadcastDetailsSheet: Relay status detail bottom sheet

Refs: #1682

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 06:50:25 +02:00
davotoula
1e611a3fb5 Enabled the reactions expand control for zapraisers even when there are no zaps 2026-01-22 18:32:37 +01:00
David Kaspar
8bbeb9aadb Merge pull request #1679 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-22 09:28:08 +00:00
Crowdin Bot
5f75d5a48b New Crowdin translations by GitHub Action 2026-01-22 09:25:17 +00:00
David Kaspar
b39a3d1911 Merge pull request #1678 from davotoula/sonar-fixes
Sonar fixes - reduce Cognitive Complexity in VoiceAnonymizer.kt
2026-01-22 09:23:22 +00:00
davotoula
1a736f1c8a refactor to reduce complexity encodePcmToAac 2026-01-21 17:41:54 +01:00
davotoula
b8ecb03ddd refactor to reduce complexity decodeAudioToPcm 2026-01-21 17:41:53 +01:00
nrobi144
ff47080cf1 fix: Resolve API mismatches after merge with upstream
- Fix import paths to use desktop.subscriptions instead of commons.subscriptions
- Update DesktopLocalCache to match ICacheProvider interface changes
- Use correct commons.relayClient paths for ComposeSubscriptionManager
- Add missing justConsumeMyOwnEvent and getOrCreateAddressableNote methods

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 06:23:58 +02:00
nrobi144
d08aed47e8 Merge upstream/main into nrobi144/phase-2A
Resolved import conflicts caused by package reorganization:
- upstream moved classes to amethyst/service/relayClient/*
- upstream moved models to commons/model/*
- kept our desktop additions (zaps, bookmarks, search)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 06:04:13 +02:00
davotoula
80c946be44 rename method to reflect new functionality 2026-01-20 22:42:48 +01:00
davotoula
ac4a1de0cd Block share menu from closing while downloading video 2026-01-20 22:31:14 +01:00
davotoula
d542e4d6a0 change to 2 minutes delay before cleaning up temp shared file 2026-01-20 19:55:19 +01:00
davotoula
6257b6dd80 Merge branch 'main' into share-videos-to-other-apps 2026-01-20 19:35:41 +01:00
David Kaspar
29faa7882d Increase max voice record duration to 600 seconds 2026-01-20 18:27:20 +00:00
David Kaspar
5401a89356 Merge pull request #1677 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-20 18:21:28 +00:00
Crowdin Bot
c801e9ce86 New Crowdin translations by GitHub Action 2026-01-20 13:34:58 +00:00
Vitor Pamplona
f134f815d0 Merge pull request #1672 from davotoula/voice-notes-code-review-fixes
Voice notes enhancements and code review fixes
2026-01-20 08:33:11 -05:00
nrobi144
8155cfee9d update metadata loading 2026-01-20 15:18:11 +02:00
nrobi144
8f6de1d304 zap updates 2026-01-19 16:32:01 +02:00
davotoula
4897aa4d09 Use coroutine instead of Handler for temp file cleanup 2026-01-18 22:51:00 +01:00
davotoula
5450c7670f update translations 2026-01-18 22:31:31 +01:00
davotoula
1a04f16d1a Safer temp video handling
Cleanup deletes the actual shared file (and on error) instead of the old temp path in
Guard against empty response.body
Gate media-type detection so image sharing only checks image headers
Added unit test for rename-failure fallback
2026-01-18 22:28:55 +01:00
davotoula
f2e484b18e URL videos: Downloads video to temp file using streaming (avoids OOM), shows progress indicator, shares via Intent, cleans up after 60 seconds
Local videos: Shares directly without downloading
Video format detection: Supports MP4, WebM, MKV, AVI, MOV based on file magic numbers
2026-01-18 20:09:00 +01:00
KotlinGeekDev
893d363a03 Merge remote-tracking branch 'origin/kmp-completeness' into kmp-completeness 2026-01-16 16:11:23 +01:00
KotlinGeekDev
db536289c4 Make linter happy, I guess. 2026-01-16 16:10:58 +01:00
KotlinGeekDev
f2ac68ccf8 Merge branch 'vitorpamplona:main' into kmp-completeness 2026-01-15 20:53:38 +01:00
davotoula
505a6583ee remove unused imports 2026-01-15 14:20:05 +01:00
davotoula
40d6943439 sonar fixes 2026-01-15 14:12:09 +01:00
davotoula
835fbb90b7 Do something with the "Boolean" value returned by "delete". 2026-01-15 14:00:13 +01:00
davotoula
76a05f7d71 moved re-record button inline and added to VoiceMessagePreview.kt 2026-01-15 13:58:14 +01:00
davotoula
5be1633843 limit voice recording to 180s 2026-01-15 13:58:14 +01:00
davotoula
db75a7c910 preallocate list capacity 2026-01-15 13:58:14 +01:00
davotoula
50b0e8bd17 Moved processingPreset = preset to execute synchronously before launching the coroutine 2026-01-15 13:58:14 +01:00
davotoula
0b233f3a3f use colorScheme.onSurfaceVariant instead of Gray 2026-01-15 13:58:14 +01:00
davotoula
001dce8fef guard for null inputFile.parentFile 2026-01-15 13:58:14 +01:00
davotoula
708c6da9fe add docs 2026-01-15 13:58:14 +01:00
davotoula
c6d437b98e confirmed: <1 results in Higher pitch. Adjusted NEUTRAL to slightly lower pitch 2026-01-15 13:58:14 +01:00
davotoula
5ce4391db4 guard encoder / decoder .stop 2026-01-15 13:58:13 +01:00
Vitor Pamplona
6f62e2c7b1 Merge pull request #1671 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-14 17:58:41 -05:00
Crowdin Bot
208f57830f New Crowdin translations by GitHub Action 2026-01-14 22:56:07 +00:00
Vitor Pamplona
92d4654b20 Removes the memory counter methods because they are not the best way to measure the size in memory of these objects, since we intern strings all the time. 2026-01-14 16:49:11 -05:00
Vitor Pamplona
3d0b0c01b9 Finishes editing quartz event store readme 2026-01-14 15:36:32 -05:00
Vitor Pamplona
adb2f48fb5 Updates event store readme 2026-01-14 15:29:13 -05:00
Vitor Pamplona
54ab70beb7 Fixes https://github.com/vitorpamplona/amethyst/issues/1670 2026-01-14 14:24:57 -05:00
Vitor Pamplona
c41040848c Fixes NPE on null existing event. 2026-01-14 09:30:53 -05:00
Vitor Pamplona
6097a0b5b7 Removes the need for an extra object for the tag array in the custom emoji nip 2026-01-13 17:32:38 -05:00
Vitor Pamplona
288fccd414 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-13 17:13:17 -05:00
Vitor Pamplona
be7865d458 updates keyring and vico 2026-01-13 17:13:03 -05:00
Vitor Pamplona
e0e0990278 Merge pull request #1669 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-13 16:16:43 -05:00
Crowdin Bot
b0210e98f1 New Crowdin translations by GitHub Action 2026-01-13 21:08:51 +00:00
Vitor Pamplona
04ac40fa1a moves test as well 2026-01-13 16:07:03 -05:00
Vitor Pamplona
2ce1e81072 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-13 16:04:15 -05:00
Vitor Pamplona
09d0f4425c Moves some of the stuff that was saved in Commons back to the desktop app because it is only used there 2026-01-13 16:04:02 -05:00
Vitor Pamplona
73fe985a65 Fixes tests 2026-01-13 15:49:08 -05:00
Vitor Pamplona
ba5c86fbba Migrates rich text parser from jvm to commons 2026-01-13 15:31:26 -05:00
Vitor Pamplona
bc55749998 Merge pull request #1668 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-13 15:01:19 -05:00
Crowdin Bot
33c51b9a10 New Crowdin translations by GitHub Action 2026-01-13 20:00:04 +00:00
Vitor Pamplona
42bdd1c831 Emoji State to commons 2026-01-13 14:57:29 -05:00
Vitor Pamplona
7c1053df2d Moves reports to common
Adds support for additional information
2026-01-13 14:37:32 -05:00
Vitor Pamplona
9b7a83796b Moves private chatroom models to commons 2026-01-13 14:33:24 -05:00
Vitor Pamplona
afe66cd55f Moves user actions to commons 2026-01-13 14:31:04 -05:00
Vitor Pamplona
a8dd229fb2 Moves Channels (public chats, ephemeral channels and live streams) Account modules to commons 2026-01-13 14:30:51 -05:00
Vitor Pamplona
6d15e4861c Moves channels to commons and removes the dependency in the cache from Note 2026-01-13 12:22:40 -05:00
Vitor Pamplona
5241a6dfa7 Fixes missing cache after AI PR for the desktop app
Removes dependency on the Cache from User.
Updates interface methods to match.
2026-01-13 11:27:45 -05:00
Vitor Pamplona
2fe0157b2e Merge pull request #1665 from davotoula/voice-distortion-poc
Voice distortion (basic)
2026-01-13 08:13:02 -05:00
davotoula
ec159ff85f add translations 2026-01-12 23:03:52 +01:00
davotoula
f13a9164c2 code review: resource leaks and init guard 2026-01-12 23:03:52 +01:00
davotoula
3287e3c031 add a ±10% random variation to HIGH and DEEP pitch 2026-01-12 23:03:51 +01:00
davotoula
666015b8eb reduce duplication:
New shared controller for state + processing + distorted file cleanup
New shared UI section for anonymization header + preset selector
2026-01-12 23:03:51 +01:00
davotoula
a4af706ebd correct the item where progress spinner happens on 2026-01-12 23:03:51 +01:00
davotoula
6f8533f5e1 added explainer to UI 2026-01-12 23:03:51 +01:00
davotoula
9c0fbbf8a4 correct pitch selection
Add to voice note screen (new note)
2026-01-12 23:03:51 +01:00
davotoula
63d7ba07b9 add voice preset selector UI to VoiceReplyScreen 2026-01-12 23:03:51 +01:00
davotoula
8182268a00 add voice anonymization state to VoiceReplyViewModel 2026-01-12 23:03:51 +01:00
davotoula
8de148e562 add VoiceAnonymizer for audio pitch/formant shifting 2026-01-12 23:03:51 +01:00
davotoula
83d4d3e756 add VoicePreset enum for voice anonymization
Define preset options (None, Deep, High, Neutral)
2026-01-12 23:03:51 +01:00
davotoula
bfd0fc01b9 add TarsosDSP dependency for voice anonymization 2026-01-12 23:03:51 +01:00
David Kaspar
8f1ff084d0 Merge pull request #1664 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-12 21:17:22 +00:00
Crowdin Bot
c585b05879 New Crowdin translations by GitHub Action 2026-01-12 20:32:01 +00:00
Vitor Pamplona
e9210872bd Improves performance by precaching signatures before sorting them
Fixes out of order crash when the same signature came back.
2026-01-12 15:29:33 -05:00
Vitor Pamplona
e561492705 Removes unused aliases 2026-01-12 15:28:33 -05:00
Vitor Pamplona
096f59ffc1 Merge pull request #1663 from nrobi144/phase1-final
Desktop - Threads and Notifications + Empty state updates
2026-01-12 10:00:20 -05:00
Vitor Pamplona
2adde38a9c Merge pull request #1660 from davotoula/1654-voice-reply-to-text-note-as-kind-1
voice reply to text note as kind 1
2026-01-12 09:39:03 -05:00
KotlinGeekDev
a9f6ebde83 Merge branch 'vitorpamplona:main' into kmp-completeness 2026-01-12 15:25:11 +01:00
KotlinGeekDev
f5bb0dbc1d Move cachemap dependency to version catalog. 2026-01-12 15:06:57 +01:00
KotlinGeekDev
7c936cfcac Foundations(iOS Sourceset): Provide implementation for LargeCache, using a CacheMap. 2026-01-12 15:05:50 +01:00
KotlinGeekDev
d90f3f4f98 Foundations(iOS Sourceset): Provide implementation for Rfc3986 on iOS, using the Swift Rfc3986UriBridge. 2026-01-12 14:55:24 +01:00
KotlinGeekDev
b94f8c9083 Foundations(iOS Sourceset): - Fix test resource loading for iOS targets(using Gradle env. variables).
- Bring in the SwiftPackageManager for KMP plugin, to use Swift libraries for iOS implementations(and configure accordingly).
  - Bring in cachemap dependency(for iOS LargeCache implementation).
2026-01-12 14:52:12 +01:00
Róbert Nagy
30f7077ea3 Merge branch 'main' into phase1-final 2026-01-12 07:02:49 +02:00
nrobi144
e1412c1f97 fix: Add proper empty states with EOSE tracking
- NotificationsScreen: Show "No notifications yet" after EOSE instead of infinite loading
- FeedScreen: Show "No notes found" after EOSE for both Global and Following feeds
- ThreadScreen: Show "Note not found" if root note missing after EOSE, "No replies yet" for empty threads

Uses existing EmptyState component from commons. Tracks EOSE (End of Stored Events) from relays to distinguish between "still loading" and "no data".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:52:33 +02:00
nrobi144
ec629ac3fe feat: Extract Card interface and CardFeedState to commons (B1)
- Create commons/ui/notifications/CardFeedState.kt with:
  - Card interface (createdAt, id)
  - CardFeedState sealed class (Loading, Loaded, Empty, FeedError)
  - DefaultCardComparator for sorting

- Update Android imports to use commons Card/CardFeedState:
  - DefaultFeedOrder.kt
  - AccountViewModel.kt
  - CardFeedContentState.kt
  - CardFeedState.kt (now only contains concrete card types)
  - CardFeedView.kt

Part of Phase B (Notifications Feed Extraction) for #1648

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:34:14 +02:00
nrobi144
45207857c6 finish ThreadScreen 2026-01-12 06:23:57 +02:00
David Kaspar
dea0c336ef Merge pull request #1661 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-11 17:54:16 +00:00
davotoula
1e898d57b2 code review fixes:
textHint variable was retrieved but never used
extracted logic for fixing relay hints and author information in tags
made mimeType check logic clearer
2026-01-11 18:34:59 +01:00
Crowdin Bot
b71bb8cd2b New Crowdin translations by GitHub Action 2026-01-11 17:20:41 +00:00
Vitor Pamplona
d9a9cdd374 Fixes lack of update in follow count on the UserProfile page 2026-01-11 12:18:21 -05:00
davotoula
f51d0fb56e code review:
added p-tag for voice reply to kind 1
allow waveform IMeta with a missing mimeType to render as audio
2026-01-11 17:42:05 +01:00
davotoula
70c638ad90 refactor to a shared composable to eliminate waveform code duplication 2026-01-11 10:37:25 +01:00
davotoula
d8093f64b1 Display kind 1 voice replies as audio waveform 2026-01-11 10:37:24 +01:00
davotoula
58b97e0695 Voice reply to VoiceEvent/VoiceReplyEvent (KIND 1222/1244) → Creates VoiceReplyEvent (KIND 1244)
Voice reply to TextNoteEvent (KIND 1) → Creates TextNoteEvent (KIND 1) with audio IMeta attachment and proper reply tags
2026-01-11 10:12:43 +01:00
Vitor Pamplona
d24fe03bdd Avoids crashing when the k tag is a text 2026-01-10 18:34:01 -05:00
Vitor Pamplona
1cf2beb92e Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  remove unused imports
  guard for NaN crash with short recordings
  fix re-record button
  change icon to stop icon
  change from "hold to record" to "click to start, click to stop"
2026-01-10 12:10:40 -05:00
Vitor Pamplona
3e88eed640 Merge pull request #1657 from davotoula/record-voice-on-off-button
Record voice on off button instead of hold to record
2026-01-10 12:09:59 -05:00
Vitor Pamplona
30e5b3d35e removes bringing back all versions of an Addressable on the search screen. 2026-01-10 12:08:42 -05:00
David Kaspar
1882792a9d Merge pull request #1658 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-10 17:07:09 +00:00
Crowdin Bot
a935c7cecf New Crowdin translations by GitHub Action 2026-01-10 17:06:00 +00:00
Vitor Pamplona
cf0f71d04f Fixes: https://github.com/vitorpamplona/amethyst/issues/1621
Renders the fork information at the second line for the Master Note as well.
2026-01-10 12:02:26 -05:00
davotoula
d52906fda0 remove unused imports 2026-01-10 17:59:01 +01:00
davotoula
7b6b0f66a5 guard for NaN crash with short recordings 2026-01-10 17:59:01 +01:00
davotoula
5faa439a8b fix re-record button 2026-01-10 17:37:50 +01:00
davotoula
524180bec9 change icon to stop icon 2026-01-10 17:37:50 +01:00
davotoula
3c12be354e change from "hold to record" to "click to start, click to stop" 2026-01-10 17:37:50 +01:00
Vitor Pamplona
045f2aced9 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  update cz, pt, de, sv
2026-01-10 11:36:22 -05:00
Vitor Pamplona
1ead2983ac Moves to a fork model via interfaces to start creating specific screens for each event type 2026-01-10 11:36:11 -05:00
Vitor Pamplona
1c26ccecc0 Adds support for NipTexts on Amethyst 2026-01-10 11:15:03 -05:00
nrobi144
6aacb8e654 move ThreadFilter to commons 2026-01-09 14:40:30 +02:00
David Kaspar
d8b8650048 Merge pull request #1652 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-09 11:30:39 +00:00
Crowdin Bot
c7b408f79d New Crowdin translations by GitHub Action 2026-01-09 11:29:27 +00:00
David Kaspar
761addb92e Merge pull request #1651 from davotoula/update-translations
update cz, pt, de, sv
2026-01-09 11:28:09 +00:00
davotoula
e874e302e0 update cz, pt, de, sv 2026-01-09 11:17:56 +01:00
nrobi144
a6f49665a7 feat: Extract drawReplyLevel modifier to commons
Move the thread reply level visualization modifier to shared commons
module for use by both Android and Desktop platforms.

- Add ThreadModifiers.kt to commons/ui/thread/
- Update Android ThreadFeedView to use shared modifier
- Add overload for non-State level parameter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 07:14:52 +02:00
nrobi144
9836a47f65 update Android imports 2026-01-09 07:01:36 +02:00
nrobi144
a6d90c319e feat: Add lifecycle-viewmodel KMP dependencies to commons
Adds androidx.lifecycle.viewmodel.compose and lifecycle.runtime.compose
to commons/commonMain as preparation for extracting ViewModels to shared code.

These dependencies have KMP support since 2.8.0 (currently on 2.10.0).

Preparation for Phase A2: Extract Thread ViewModels to commons.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 07:01:36 +02:00
nrobi144
211305c572 A1 extract to commons 2026-01-09 07:01:35 +02:00
Vitor Pamplona
54c1605de7 v1.05.1 2026-01-08 19:01:01 -05:00
Vitor Pamplona
dca9a1da47 Clicking on Drafts now edits the post. 2026-01-08 18:29:00 -05:00
Vitor Pamplona
466ae37b85 Fixes bug of mixing DMs between the logged in users. 2026-01-08 17:30:46 -05:00
Vitor Pamplona
b586971f00 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-08 16:23:16 -05:00
Vitor Pamplona
bb01918120 v1.05.0 2026-01-08 16:21:30 -05:00
Vitor Pamplona
f2bda2e5ec No need to set items directly. This happens later. 2026-01-08 15:23:11 -05:00
Vitor Pamplona
e12d924c59 Merge pull request #1650 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-08 10:57:57 -05:00
Crowdin Bot
a972891059 New Crowdin translations by GitHub Action 2026-01-08 15:03:34 +00:00
Vitor Pamplona
dd259bab33 Separates currentFilters from lastSeenFilters to correctly close subscriptions with relays when ending them on NostrClient. 2026-01-08 10:00:42 -05:00
Vitor Pamplona
19d3c5a10e Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-07 16:42:16 -05:00
Vitor Pamplona
d5ba3ffc04 Simplifies NIP-05 processing 2026-01-07 16:38:51 -05:00
Vitor Pamplona
882cf6ee59 Moving OpenGraph parser to commons 2026-01-07 16:34:15 -05:00
KotlinGeekDev
086e929e32 Make some modifications to CustomBitSet. 2026-01-07 22:27:01 +01:00
Vitor Pamplona
cba4d25424 Moves follows and publish to their own packages 2026-01-07 16:16:01 -05:00
Vitor Pamplona
1176093567 Merge pull request #1647 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-07 15:37:36 -05:00
Crowdin Bot
b5a9b346c6 New Crowdin translations by GitHub Action 2026-01-07 20:35:11 +00:00
Vitor Pamplona
6b8036eac4 Forcing notify relay to be either the NIP-65 or DM relays 2026-01-07 15:33:16 -05:00
Vitor Pamplona
139e9fd3ef Re-adds name to the profile edit page. 2026-01-07 14:52:40 -05:00
Vitor Pamplona
259b476139 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-07 14:43:46 -05:00
Vitor Pamplona
92f07c5615 Remove deprecated field 2026-01-07 14:33:56 -05:00
Vitor Pamplona
dceabbb51e delete duplicated file 2026-01-07 14:33:45 -05:00
Vitor Pamplona
96ae047271 Removes unnecessary file from commons 2026-01-07 14:31:23 -05:00
Vitor Pamplona
ab99dc34f3 Merge pull request #1646 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-07 14:04:01 -05:00
Crowdin Bot
331124c92a New Crowdin translations by GitHub Action 2026-01-07 18:42:55 +00:00
Vitor Pamplona
13aaaeacd8 Fixes imports 2026-01-07 13:40:24 -05:00
Vitor Pamplona
64c237a4e5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-07 13:27:00 -05:00
Vitor Pamplona
60dafd9267 Moves reactions to packages for their nips 2026-01-07 13:26:47 -05:00
Vitor Pamplona
1783fbab59 Separating ListChange 2026-01-07 13:18:17 -05:00
David Kaspar
78d614129a Merge pull request #1645 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-07 17:26:51 +00:00
Crowdin Bot
5051eb6d40 New Crowdin translations by GitHub Action 2026-01-07 17:25:00 +00:00
Vitor Pamplona
cf174c7734 Removes dependency on okhttp for the HTMLParser 2026-01-07 12:22:57 -05:00
Vitor Pamplona
0e59ea570e Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-07 12:09:40 -05:00
Vitor Pamplona
e3832ee19b Moves Filters from Common to Desktop 2026-01-07 12:06:28 -05:00
Vitor Pamplona
5cb0231cf9 Fixes the choice of padding to match what it was on Amethyst 2026-01-07 12:06:11 -05:00
Vitor Pamplona
ed5246e8c3 Merge pull request #1644 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-07 11:57:24 -05:00
Vitor Pamplona
806bc2729a Fixes places for the test and common code 2026-01-07 11:51:45 -05:00
Crowdin Bot
e05aa611eb New Crowdin translations by GitHub Action 2026-01-07 16:43:22 +00:00
Vitor Pamplona
9014424f0d Merge pull request #1643 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-07 11:42:50 -05:00
Crowdin Bot
e84eca2bca New Crowdin translations by GitHub Action 2026-01-07 16:42:01 +00:00
Vitor Pamplona
dd9d7ac0c8 Merge remote-tracking branch 'origin/nrobi144/desktop-phase1'
* origin/nrobi144/desktop-phase1:
  Fixes FilledFilter test
  moves keystore to commons to avoid unnecessary dependencies on Quartz
  Fixes missing tagsAll
2026-01-07 11:41:30 -05:00
Vitor Pamplona
71448d9712 Merge pull request #1642 from nrobi144/nrobi144/desktop-phase1
Desktop Phase 1 - Compose Multiplatform Support
2026-01-07 11:40:46 -05:00
Vitor Pamplona
d3e0cdd135 Fixes FilledFilter test 2026-01-07 11:16:26 -05:00
Vitor Pamplona
ce01e8f70c moves keystore to commons to avoid unnecessary dependencies on Quartz 2026-01-07 11:09:45 -05:00
Vitor Pamplona
7a7ce69709 Fixes missing tagsAll 2026-01-07 10:54:09 -05:00
Vitor Pamplona
9232da7421 Merge branch 'main' into nrobi144/desktop-phase1 2026-01-07 10:43:15 -05:00
Vitor Pamplona
dcbe7df007 Fixes test 2026-01-07 10:07:26 -05:00
Vitor Pamplona
48bd284ef1 Update AccountManager.kt 2026-01-07 09:24:40 -05:00
Vitor Pamplona
41cb143e0d Merge branch 'main' into nrobi144/desktop-phase1 2026-01-07 09:23:57 -05:00
nrobi144
a9141763e1 previous PR fixes 2026-01-07 07:12:00 +02:00
Vitor Pamplona
60ce65470f Merge pull request #1641 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-06 19:02:45 -05:00
Crowdin Bot
deb5da4866 New Crowdin translations by GitHub Action 2026-01-07 00:00:42 +00:00
Vitor Pamplona
b65546487d Adjusts tests to be more flexible when reading NIPs from NIP-11 2026-01-06 18:57:56 -05:00
Vitor Pamplona
109150e693 These logs make everything go slow :( 2026-01-06 18:53:18 -05:00
Vitor Pamplona
93b139da62 Fixes test case 2026-01-06 18:39:51 -05:00
Vitor Pamplona
f33e2fbcfb Disables top bar reappearance to avoid the feed moving when coming back from page to page. 2026-01-06 18:06:24 -05:00
Vitor Pamplona
3c85220c7c fixes package name 2026-01-06 18:05:30 -05:00
Vitor Pamplona
cde8a343cb Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  remove unused imports
2026-01-06 17:39:04 -05:00
Vitor Pamplona
e26fb94933 Improvements on NIP-11
- Support for self, privacy_policy, terms_of_service, grasps
- New UI for the Relay Information Screen
- Improvements to the Debug Message
- Compose-stable objects
- Clickable elements for NIPs, external links, grasps, etc
2026-01-06 17:38:47 -05:00
David Kaspar
83af151c0d Merge pull request #1639 from davotoula/main
remove unused imports
2026-01-06 17:48:02 +00:00
David Kaspar
185979f82c Merge branch 'vitorpamplona:main' into main 2026-01-06 17:11:18 +00:00
Vitor Pamplona
36b50576d9 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-06 10:09:23 -05:00
Vitor Pamplona
fd47391afe Decreases the icon even further 2026-01-06 10:08:29 -05:00
nrobi144
fd83560852 key storage implementation 2026-01-06 17:08:28 +02:00
davotoula
d837fbb4c9 remove unused imports 2026-01-06 15:49:35 +01:00
David Kaspar
2d1a151195 Merge pull request #1638 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-06 14:36:18 +00:00
Crowdin Bot
fe0723889d New Crowdin translations by GitHub Action 2026-01-06 14:34:17 +00:00
Vitor Pamplona
173a40394e Smaller icon to avoid interfering with Trust scores 2026-01-06 09:32:23 -05:00
nrobi144
df9d648988 key storage spotless 2026-01-06 06:50:31 +02:00
nrobi144
4339247815 key storage 2026-01-06 06:49:59 +02:00
Vitor Pamplona
40f9622722 Checks kind size before throwing 2026-01-05 19:41:23 -05:00
Vitor Pamplona
8a5e05c0ac Simple check to make sure events are valid. 2026-01-05 19:26:24 -05:00
Vitor Pamplona
69c53aca06 Improving error logs 2026-01-05 19:26:00 -05:00
Vitor Pamplona
2c4279621d Makes the Kotlin Serialization lenient to avoid unnecessary crashes 2026-01-05 17:18:01 -05:00
Vitor Pamplona
1835c2238e removes unecessary log 2026-01-05 16:01:39 -05:00
Vitor Pamplona
a9df6fcc61 Adds the default bookmark option to be inside the bookmark list screen 2026-01-05 13:38:24 -05:00
Vitor Pamplona
2fc7e505a3 Update stability analiser 2026-01-05 09:36:46 -05:00
nrobi144
2079493fc4 spotless 2026-01-05 13:43:54 +02:00
nrobi144
7d3ac9ec8a add avatars 2026-01-05 13:43:00 +02:00
nrobi144
55d59fcfd2 fix follows not working 2026-01-05 13:12:39 +02:00
nrobi144
115c622dca fix profile not loading 2026-01-05 06:46:19 +02:00
Vitor Pamplona
a151bba3d0 Improves stability for video ui 2026-01-04 16:41:58 -05:00
Vitor Pamplona
fe1e0e46c3 Fixes warnings on test case 2026-01-04 16:38:21 -05:00
Vitor Pamplona
fdc751f9e5 Updates Vico 2026-01-04 16:38:06 -05:00
Vitor Pamplona
eeafeeff17 Moves to new banner by Purple Painer 2026-01-04 14:40:45 -05:00
Vitor Pamplona
0d665c41d2 Merge pull request #1637 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-04 13:42:21 -05:00
Crowdin Bot
69dd0b8e5e New Crowdin translations by GitHub Action 2026-01-04 18:38:38 +00:00
Vitor Pamplona
9f8a28c6d0 Merge pull request #1636 from davotoula/reduce-sonarcube-warnings
Reduce sonarcube warnings
2026-01-04 13:37:26 -05:00
davotoula
59fb175637 refactor VoiceMessagePreview.kt 2026-01-04 12:32:08 +01:00
davotoula
a4925de4a1 cleaner code 2026-01-04 12:27:07 +01:00
davotoula
0d923b5caa optimise imports 2026-01-04 12:19:00 +01:00
Vitor Pamplona
7b13b9e72a Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-03 18:18:50 -05:00
Vitor Pamplona
d7888c57fa Creating additional configuration options and amplifying tests to a combination of all those options 2026-01-03 18:18:36 -05:00
David Kaspar
010a51949c Merge pull request #1635 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-03 21:09:02 +00:00
Crowdin Bot
c878b07458 New Crowdin translations by GitHub Action 2026-01-03 19:54:51 +00:00
Vitor Pamplona
98ccb5e0ae Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-01-03 14:52:08 -05:00
nrobi144
64eec9844b bugfixes 2026-01-03 13:00:02 +02:00
nrobi144
f800e20b05 reuse some filtering logic 2026-01-03 06:15:37 +02:00
Vitor Pamplona
7a34b8480e Fixes count not working for LIMIT queries 2026-01-02 17:22:18 -05:00
Vitor Pamplona
51f9fdd58c Adds a simple query to the count filter 2026-01-02 16:58:59 -05:00
Vitor Pamplona
989c63a717 Expose query plans with the current hasher 2026-01-02 16:57:49 -05:00
Vitor Pamplona
48e791c634 removes logs 2026-01-02 16:17:58 -05:00
David Kaspar
e722f1eb61 Merge pull request #1632 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-02 21:01:45 +00:00
Crowdin Bot
98ba083172 New Crowdin translations by GitHub Action 2026-01-02 19:56:51 +00:00
Vitor Pamplona
307cff87e7 Adds more settings to pick different ways to build the SQL database 2026-01-02 14:54:30 -05:00
nrobi144
36bb89fd36 update desktop with refactored classes 2026-01-02 14:54:24 +02:00
nrobi144
2751855186 extract actions from amethyst 2026-01-02 13:29:55 +02:00
nrobi144
6581fe18a3 initial desktop phase1 fixes 2026-01-02 07:52:36 +02:00
nrobi144
146e4e5533 initial desktop phase1 2026-01-02 07:51:31 +02:00
Vitor Pamplona
7c63d654f2 Inverts the name of the isFilledFilter function 2026-01-01 21:29:20 -05:00
David Kaspar
19d66f37d4 Merge pull request #1631 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-01-01 21:17:15 +00:00
Crowdin Bot
4c9df0bfef New Crowdin translations by GitHub Action 2026-01-01 20:09:47 +00:00
Vitor Pamplona
7b919ba44a added description 2026-01-01 15:07:27 -05:00
Vitor Pamplona
291db1f30b duplicates kinds and pubkey on the tags table to take complex queries under 100 microseconds 2026-01-01 14:57:48 -05:00
Vitor Pamplona
238d4e0004 No need for the fts module anymore 2026-01-01 14:52:11 -05:00
Vitor Pamplona
f6d920366d Fixes some of the tests 2026-01-01 12:53:02 -05:00
Vitor Pamplona
aac880c575 Range queries are not effective as the first argument, switching to kind, key, created_at index, which also almost invalidates the need for the Replaceable idx and is only kept for the unique key consistency 2026-01-01 12:51:43 -05:00
Vitor Pamplona
8affb2564b explains the fk index on tags 2026-01-01 12:49:12 -05:00
Vitor Pamplona
b7c7eabf2a Explains limit index 2026-01-01 12:48:48 -05:00
Vitor Pamplona
f48eba6a8e No need for this index since it will be covered by addressable_idx 2026-01-01 12:48:22 -05:00
Vitor Pamplona
307703cbb7 Explains ID index 2026-01-01 12:47:35 -05:00
Vitor Pamplona
45df452a38 Adds stable to the desktop account manager 2026-01-01 12:37:54 -05:00
Vitor Pamplona
9d67d5d6dc Implements a raw add on to the query builder 2026-01-01 12:37:44 -05:00
Vitor Pamplona
1978c8e17e Faster way of coding equals to null in the sql builder 2026-01-01 12:37:13 -05:00
Vitor Pamplona
507256e0dd Fixes Or builder 2026-01-01 12:36:26 -05:00
Vitor Pamplona
90f38c3a58 adds a isRegular filter for the Kind alias 2026-01-01 12:22:20 -05:00
Vitor Pamplona
6553012372 Uses aliases for the Filter class 2026-01-01 12:22:07 -05:00
nrobi144
5c4db83b40 run spotless 2026-01-01 12:56:52 +02:00
nrobi144
9b330164a7 use typealiases as references to reduce files changed 2026-01-01 12:41:13 +02:00
nrobi144
4bc7e86448 fixes imports 2026-01-01 07:55:31 +02:00
nrobi144
3750c7e26e fixes 2026-01-01 07:32:44 +02:00
nrobi144
eb47c19172 move models to commons 2026-01-01 07:26:39 +02:00
Vitor Pamplona
393366d9cc Adds size of the DB as a function to the lib 2025-12-31 18:41:20 -05:00
Vitor Pamplona
5d7c84d357 Setting higher cache size 2025-12-31 16:53:41 -05:00
Vitor Pamplona
6713c3bd50 Adds performance and flexibility by exposing Raw methods 2025-12-31 13:39:17 -05:00
Vitor Pamplona
4f7972df90 Removes logs 2025-12-31 13:37:02 -05:00
Vitor Pamplona
780ea487aa Adds a test case to simulate reports 2025-12-31 09:42:54 -05:00
Vitor Pamplona
4f1ad11d79 Moves SQLBuilder to its own class 2025-12-31 09:27:33 -05:00
nrobi144
f3b8589fea update claude.md 2025-12-31 13:00:25 +02:00
nrobi144
628f86e1a1 update claude.md 2025-12-31 07:46:50 +02:00
nrobi144
65859a9269 update claude.md 2025-12-31 07:13:27 +02:00
Vitor Pamplona
7b696f5f54 Adds created_at to tags to speed up tag-ordered queries 2025-12-30 16:23:11 -05:00
Vitor Pamplona
5eb9d230e2 Moves current test to a better name of Notifications
Adds a Followers test
2025-12-30 13:00:03 -05:00
Vitor Pamplona
c3d741ebd1 Implements AND queries for tags: https://github.com/nostr-protocol/nips/pull/1365 2025-12-30 12:29:04 -05:00
Vitor Pamplona
df97b7c444 Improves efficiency of the filter class for matching and checking errors 2025-12-30 12:09:05 -05:00
Vitor Pamplona
333285747b Migrates to using indexes instead tagNames to avoid non-ASCII chars reaching the DB 2025-12-30 12:06:22 -05:00
Vitor Pamplona
2a8e7cbe94 Fixes duplicated results 2025-12-30 12:03:09 -05:00
nrobi144
f180fd39e1 finish skills 2025-12-30 15:45:02 +02:00
nrobi144
f654af9d8a initial skills 2025-12-30 15:18:48 +02:00
Vitor Pamplona
258c4e0111 Moves BundledUpdate (which should cease to exist at some point) to Amethyst's module 2025-12-29 19:42:47 -05:00
Vitor Pamplona
54ce0a5a92 Moves mutable time to Amethyst 2025-12-29 19:37:45 -05:00
Vitor Pamplona
c5133e98f8 - Fixes Preview Tag
- Fixes Email Pattern
2025-12-29 18:57:56 -05:00
KotlinGeekDev
bd030e2e96 Merge branch 'vitorpamplona:main' into kmp-completeness 2025-12-30 00:39:11 +01:00
Vitor Pamplona
e9d07b3939 Aligns Android studio with gradle files 2025-12-29 17:37:43 -05:00
Vitor Pamplona
d586c39008 Getting rid of some idea files in the repo 2025-12-29 16:21:19 -05:00
Vitor Pamplona
31d7e135cb Updates kotlin, compose, multiplatform, activity, serialization, media3, mockk, secp256, tor, androidxCamera, stdlib 2025-12-29 14:23:48 -05:00
KotlinGeekDev
5db9384e7f Foundations(iOS Sourceset): Linter fixes for EventHasherSerializer and Secp256k1Instance. 2025-12-29 19:01:22 +01:00
KotlinGeekDev
4feaec5e00 Foundations(iOS Sourceset): Implement functions(some, basic) for BitSet(with passing tests that use it). 2025-12-29 19:00:00 +01:00
Vitor Pamplona
6054c5700d Merge pull request #1625 from nrobi144/feat/desktop-multiplatform
Create desktopApp module
2025-12-29 10:41:58 -05:00
Vitor Pamplona
d2c8991916 Merge pull request #1627 from greenart7c3/payto_part1
Payment targets - part 1
2025-12-29 09:31:45 -05:00
greenart7c3
ff0466872a Create PaymentTargetEvent 2025-12-29 06:26:25 -03:00
Vitor Pamplona
163632249e Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  Quartz Feature Parity Table on Multiplatform
  enhanced recording button
  translations
  bug fix: - Allow reply to reply by using BaseVoiceEvent
  TODO: Reply to a reply fails to upload Verify deletion of temp voice files
  Code review: - Removed DisposableEffect cleanup - Added cancelUpload() to selectRecording()
  TOOD: Move re-record higher up tp avoid google assistant on longpress Reply to a reply fails to upload Verify deletion of temp voice files
  Register Route in AppNavigation Modify ReplyViaVoiceReaction to Navigate
  Add VoiceReply Route Create VoiceReplyViewModel and VoiceReplyScreen Add String Resource(s)
2025-12-28 12:23:14 -05:00
David Kaspar
7351cf1dd3 Merge pull request #1626 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-28 16:24:52 +00:00
Crowdin Bot
d2164118bc New Crowdin translations by GitHub Action 2025-12-28 15:59:11 +00:00
Vitor Pamplona
425d07fa8f Merge pull request #1622 from davotoula/voice-notes-replies
Christmas delivery: Voice notes replies
2025-12-28 10:57:52 -05:00
Vitor Pamplona
fe046477f5 Merge pull request #1624 from DestBro/quartz_feature_parity_table_on_multiplatform
Quartz Feature Parity Table on Multiplatform
2025-12-28 10:40:40 -05:00
Róbert Nagy
edd0ac9b77 Merge branch 'main' into feat/desktop-multiplatform 2025-12-28 15:43:59 +02:00
nrobi144
0dcd480d43 leftover changes 2025-12-28 07:35:48 +02:00
nrobi144
e407e03e90 update readme 2025-12-28 07:21:36 +02:00
nrobi144
b23ff488ed refactor: Polish shared commons components and fix formatting
- Improve RelayConnectionManager with better connection handling
- Refine UI components (LoginCard, ProfileInfoCard, RelayStatusCard)
- Update KeyInputField with improved layout
- Fix formatting in FeedHeader and AppScreen
- Add proper number formatting utilities
- Remove .claude config from version control

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 07:03:57 +02:00
Vitor Pamplona
57a22384b2 Refactoring 2025-12-27 17:57:32 -05:00
Vitor Pamplona
390da16129 Creating a IndexingStrategy interface 2025-12-27 17:55:30 -05:00
Dumitru
71d9f25557 Quartz Feature Parity Table on Multiplatform 2025-12-27 20:41:17 +01:00
Vitor Pamplona
55774b0292 sorting tags before inserting speeds up imports by ~20% 2025-12-27 13:13:46 -05:00
Vitor Pamplona
068ba3b717 Vacuum and Analyze are blocking, so moving to a coroutine. 2025-12-27 12:40:23 -05:00
Vitor Pamplona
d435231f55 Setting cache doesn't seem to do anything :( 2025-12-27 12:38:22 -05:00
Vitor Pamplona
f07070d65e Fixes test name 2025-12-27 12:12:12 -05:00
Vitor Pamplona
f0b6fa622c adds vacuum and analyse utility functions 2025-12-27 12:11:58 -05:00
Vitor Pamplona
f9582bb007 Increases cache size to up to 40MB 2025-12-27 12:11:31 -05:00
nrobi144
201acc8140 feat: Extract shared UI components to commons for Android/Desktop reuse
Extract reusable components from desktopApp to commons module:

Utilities (commonMain):
- NumberFormatters: countToHumanReadable, countToHumanReadableBytes
- PubKeyFormatter: toShortDisplay, toDisplayHexKey for key formatting

Utilities (jvmAndroid):
- TimeAgoFormatter: Human-readable time formatting for Nostr timestamps
- ZapFormatter: BigDecimal amount formatting with G/M/k suffixes

UI Components (commonMain):
- AppScreen enum for shared navigation
- LoadingState, EmptyState, ErrorState with refresh/retry support
- FeedHeader with relay status indicator
- NoteCard for displaying Nostr notes
- ActionButtons (AddButton, RemoveButton)
- RobohashImage for avatar display
- PlaceholderScreens (Search, Messages, Notifications)
- RelayStatusColors and shared color definitions

UI Components (jvmAndroid):
- LoginCard with key input field
- NewKeyWarningCard for new key backup warnings
- KeyInputField and SelectableKeyText
- ProfileInfoCard for account display
- RelayStatusCard for relay management

Core (jvmAndroid):
- AccountManager with login/logout/key generation
- RelayConnectionManager base class
- RelayStatus and DefaultRelays

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 15:12:04 +02:00
nrobi144
e5a064d0c3 feat: Add expect/actual for blurhash and base64Image in commons
- Create PlatformImage expect/actual abstracting Bitmap/BufferedImage
- Move pure blurhash algorithms (Base83, SRGB, CosineCache, BlurHashEncoder) to commonMain
- Rewrite BlurHashDecoder to return PlatformImage instead of Bitmap
- Add PlatformImage.toBlurhash() extension in commonMain
- Add platform-specific Bitmap.toBlurhash() and BufferedImage.toBlurhash()
- Add base64Image toPlatformImage() for both Android and JVM

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 12:48:32 +02:00
nrobi144
131252f19d feat: Convert commons module to Kotlin Multiplatform
- Rewrite build.gradle.kts for KMP with Android + JVM targets
- Restructure source sets: commonMain, jvmAndroid, androidMain, jvmMain
- Replace android.util.LruCache with androidx.collection.LruCache (KMP-ready)
- Replace android.util.Patterns with local regex constants
- Move shared code to commonMain (icons, hashtags, robohash, compose, etc.)
- Move JVM-shared code to jvmAndroid (richtext, base64Image detection)
- Keep Android-specific code in androidMain (blurhash, bitmap handling)
- Remove @Preview annotations from shared code (Android-only feature)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 07:52:45 +02:00
Vitor Pamplona
273d5feb94 - Adds support to delete GiftWraps by p-tag for Deletion and Vanish modules
- Adds Query plans to the test cases
- Reduces the amount of indexes that weren't used.
2025-12-26 15:43:51 -05:00
Vitor Pamplona
4455509e32 Converts pubkey to pubkey hash on vanish events. 2025-12-26 14:33:41 -05:00
Vitor Pamplona
5dcac034ac Uses event_header_row_id as primary key (with index) on event_vanish 2025-12-26 14:32:22 -05:00
Vitor Pamplona
ef5ed755be Uses primary key to reuse rowId instead of building a separate index. 2025-12-26 14:31:18 -05:00
Vitor Pamplona
720e4d2c87 Forces the use of the index on Addressables and Replaceables 2025-12-26 14:29:10 -05:00
KotlinGeekDev
356b49dba9 Foundations(iOS Sourceset): Implement functions(some, basic) for EventHasherSerializer. 2025-12-26 16:17:58 +01:00
KotlinGeekDev
59c18f6b23 Foundations(iOS Sourceset): Implement secp256k1 instance functions. Small fixes in TestResourceLoader. 2025-12-26 15:27:50 +01:00
nrobi144
59eeb1ff0c fix: Use configured relays to initiate subscriptions
The FeedScreen was waiting for connectedRelays to be non-empty before
subscribing, but relays are only added to the NostrClient pool when
subscriptions request them. This created a deadlock where no relays
would ever connect.

Now uses relayStatuses.keys (configured relays) to initiate the
subscription, which triggers the NostrClient to add those relays
to its pool and connect to them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 14:10:02 +02:00
nrobi144
8c8a4ab9e8 feat: Add relay connections, login, and feed view to desktop app
- Add DesktopHttpClient and DesktopRelayConnectionManager for relay WebSocket connections
- Implement AccountManager with key generation and nsec/npub login support
- Create LoginScreen with key import and new key generation UI
- Build FeedScreen with live global feed subscription and note cards
- Add ProfileScreen showing account info and logout
- Update RelaySettingsScreen with connection status and relay management
- Configure JDK 21 toolchain for desktop builds
- Add shared UI analysis documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 14:02:44 +02:00
nrobi144
e441a67e2b fix: Correct compose compiler plugin alias
Use jetbrainsComposeCompiler alias to match version catalog.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 08:24:38 +02:00
nrobi144
82f8bc62a0 feat: Add Compose Multiplatform Desktop support foundation
- Add desktopApp module with JVM entry point and sidebar navigation
- Add Claude specs for AI-assisted development:
  - Agent definitions: nostr-protocol, kotlin-multiplatform, compose-ui, kotlin-coroutines
  - Skills: quartz-kmp conversion, compose-desktop patterns
  - Commands: desktop-run, nip, extract
- Update Gradle configuration with Compose Multiplatform 1.7.1 plugin
- Add coroutines and secp256k1 JVM dependencies to version catalog

Next steps:
- Convert Quartz library to full KMP (expect/actual for crypto)
- Implement relay connections in desktop app
- Share UI components between Android and Desktop

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 08:21:02 +02:00
Vitor Pamplona
eeb0c11c65 No need for this create method of GiftWraps to be suspending 2025-12-24 17:10:34 -05:00
Vitor Pamplona
66ea8ceda0 Fixes replaceable test 2025-12-24 17:10:04 -05:00
Vitor Pamplona
77e56f86a9 Using in memory databases for the test cases 2025-12-24 17:09:33 -05:00
Vitor Pamplona
09eeebba94 Keeps the hasher cache on the seed module 2025-12-24 15:28:59 -05:00
Vitor Pamplona
f1298c3133 Removes event id from the deletion module insert 2025-12-24 15:27:38 -05:00
Vitor Pamplona
826308cbad test addressable filters together with event-id based for deletions and updates 2025-12-24 15:26:43 -05:00
Vitor Pamplona
ff48af5090 Adds a pTagHash utility to the hasher 2025-12-24 15:25:27 -05:00
davotoula
6f8072d1bb enhanced recording button 2025-12-24 18:37:29 +00:00
davotoula
0a0365c266 translations 2025-12-23 16:28:39 +00:00
davotoula
6785f124e9 bug fix:
- Allow reply to reply by using BaseVoiceEvent
2025-12-23 15:04:08 +00:00
davotoula
2422e77dd0 TODO:
Reply to a reply fails to upload
Verify deletion of temp voice files

Code review:
- move re-record button higher
2025-12-23 15:04:08 +00:00
davotoula
64fe4dfac6 Code review:
- Removed DisposableEffect cleanup
- Added cancelUpload() to selectRecording()
2025-12-23 15:04:08 +00:00
davotoula
9b2ef76250 TOOD:
Move re-record higher up tp avoid google assistant on longpress
Reply to a reply fails to upload
Verify deletion of temp voice files

Code review:
- temp file cleanup on disposal
- Made uploads cancellable
- recording must exist; metadata-only no longer enables Send)
- Removed duplication
2025-12-23 15:04:08 +00:00
davotoula
8eee85a05e Register Route in AppNavigation
Modify ReplyViaVoiceReaction to Navigate
2025-12-23 15:04:08 +00:00
davotoula
13e07eb2e8 Add VoiceReply Route
Create VoiceReplyViewModel and VoiceReplyScreen
Add String Resource(s)
2025-12-23 15:04:08 +00:00
Vitor Pamplona
aa2f969be6 Faster event serializer -> sha256 -> id comparison without creating intermediary buffers. 2025-12-23 09:39:30 -05:00
Vitor Pamplona
01ea413afd Faster SQLLite DB with probabilistic hashvalues 2025-12-22 15:18:59 -05:00
Vitor Pamplona
79d6745ee6 Adds faster parsers for Messages and Commands
Unwraps inline runCatching because it seems faster for some reason.
2025-12-22 14:52:02 -05:00
David Kaspar
2de4d49fd8 Merge pull request #1620 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-22 20:30:10 +01:00
Crowdin Bot
94f1e45b9a New Crowdin translations by GitHub Action 2025-12-22 19:20:35 +00:00
Vitor Pamplona
10bba83ee3 Faster Event parsers 2025-12-22 14:18:59 -05:00
Vitor Pamplona
59413f3051 Moves type to the when clause 2025-12-22 12:25:07 -05:00
Vitor Pamplona
23a7e3b535 Fixes kind being a number 2025-12-22 12:23:34 -05:00
Vitor Pamplona
4c1fedddef Makes runCatching inline 2025-12-22 12:17:55 -05:00
Vitor Pamplona
14a6ba586a Fixes command parser 2025-12-22 11:19:25 -05:00
Vitor Pamplona
349625f748 simple refactoring 2025-12-22 11:18:28 -05:00
Vitor Pamplona
e278982e6d Filters correct events 2025-12-22 11:17:43 -05:00
Vitor Pamplona
c7038d695f Adds new tests for rumor and template serializer 2025-12-22 11:07:01 -05:00
Vitor Pamplona
eefca1e3f4 Removes unnecessary tests 2025-12-22 08:11:55 -05:00
Vitor Pamplona
37158a83dc Adds a nextLong random method
Adds unbounded methods because they are faster.
2025-12-20 12:26:16 -05:00
Vitor Pamplona
e4f3a56851 Removes the Statement cache since statements are not thread safe 2025-12-20 12:25:16 -05:00
Vitor Pamplona
8cbc5f1e93 Faster Array Deserializer 2025-12-19 18:57:12 -05:00
Vitor Pamplona
8d5f866ed0 Merge 2025-12-19 17:47:26 -05:00
Vitor Pamplona
62151727f2 Merge pull request #1619 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-19 17:45:47 -05:00
Vitor Pamplona
7e65f1afa7 Fixes the Imodule dependency 2025-12-19 17:45:31 -05:00
Crowdin Bot
f6890e4e27 New Crowdin translations by GitHub Action 2025-12-19 22:34:14 +00:00
Vitor Pamplona
1dbb5f0c83 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-12-19 17:31:37 -05:00
Vitor Pamplona
664f0d0d59 Adds a Benchmark to simulate a real use on storage operators 2025-12-19 17:29:23 -05:00
Vitor Pamplona
8d15f0478f Faster MurMur hash for 64bit results. 2025-12-18 17:30:53 -05:00
Vitor Pamplona
fde5d57b16 Faster parseResult array builder 2025-12-18 12:08:05 -05:00
Vitor Pamplona
12beeea8ee Adds a module interface for the SQL Lite db with drop functions too 2025-12-18 12:01:17 -05:00
Vitor Pamplona
7840bc3318 Uses in memory DB because that's where the performance hits 2025-12-18 11:57:38 -05:00
Vitor Pamplona
d3abc47365 Merge pull request #1618 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-18 09:40:43 -05:00
Crowdin Bot
26124ba10b New Crowdin translations by GitHub Action 2025-12-18 14:32:20 +00:00
Vitor Pamplona
34355121b1 Merge 2025-12-18 09:28:49 -05:00
Vitor Pamplona
5df019e485 adds kotlin ktx to benchmark 2025-12-18 09:21:40 -05:00
Vitor Pamplona
625a12f4e2 Better way to clear the test db 2025-12-16 18:03:50 -05:00
Vitor Pamplona
9069b11036 small refactoring 2025-12-16 18:03:26 -05:00
Vitor Pamplona
0fb64c5ff7 Fixes new generics on the benchmark classes 2025-12-16 16:57:02 -05:00
Vitor Pamplona
2b865ab56d Fixes assertions after using generics on event stores query 2025-12-16 15:24:59 -05:00
Vitor Pamplona
759971d139 Merge pull request #1614 from davotoula/cleaner-code-pt2
log boolean returned by .delete()
2025-12-16 11:15:52 -05:00
davotoula
3373d3a958 log boolean returned by .delete() 2025-12-16 17:08:44 +01:00
Vitor Pamplona
7c0e0c49d3 Merge pull request #1613 from davotoula/cleaner-code
cleaner code
2025-12-16 11:05:26 -05:00
davotoula
aa4270883d correct way to clear collection 2025-12-16 17:00:00 +01:00
David Kaspar
8a5732cc47 Merge pull request #1612 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-16 15:24:36 +01:00
Crowdin Bot
128155714d New Crowdin translations by GitHub Action 2025-12-16 13:37:17 +00:00
Vitor Pamplona
7b453076db Merge pull request #1609 from davotoula/voice-notes
Create KIND 1222 (voice notes) in amethyst
2025-12-16 08:35:43 -05:00
Vitor Pamplona
769e92f49d Merge pull request #1610 from davotoula/get-identity-claim-fixes
minor bug fix: getIdentityClaimIcon
2025-12-16 08:33:58 -05:00
Vitor Pamplona
eef3f42d3f Creating interfaces for multiple EventStores 2025-12-16 08:31:17 -05:00
davotoula
90c8a9df4c use 0 size reference for all identity icons
fix bug (R.string.github vs R.drawable.github)
2025-12-16 12:10:29 +01:00
davotoula
7e1998174c Delete any existing temp file before replacing 2025-12-16 09:36:24 +01:00
Vitor Pamplona
aa9ac205c1 deletes Db after tests 2025-12-15 22:04:20 -05:00
Vitor Pamplona
7cffdd88bb Adds additional hashtag filter test for the db 2025-12-15 17:16:52 -05:00
David Kaspar
44f395815d Merge pull request #1608 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-15 20:33:24 +01:00
davotoula
de927b62bd translate hardcoded strings 2025-12-15 20:11:00 +01:00
davotoula
3dd4249220 code review: abort posting message if voice upload fails 2025-12-15 19:39:24 +01:00
davotoula
a370112bc5 code review: extract startRecording code 2025-12-15 19:33:08 +01:00
davotoula
09f66359ad code review: delete temp voice files in removeVoiceMessage(), cancel() and uploadVoiceMessageSync() 2025-12-15 19:17:24 +01:00
davotoula
0f06838c25 code review: replace layout with box (Calling a androidx.compose.ui.UiComposable composable function where a UI Composable composable was expected ) 2025-12-15 19:09:09 +01:00
davotoula
2a0eeed55f code review: remove unsafe casting to avoid app crash if a non-voice reply context leaks through
lint fixes
2025-12-15 19:03:58 +01:00
davotoula
555fc983f6 Fix rate conditions between subsequent recordings
reduce duplicate code
fix a few coding issues (state management, callback usage, scope management)
2025-12-15 18:35:24 +01:00
davotoula
f3fea8cfb4 Flow: Record, preview, then post or cancel
Added media server selection for upload
Added recording indicator
Added upload progress
Text input disabled for voice messages
2025-12-15 18:05:53 +01:00
davotoula
c0d7afe86c fix potential race condition with previous recorder in method stop 2025-12-15 17:24:45 +01:00
davotoula
8ebd76b77b add a record voice button to new post screen 2025-12-15 17:10:30 +01:00
Vitor Pamplona
8f9340038b Removes advertisement by default. 2025-12-15 08:32:31 -05:00
Crowdin Bot
6e7c02c5b6 New Crowdin translations by GitHub Action 2025-12-15 11:15:10 +00:00
David Kaspar
54e6369d41 Merge pull request #1605 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-15 12:13:50 +01:00
Crowdin Bot
8028c7b2d4 New Crowdin translations by GitHub Action 2025-12-14 17:15:06 +00:00
Vitor Pamplona
17f886d661 Merge pull request #1604 from davotoula/optimise-imports-and-lint
Optimise imports and lint
2025-12-14 12:13:53 -05:00
davotoula
9ef9fb4b09 amethyst lint 2025-12-14 10:20:25 +01:00
davotoula
c2636e6e2f amethyst 2025-12-14 10:17:09 +01:00
davotoula
49517e2264 quartz 2025-12-14 10:12:15 +01:00
David Kaspar
86aa23ef3f Merge pull request #1603 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-14 09:45:45 +01:00
Crowdin Bot
49ec21c243 New Crowdin translations by GitHub Action 2025-12-14 01:45:25 +00:00
Vitor Pamplona
3f26794038 Merge pull request #1602 from DestBro/patch-1
Add iosX64 target
2025-12-13 20:44:10 -05:00
Dumitru
3c06e0ffb1 Fix typo iosX64MainTest to iosX64Test 2025-12-13 23:40:55 +01:00
Dumitru
6d7205625a Add iosX64 target 2025-12-13 23:18:47 +01:00
Vitor Pamplona
83d14bd0e3 Minor adjustments and new benchmarks 2025-12-12 19:51:41 -05:00
Vitor Pamplona
1a63b77a2f Reduce some variables 2025-12-12 19:41:12 -05:00
Vitor Pamplona
286d9c8347 Improve formatting 2025-12-12 18:24:19 -05:00
Vitor Pamplona
c89ae5782e small refactoring 2025-12-12 17:45:18 -05:00
Vitor Pamplona
84eb0a9abb Adds a large array test case 2025-12-12 17:40:38 -05:00
Vitor Pamplona
e1b23e8ea5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-12-12 17:30:57 -05:00
Vitor Pamplona
7a61217eb6 Fixes rounding loop of MurMur128 2025-12-12 17:29:49 -05:00
David Kaspar
50c7dea2bd Merge pull request #1601 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-12 23:29:33 +01:00
Crowdin Bot
ed316fb06b New Crowdin translations by GitHub Action 2025-12-12 21:56:51 +00:00
Vitor Pamplona
95723479d6 Adds MurMur3128bits for x64 2025-12-12 16:54:34 -05:00
Vitor Pamplona
5c01aaf996 Upgrades AGP 2025-12-12 13:25:29 -05:00
Vitor Pamplona
25e9313d17 Reverts interning of all tag sizes due to significant gains in memory usage from large duplicated tags, like r tags with urls 2025-12-11 18:26:27 -05:00
Vitor Pamplona
9bd07468c2 deletes db before tests 2025-12-11 17:13:51 -05:00
Vitor Pamplona
39eed5d85a reformatting 2025-12-11 17:09:03 -05:00
Vitor Pamplona
ee972cff35 reverts db deletions to avoid conflicts with the open file 2025-12-09 18:02:26 -05:00
Vitor Pamplona
b06f80e862 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  Offers support for external transactions Offers support for indexing strategies for tags
2025-12-09 17:28:43 -05:00
Vitor Pamplona
d0efe26ce7 Offers support for external transactions
Offers support for indexing strategies for tags
2025-12-09 17:28:33 -05:00
Vitor Pamplona
26a764a634 Offers support for external transactions
Offers support for indexing strategies for tags
2025-12-09 17:15:22 -05:00
Vitor Pamplona
bcebac529b Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-12-09 11:02:50 -05:00
Vitor Pamplona
fc03d8032e Activates recomposition logging in the application class 2025-12-09 11:02:36 -05:00
Vitor Pamplona
15ee49282d Adds the stability plugin to allow traces in debug 2025-12-09 11:01:03 -05:00
Vitor Pamplona
ac41580ef7 Merge pull request #1599 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-09 10:38:41 -05:00
Crowdin Bot
516c7c93ae New Crowdin translations by GitHub Action 2025-12-09 14:52:11 +00:00
Vitor Pamplona
d268577443 Adds the missing when clauses due to the change in reports. 2025-12-09 09:49:18 -05:00
Vitor Pamplona
d3fb507faa Reducing the need to intern large tags 2025-12-09 09:37:49 -05:00
Vitor Pamplona
f4a0cd5af3 The number of params varies but a good limit seems to be 1000. This breaks the tag insert into many 1000 param inserts 2025-12-09 09:20:17 -05:00
Vitor Pamplona
e44322adaa Speeds up db deletions 2025-12-09 09:05:37 -05:00
Vitor Pamplona
98e818cc60 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-12-08 20:00:12 -05:00
Vitor Pamplona
03f13eeb4b Improved support for random report types coming from AI 2025-12-08 19:59:59 -05:00
David Kaspar
23747a9334 Merge pull request #1597 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-04 22:47:58 +01:00
Crowdin Bot
5dea9e3cef New Crowdin translations by GitHub Action 2025-12-04 19:44:01 +00:00
Vitor Pamplona
0abc631773 - Removes compose bom from Quartz to avoid unnecessary dependencies.
- Removes old datetime dependencies from Quartz
- Moves compose.runtime dependencies to compose.runtime.annotation
- Adds dependency on coroutines directly (instead of through compose runtime)
- Removes old secp256 target dependencies
- Adds Default scope for NostrClient and Relay Authenticator
- Updates readme
2025-12-04 14:41:33 -05:00
Vitor Pamplona
03633c70ca Adds link to quartz versions in the readme 2025-12-03 15:24:32 -05:00
Vitor Pamplona
67eb47adb1 Updates Tor, Compose, Activity 2025-12-03 15:23:47 -05:00
Vitor Pamplona
0cea52ca80 Shows WoT Followers in each user profile 2025-12-02 19:23:13 -05:00
Vitor Pamplona
e7c1925c76 Shows WoT Followers 2025-12-02 19:15:51 -05:00
Vitor Pamplona
bb77163955 Adds WoT scores for public chat and live stream screens 2025-12-02 18:56:07 -05:00
Vitor Pamplona
cc679e3d6b Using banner without the user header for Follow packs because the user is already listed in the bottom of the card. 2025-12-02 18:33:10 -05:00
Vitor Pamplona
1125c4e8f5 Creates a new size for the score tag due to the size of DM Group headers 2025-12-02 18:20:06 -05:00
Vitor Pamplona
aa1aaeea38 Adds scores to the notification cards 2025-12-02 18:07:29 -05:00
Vitor Pamplona
742fa14286 Fixes size of the score in the dm screen 2025-12-02 17:35:41 -05:00
Vitor Pamplona
7f741260d1 Adds a cache system for WoT scores 2025-12-02 16:59:16 -05:00
Vitor Pamplona
9e1f863a67 Refactoring 2025-12-02 16:53:47 -05:00
Vitor Pamplona
72dc9368fb Fixes missing emit for null events 2025-12-02 16:05:23 -05:00
Vitor Pamplona
71dc25099e Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  fix lint warnings
  add custom serializer that accepts both single integers and arrays add tests
  New Crowdin translations by GitHub Action
  updated cz, de, pt, sv
2025-12-02 15:16:39 -05:00
Vitor Pamplona
ab44d5daf5 Removing the generalist approach of ptag-mentions 2025-12-02 15:16:14 -05:00
Vitor Pamplona
e6861ea988 Removes expired addressable events. 2025-12-02 15:13:29 -05:00
Vitor Pamplona
62bf7db348 Adds the about user proerty of Contact Card 2025-12-02 15:07:07 -05:00
Vitor Pamplona
0a76405d90 Merge pull request #1596 from davotoula/bugfix-Unexpected-JSON-token
Bugfix: unexpected json token
2025-12-02 08:55:00 -05:00
davotoula
70b36d64b5 fix lint warnings 2025-12-02 12:57:56 +01:00
davotoula
24bd1d0c97 add custom serializer that accepts both single integers and arrays
add tests
2025-12-02 12:53:51 +01:00
David Kaspar
e41c3284ff Merge pull request #1595 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-02 12:28:52 +01:00
Crowdin Bot
03b646cb33 New Crowdin translations by GitHub Action 2025-12-02 09:28:01 +00:00
David Kaspar
80593e28db Merge pull request #1594 from davotoula/update-translations
updated cz, de, pt, sv
2025-12-02 10:26:32 +01:00
davotoula
9435ba9b14 updated cz, de, pt, sv 2025-12-02 10:15:06 +01:00
Vitor Pamplona
39223f0270 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-12-01 18:19:18 -05:00
Vitor Pamplona
4332f311b3 - Removing user reports flow
- Integrates LocalCache.consume report into the consumeRegularEvent practice
2025-12-01 18:16:37 -05:00
Vitor Pamplona
3d984d8917 Merge pull request #1593 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-12-01 17:52:48 -05:00
Crowdin Bot
30b6fff4d4 New Crowdin translations by GitHub Action 2025-12-01 22:51:23 +00:00
Vitor Pamplona
5062c10849 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  Remove delay, and implement custom moving function that updates the list in one event, rather than two with the previous approach.
  Increase delay time in moveBookmark. Need to find a better alternative to delay.
  String resources.
  Re-introduce display stats for bookmark list, focusing on total bookmarks size per category.
  Support moving articles/posts from private to public and vice versa.
2025-12-01 17:47:01 -05:00
Vitor Pamplona
8b5676bd39 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst: (23 commits)
  Fix default permissions
  Add Bookmark group metadata edit to routes/nav. Make refactors to make use of it.
  Bookmark group metadata edit screen, with VM and string resources. Add methods to LabeledBookmarkListEvent and LabeledBookmarkListsState. Support image urls in LabeledBookmarkList.
  String resources. Remove unneeded Todo.
  Add bookmark list addition/removal to the note dropdown menu.
  Article Bookmark management screen. Add routes and nav entries for both screens.
  Post Bookmark management screen.
  Other Hashtag/Link related refactors.
  Remove HashtagListView and LinksListView due to spec changes, with their entries in BookmarkType.
  Use stringResources. Implement the necessary callbacks in BookmarkGroupScreen. Disable component stats display in BookmarkGroupItem until Link/Hashtag support is implemented.
  Bring ArticleListView up to speed. Fix callback naming mistake in BookmarkGroupItemOptions and refactor usages accordingly.
  First category-based screen: PostListView. Modify NoteCompose to use a different note options menu when in the PostListView.
  Fix some methods in LabeledBookmarkListsState. Add useful methods to LabeledBookmarkList and BookmarkGroupViewModel.
  Introduce BookmarkGroupScreen, with it's associated ViewModel, and the different category-based views.
  Move BookmarkType to an enum instead, for serialization purposes.
  Slight function name refactor in LabeledBookmarkListsState, and inclusion of a method to reference a particular bookmark group. Add route for viewing a bookmark group, and wire UI accordingly.
  Change the UI of BookmarkGroupItem to add buttons for viewing parts of the bookmark group.
  Add support for LabeledBookmarkListEvent in the LocalCache, and EventFactory(to prevent crashes when creating one).
  Add bookmark groups option to side menu. Make them work by hooking them up in Account.
  Build out UI for the list of labeled bookmarks, or bookmark groups, borrowing some components from elsewhere.
  ...
2025-12-01 17:46:14 -05:00
Vitor Pamplona
4c43bc99da Merge pull request #1591 from KotlinGeekDev/bookmark-groups-improvements
Bookmark groups improvements.
2025-12-01 17:46:08 -05:00
Vitor Pamplona
13aa96faef Refactoring report caching. 2025-12-01 17:45:21 -05:00
KotlinGeekDev
80947207ab Remove delay, and implement custom moving function that updates the list in one event, rather than two with the previous approach. 2025-11-28 16:14:47 +01:00
KotlinGeekDev
bf6109d18b Increase delay time in moveBookmark. Need to find a better alternative to delay. 2025-11-28 15:21:58 +01:00
KotlinGeekDev
19a499f6cc Merge branch 'main' into bookmark-groups-improvements 2025-11-28 13:37:54 +00:00
Vitor Pamplona
2db23b5f7f Merge pull request #1592 from greenart7c3/main
Fix duplicated default permissions
2025-11-28 07:16:22 -05:00
greenart7c3
7c4ad3abb7 Fix default permissions 2025-11-28 05:43:52 -03:00
KotlinGeekDev
b4431aab54 String resources. 2025-11-28 04:52:45 +01:00
KotlinGeekDev
cd62b0a722 Re-introduce display stats for bookmark list, focusing on total bookmarks size per category. 2025-11-28 04:43:59 +01:00
KotlinGeekDev
194f57acc3 Support moving articles/posts from private to public and vice versa. 2025-11-28 04:31:08 +01:00
Vitor Pamplona
74193a006d Merge pull request #1586 from KotlinGeekDev/labeled-bookmarks
Bookmark Lists.
2025-11-27 08:17:45 -05:00
KotlinGeekDev
cd0a0405a9 Add Bookmark group metadata edit to routes/nav. Make refactors to make use of it. 2025-11-27 07:19:04 +01:00
KotlinGeekDev
0f21c72936 Bookmark group metadata edit screen, with VM and string resources. Add methods to LabeledBookmarkListEvent and LabeledBookmarkListsState. Support image urls in LabeledBookmarkList. 2025-11-27 07:08:08 +01:00
KotlinGeekDev
576d20723d Merge remote-tracking branch 'origin/labeled-bookmarks' into labeled-bookmarks 2025-11-26 22:45:50 +01:00
KotlinGeekDev
2e5b55a213 Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-26 21:45:35 +00:00
KotlinGeekDev
07f82d3a22 String resources. Remove unneeded Todo. 2025-11-26 22:44:16 +01:00
KotlinGeekDev
a0bff70dc2 Add bookmark list addition/removal to the note dropdown menu. 2025-11-26 22:25:59 +01:00
KotlinGeekDev
5742968f4c Article Bookmark management screen. Add routes and nav entries for both screens. 2025-11-26 22:22:50 +01:00
KotlinGeekDev
e8f55c6456 Post Bookmark management screen. 2025-11-26 22:21:10 +01:00
KotlinGeekDev
b00eb413fa Other Hashtag/Link related refactors. 2025-11-26 19:49:16 +01:00
KotlinGeekDev
6fc5084931 Remove HashtagListView and LinksListView due to spec changes, with their entries in BookmarkType. 2025-11-26 19:47:42 +01:00
Vitor Pamplona
1155e7d5b1 Using real keys to preview 2025-11-26 11:00:04 -05:00
Vitor Pamplona
b776957d0e Reverts to 500 posts to load in the Profile screen (too many replies in some users) 2025-11-26 10:59:47 -05:00
Vitor Pamplona
f29a6337a0 Removing unused methods in User 2025-11-25 16:56:04 -05:00
Vitor Pamplona
0ad1d75fe7 Improving font size of the WoT Score 2025-11-25 16:47:53 -05:00
Vitor Pamplona
f76638f077 Adds a way to view WoT scores 2025-11-25 15:47:08 -05:00
Vitor Pamplona
fd628fffd2 Removes Contact cards from filter since it wasn't returning anything (user is in the d tag, not p or author) 2025-11-25 15:46:51 -05:00
Vitor Pamplona
ff8b441bf6 Increases max pool of SHA 256 processors 2025-11-25 15:02:50 -05:00
Vitor Pamplona
7981ecc82c Adds pre-caching for trust providers 2025-11-25 11:03:00 -05:00
KotlinGeekDev
925506269d Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-25 11:35:01 +00:00
Vitor Pamplona
0c864b7c76 fixes wong kind 2025-11-24 11:18:27 -05:00
Vitor Pamplona
1bfe1e4844 Adds TrustProvider loader to the account filter 2025-11-24 11:03:32 -05:00
Vitor Pamplona
1e00c08f7d Fixes ConcurrentExceptions on Event outbox tentative management 2025-11-24 10:50:58 -05:00
Vitor Pamplona
4f019c6e49 Removes ConcurrentModificationException on outbox events 2025-11-24 10:01:05 -05:00
Vitor Pamplona
5e7f938442 Merge pull request #1589 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-22 12:18:52 -05:00
Crowdin Bot
5fb309d00c New Crowdin translations by GitHub Action 2025-11-22 17:16:30 +00:00
Vitor Pamplona
83639a5991 Merge pull request #1587 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-22 12:15:14 -05:00
KotlinGeekDev
d1fe0cb941 Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-22 02:45:27 +00:00
Crowdin Bot
f3938fb336 New Crowdin translations by GitHub Action 2025-11-21 22:18:06 +00:00
Vitor Pamplona
d12a8ba1c1 Revert runTest because the wait to get results back fails in the function 2025-11-21 17:16:08 -05:00
Vitor Pamplona
382de9ba03 Fixing new remember rules 2025-11-21 16:51:21 -05:00
Vitor Pamplona
25094be07c Puts flow creation inside a remember 2025-11-21 16:43:32 -05:00
Vitor Pamplona
ad879fcf63 Fix test 2025-11-21 16:42:07 -05:00
Vitor Pamplona
50f91e8b91 Updates dependencies 2025-11-21 16:37:56 -05:00
Vitor Pamplona
1f3bd7614e Moves account loading to the IO thread 2025-11-21 16:37:48 -05:00
Vitor Pamplona
c5fae65f2d Moves temporaryAccount handler to lazy loading 2025-11-21 15:31:02 -05:00
Vitor Pamplona
2bff998340 Moves from List to Set of Trust Provider 2025-11-21 15:18:31 -05:00
KotlinGeekDev
f103fe9c79 Merge conflict-resolved changes from upstream 2025-11-21 19:09:50 +01:00
KotlinGeekDev
dae8544938 Fix some conflicts 2025-11-21 18:44:24 +01:00
KotlinGeekDev
b4aca59372 Use stringResources. Implement the necessary callbacks in BookmarkGroupScreen. Disable component stats display in BookmarkGroupItem until Link/Hashtag support is implemented. 2025-11-21 18:19:41 +01:00
Vitor Pamplona
ffe88a5364 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  add a convertExeptions function
  added response helper
  check for error result
  create a RemoteSignerManager
  add a fromBunkerUri helper
  add a signer result interface for remote signer
  implement the functions of nip46
  return BunkerResponse from connect
  - Add connect, getPublivKey and ping - send NostrConnectEvent to relays
  - Add connect, getPublivKey and ping - send NostrConnectEvent to relays
  Create NostrSignerRemote
2025-11-21 11:30:51 -05:00
Vitor Pamplona
0d7f2ce838 Adds support for Trust Provider lists 2025-11-21 11:29:46 -05:00
KotlinGeekDev
26c66a97b5 Bring ArticleListView up to speed. Fix callback naming mistake in BookmarkGroupItemOptions and refactor usages accordingly. 2025-11-21 14:51:53 +01:00
KotlinGeekDev
808030d176 First category-based screen: PostListView. Modify NoteCompose to use a different note options menu when in the PostListView. 2025-11-21 14:20:57 +01:00
KotlinGeekDev
267d680ffc Fix some methods in LabeledBookmarkListsState. Add useful methods to LabeledBookmarkList and BookmarkGroupViewModel. 2025-11-21 14:11:55 +01:00
Vitor Pamplona
058e8ab833 Merge pull request #1555 from greenart7c3/nip46
Support for nip 46
2025-11-20 14:53:17 -05:00
Vitor Pamplona
23d1e94ddf Moves the prepare video call to IO 2025-11-18 19:12:21 -05:00
Vitor Pamplona
89d7e52dc4 Avoids blinking image space in the screen 2025-11-18 18:07:08 -05:00
Vitor Pamplona
2f93aa8777 Moves the QR Code screen from Dialog to full Route 2025-11-18 17:56:04 -05:00
Vitor Pamplona
6f0c65d84c Merge pull request #1583 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-18 17:26:42 -05:00
Crowdin Bot
07ce3d5e5f New Crowdin translations by GitHub Action 2025-11-18 22:20:37 +00:00
Vitor Pamplona
ba541129ba Merge pull request #1582 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-18 17:19:45 -05:00
Vitor Pamplona
b9d4805d56 Merge pull request #1581 from StellarStoic/main
ui: center QR dialog content and reduce excessive top spacing
2025-11-18 17:19:36 -05:00
Crowdin Bot
302aee325f New Crowdin translations by GitHub Action 2025-11-18 22:19:14 +00:00
Vitor Pamplona
3b648a1d9f Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  Open njump.to, iris.to, zap.stream and shosho.live in Amethyst
2025-11-18 17:17:09 -05:00
Vitor Pamplona
a36d35912a Manages the pool state without having to loop through relays, saving some milliseconds of processing. 2025-11-18 17:12:39 -05:00
Vitor Pamplona
326f059d1e Removes logging for connected and available relay dump 2025-11-18 16:00:28 -05:00
StellarStoic
3762fb7b2a ui: center QR dialog content and reduce excessive top spacing 2025-11-18 13:23:36 +01:00
Vitor Pamplona
653e70070c Merge pull request #1580 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-17 16:55:12 -05:00
Crowdin Bot
941f890e46 New Crowdin translations by GitHub Action 2025-11-17 21:49:41 +00:00
Vitor Pamplona
a0bd0bb619 Merge pull request #1579 from kirpy0x/UpdateNostrLinks
Open njump.to, iris.to, zap.stream and shosho.live in Amethyst
2025-11-17 16:48:31 -05:00
kirpy
be2d976124 Open njump.to, iris.to, zap.stream and shosho.live in Amethyst 2025-11-17 12:55:10 -07:00
KotlinGeekDev
c48436b0a6 Introduce BookmarkGroupScreen, with it's associated ViewModel, and the different category-based views. 2025-11-17 18:51:39 +01:00
KotlinGeekDev
c50c3f26c8 Move BookmarkType to an enum instead, for serialization purposes. 2025-11-17 18:17:16 +01:00
Vitor Pamplona
ae22f4685d Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  New Crowdin translations by GitHub Action
  New Crowdin translations by GitHub Action
  New Crowdin translations by GitHub Action
  user correct overlad that accepts argument
  New Crowdin translations by GitHub Action
2025-11-17 08:15:49 -05:00
David Kaspar
84694d8a36 Merge pull request #1578 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-17 13:48:05 +01:00
Crowdin Bot
f73046e652 New Crowdin translations by GitHub Action 2025-11-17 12:46:11 +00:00
David Kaspar
3727bc408b Merge pull request #1577 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-17 13:44:52 +01:00
Crowdin Bot
69ea1c8e51 New Crowdin translations by GitHub Action 2025-11-17 11:10:43 +00:00
David Kaspar
43fba40076 Merge pull request #1576 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-17 12:09:20 +01:00
greenart7c3
a6f3e09a6c add a convertExeptions function 2025-11-17 07:07:49 -03:00
greenart7c3
7dce71d8fa added response helper 2025-11-17 07:07:49 -03:00
greenart7c3
c2f2dc46d9 check for error result 2025-11-17 07:07:49 -03:00
greenart7c3
e02dfce152 create a RemoteSignerManager 2025-11-17 07:07:48 -03:00
greenart7c3
55b4ddc453 add a fromBunkerUri helper 2025-11-17 07:07:48 -03:00
greenart7c3
8d529e1ea9 add a signer result interface for remote signer 2025-11-17 07:07:48 -03:00
greenart7c3
b2f2a409c3 implement the functions of nip46 2025-11-17 07:07:48 -03:00
greenart7c3
aee6ffdbf5 return BunkerResponse from connect 2025-11-17 07:07:48 -03:00
greenart7c3
da85e59424 - Add connect, getPublivKey and ping
- send NostrConnectEvent to relays
2025-11-17 07:07:47 -03:00
greenart7c3
e6f109de74 - Add connect, getPublivKey and ping
- send NostrConnectEvent to relays
2025-11-17 07:07:47 -03:00
greenart7c3
df1bf82e76 Create NostrSignerRemote 2025-11-17 07:07:46 -03:00
Crowdin Bot
f5d8b9df8b New Crowdin translations by GitHub Action 2025-11-17 07:53:24 +00:00
David Kaspar
2c3da65063 Merge pull request #1574 from davotoula/bugfix-placeholder-not-replaced-with-value
Bugfix: use correct overload that accepts argument
2025-11-17 08:52:03 +01:00
David Kaspar
4e6b3a142b Merge pull request #1575 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-16 19:26:09 +01:00
Crowdin Bot
976ff9970a New Crowdin translations by GitHub Action 2025-11-16 16:50:25 +00:00
David Kaspar
c5073303ba Merge pull request #1573 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-16 17:49:05 +01:00
davotoula
3b81490ac1 user correct overlad that accepts argument 2025-11-16 17:43:59 +01:00
KotlinGeekDev
eb8b19ceb9 Slight function name refactor in LabeledBookmarkListsState, and inclusion of a method to reference a particular bookmark group. Add route for viewing a bookmark group, and wire UI accordingly. 2025-11-16 15:40:46 +01:00
Vitor Pamplona
4f5acc5c29 Updates to the latest zapstore config 2025-11-15 17:57:03 -05:00
Crowdin Bot
5f6e65f768 New Crowdin translations by GitHub Action 2025-11-15 22:35:09 +00:00
Vitor Pamplona
ae6f37a52f v1.04.2 to appease the PlayStore 2025-11-15 17:28:55 -05:00
Vitor Pamplona
f6e0124734 Merge pull request #1571 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-15 16:55:10 -05:00
Crowdin Bot
b6a7d9146f New Crowdin translations by GitHub Action 2025-11-15 21:52:58 +00:00
Vitor Pamplona
8492a597c5 Merge pull request #1570 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-15 16:52:28 -05:00
Vitor Pamplona
5264bd2cbc v1.04.1 2025-11-15 16:51:45 -05:00
Crowdin Bot
f630c34045 New Crowdin translations by GitHub Action 2025-11-15 21:51:29 +00:00
Vitor Pamplona
042ff4c5ed Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-11-15 16:49:33 -05:00
Vitor Pamplona
f7e72c57d3 Turn the Pay zaps via intent dialog into a full screen with route 2025-11-15 16:41:47 -05:00
Vitor Pamplona
fef78cd557 Fixes test case for payload type 2025-11-15 16:41:02 -05:00
Vitor Pamplona
673d8d2c30 Makes sure the Zap the Devs dialog doesn't appear behind the status bar 2025-11-15 16:11:58 -05:00
Vitor Pamplona
414aef1818 Merge pull request #1569 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-15 15:41:18 -05:00
Crowdin Bot
bbc4f4281a New Crowdin translations by GitHub Action 2025-11-15 20:30:16 +00:00
Vitor Pamplona
14e3098924 Fixes deserialization issues with old jsons that didn't have these properties 2025-11-15 15:27:57 -05:00
Vitor Pamplona
7742e27780 Moves all hashtags to lowercase and unfollows with ignore case just in case some other client added in uppercase 2025-11-15 15:20:56 -05:00
Vitor Pamplona
a1879a93df sending a new version for quartz 2025-11-15 12:26:38 -05:00
Vitor Pamplona
a86f64fce5 Fixes release Id for the zap the devs button 2025-11-15 12:01:17 -05:00
Vitor Pamplona
075a92a709 Version 1.04 2025-11-15 11:45:26 -05:00
Vitor Pamplona
67d6b9e350 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-11-15 11:34:05 -05:00
Vitor Pamplona
f7fd1e79f0 Improves creation of lists for users that had none 2025-11-15 11:26:35 -05:00
Vitor Pamplona
01afb0aeeb Merge pull request #1568 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-15 11:11:35 -05:00
Crowdin Bot
d777e94aad New Crowdin translations by GitHub Action 2025-11-15 16:09:40 +00:00
Vitor Pamplona
1bd2b2168f Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  updated cz, de, pt, sv
  New Crowdin translations by GitHub Action
2025-11-15 11:08:00 -05:00
Vitor Pamplona
719e34ea8d Less jarring delete buttons on lists 2025-11-15 11:04:13 -05:00
Vitor Pamplona
78c13a7d29 Minor changes to the change log 2025-11-15 10:36:44 -05:00
David Kaspar
61d4f3000f Merge pull request #1567 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-15 13:21:09 +01:00
Crowdin Bot
22c8ecefed New Crowdin translations by GitHub Action 2025-11-15 12:15:13 +00:00
David Kaspar
8995c7a51d Merge pull request #1566 from davotoula/update-translations
updated cz, de, pt, sv
2025-11-15 13:14:06 +01:00
David Kaspar
ef267acee2 Merge pull request #1565 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-15 13:13:49 +01:00
davotoula
cbf9effcf4 updated cz, de, pt, sv 2025-11-15 09:38:00 +01:00
KotlinGeekDev
37b6f84d9f Merge remote-tracking branch 'origin/labeled-bookmarks' into labeled-bookmarks 2025-11-15 03:36:28 +01:00
Crowdin Bot
007f512cc4 New Crowdin translations by GitHub Action 2025-11-15 00:20:39 +00:00
Vitor Pamplona
96ee6baa8f Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-11-14 19:18:33 -05:00
Vitor Pamplona
f400cc6088 Adds a change log 2025-11-14 19:07:52 -05:00
KotlinGeekDev
f1c3a881f1 Change the UI of BookmarkGroupItem to add buttons for viewing parts of the bookmark group. 2025-11-14 20:12:02 +01:00
KotlinGeekDev
822d464a6e Add support for LabeledBookmarkListEvent in the LocalCache, and EventFactory(to prevent crashes when creating one). 2025-11-14 19:45:32 +01:00
KotlinGeekDev
34e5e7de5b Add bookmark groups option to side menu. Make them work by hooking them up in Account. 2025-11-14 19:38:48 +01:00
Vitor Pamplona
23299b5ef3 half-way contrast for placeholder texts 2025-11-13 18:25:49 -05:00
David Kaspar
88a3e2a74b Merge pull request #1563 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-13 19:17:56 +01:00
Crowdin Bot
e5fe63d630 New Crowdin translations by GitHub Action 2025-11-13 15:53:22 +00:00
Vitor Pamplona
e6a10e07c4 Correctly adding relays to the filters so that the Global feed can filter only events that came from the relays listed in the Global filter. 2025-11-13 10:50:13 -05:00
Vitor Pamplona
19846e7ff3 Improves the composition of NIP-05 lines 2025-11-13 10:49:29 -05:00
Vitor Pamplona
05af3a78e4 Increases the contrast of placeholder text 2025-11-13 10:45:58 -05:00
Vitor Pamplona
c9370f8d02 Better parse multiple imetas with multiple urls into appropriate properties 2025-11-13 10:45:28 -05:00
Vitor Pamplona
f186b78ca9 Adds test case for empty d-tags in naddr1 2025-11-13 10:30:26 -05:00
Vitor Pamplona
abcaf19d2b removes zapstr.live 2025-11-13 09:11:20 -05:00
Vitor Pamplona
43fbf4f40f Removes filestr 2025-11-13 09:11:09 -05:00
Vitor Pamplona
9f66cd0660 Opens Follow Packs on following.space 2025-11-13 09:10:56 -05:00
Vitor Pamplona
b80f24b587 Upgrades dependencies 2025-11-13 08:42:07 -05:00
KotlinGeekDev
5068247807 Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-13 09:15:43 +00:00
Vitor Pamplona
ee52f10192 removes unecessary list of icons option from drawer 2025-11-12 18:19:28 -05:00
Vitor Pamplona
8d41b58167 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-11-12 17:26:17 -05:00
Vitor Pamplona
ac291c996c Adds on click to the UserProfile selections 2025-11-12 17:21:46 -05:00
Vitor Pamplona
29d11c8281 Moves the List/Pack creation Dialog to a screen and adds an Image upload option 2025-11-12 17:17:22 -05:00
Vitor Pamplona
e2c50847d0 Merge pull request #1562 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-12 14:21:30 -05:00
Crowdin Bot
b339e3a245 New Crowdin translations by GitHub Action 2025-11-12 19:20:01 +00:00
Vitor Pamplona
8963024a95 Fixes Jump on FollowPack screens 2025-11-12 14:18:01 -05:00
Vitor Pamplona
27bc950afb Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-11-12 14:12:26 -05:00
Vitor Pamplona
0ddbb4616b Adds creation, edits and member update for Follow Packs 2025-11-12 13:50:57 -05:00
Vitor Pamplona
adb484bf56 Improves descriptions of lists and packs 2025-11-12 13:18:18 -05:00
Vitor Pamplona
bd30b526a9 Adds a @Stable marker on UserSuggestionState s 2025-11-12 12:34:49 -05:00
Vitor Pamplona
ce49725771 Allows customization of the Create new list dialog 2025-11-12 12:34:11 -05:00
Vitor Pamplona
6c64297415 Adds an update method for changes in current Event 2025-11-12 12:33:35 -05:00
Vitor Pamplona
90330165d2 Prepares to create follow packs 2025-11-12 12:33:10 -05:00
David Kaspar
fe876de4c9 Merge pull request #1561 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-12 15:13:05 +01:00
Crowdin Bot
b17d709696 New Crowdin translations by GitHub Action 2025-11-12 13:32:00 +00:00
Vitor Pamplona
8554257329 Change reqResultsInOrderAsFlow to reqAsFlow 2025-11-12 08:29:05 -05:00
Vitor Pamplona
9eee5d9e1e slight performance improvement on the reqflow 2025-11-12 08:27:59 -05:00
Vitor Pamplona
b4810fd12c Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2025-11-11 19:29:10 -05:00
Vitor Pamplona
bf803384a8 This early decryption doesn't seem needed anymore. The event consumer in the account will take care of decrypting the new DM 2025-11-11 19:01:52 -05:00
Vitor Pamplona
e8eb4c688c Modifies the flow to only output changes in the relay piece that has modified. 2025-11-11 19:01:20 -05:00
Vitor Pamplona
a5207c3f0a adds recursive markAsSeen to mark the inner chat element of NIP-17 DMs for each relay that that message was received. 2025-11-11 19:00:49 -05:00
Vitor Pamplona
ce073553ee Makes sure the edit is Stable 2025-11-11 18:59:34 -05:00
Vitor Pamplona
dd97dfc67f Merge pull request #1560 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-11 17:08:06 -05:00
Vitor Pamplona
1cfe953a65 Refactors the code to avoid using callback lambdas 2025-11-11 16:37:15 -05:00
Vitor Pamplona
d4b91845d5 rename runIOCatching to launchSigner 2025-11-11 16:10:49 -05:00
Crowdin Bot
a114bf9dfa New Crowdin translations by GitHub Action 2025-11-11 21:05:02 +00:00
Vitor Pamplona
b14c588a34 Moves the dispatcher of the save event for server models from each of the viewmodels to the account view model since the scope gets cancelled when the screen closes. 2025-11-11 16:01:19 -05:00
Vitor Pamplona
1589b646a8 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  updated cz, de, pt, sv
2025-11-11 16:00:15 -05:00
Vitor Pamplona
8c3ab80e47 More correctly parses null and default values from NIP-55 using Jackson 2025-11-11 14:00:17 -05:00
David Kaspar
4690f59922 Merge pull request #1556 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-11 09:12:24 +01:00
Crowdin Bot
255389aa3c New Crowdin translations by GitHub Action 2025-11-11 07:58:56 +00:00
David Kaspar
cf756669e5 Merge pull request #1557 from davotoula/update-translations
updated cz, de, pt, sv
2025-11-11 08:57:39 +01:00
davotoula
d7ca9d5a82 updated cz, de, pt, sv 2025-11-11 08:53:04 +01:00
Vitor Pamplona
5f88249be6 Creates a feed filter option for only kind 3 users 2025-11-10 19:45:39 -05:00
Vitor Pamplona
db354b646f Adds context to the highlights 2025-11-10 18:42:17 -05:00
Vitor Pamplona
17fdf05847 Fixes the author of the highlight 2025-11-10 18:00:32 -05:00
Vitor Pamplona
ddfe61eb9f adds description to the follow card ui 2025-11-10 17:36:07 -05:00
Vitor Pamplona
8bdd9d0979 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  updated cz, de, pt, sv
  New Crowdin translations by GitHub Action
  Update strings.xml

# Conflicts:
#	amethyst/src/main/res/values/strings.xml
2025-11-10 17:12:11 -05:00
Vitor Pamplona
bd1a70039a Creates a feed for follow packs 2025-11-10 17:02:21 -05:00
KotlinGeekDev
0c06bd4271 Build out UI for the list of labeled bookmarks, or bookmark groups, borrowing some components from elsewhere. 2025-11-10 19:34:55 +01:00
David Kaspar
87599b7e59 Merge pull request #1554 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-10 18:22:12 +01:00
Crowdin Bot
5762490098 New Crowdin translations by GitHub Action 2025-11-10 17:03:42 +00:00
David Kaspar
e634040504 Merge pull request #1553 from davotoula/update-translations
updated cz, de, pt, sv
2025-11-10 18:02:10 +01:00
davotoula
15e7909222 updated cz, de, pt, sv 2025-11-10 18:01:16 +01:00
Vitor Pamplona
ffaa06fed7 Adds an option to render a user gallery from HexKeys instead of full User objects 2025-11-10 11:18:18 -05:00
Vitor Pamplona
58128d9ad0 Making Channel metadata view model stable for performance 2025-11-10 11:17:22 -05:00
Vitor Pamplona
011aebfc27 Moving preview EmptyNav to a class to avoid Nav auto-imports in production code. 2025-11-10 11:17:04 -05:00
Vitor Pamplona
7230f67782 Merge pull request #1552 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-10 08:02:31 -05:00
Crowdin Bot
93e80dda83 New Crowdin translations by GitHub Action 2025-11-10 12:54:37 +00:00
Vitor Pamplona
0505011eab Merge pull request #1551 from maxblake2015/main
Update strings.xml
2025-11-10 07:53:07 -05:00
KotlinGeekDev
d029c9eca0 Merge remote-tracking branch 'origin/labeled-bookmarks' into labeled-bookmarks 2025-11-09 22:13:47 +01:00
KotlinGeekDev
1d49ee3d57 Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-09 21:13:32 +00:00
KotlinGeekDev
751cbdcaa9 Introduce LabeledBookmarkList and a full implementation for LabeledBookmarkListsState. 2025-11-09 22:12:54 +01:00
KotlinGeekDev
d5a2910029 Add support for descriptions in LabeledBookmarkListEvent. Make function names clearer, and add modification functions. 2025-11-09 22:11:12 +01:00
Max Blake
ea83dc3fd7 Update strings.xml
a small logical correction
2025-11-08 12:22:45 +00:00
Vitor Pamplona
948180157f pushes exception upward if triggered 2025-11-07 17:37:34 -05:00
Vitor Pamplona
a996065c05 Creates new observer for events with null option 2025-11-07 17:27:41 -05:00
Vitor Pamplona
c328a3aefe Refactors event observer name to a more precise one 2025-11-07 17:26:20 -05:00
Vitor Pamplona
6ccec0681a Marks address as stable 2025-11-07 17:23:53 -05:00
Vitor Pamplona
26a6b5d219 - Inverts public and private tabs
- adds number of users in the tab title
2025-11-06 16:19:43 -05:00
Vitor Pamplona
d17f39e85b Fixes naming cropping the menu icon 2025-11-06 16:18:29 -05:00
Vitor Pamplona
2ac5a57efe Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  optimise imports
  synchronize all cache mutations and supply sinceRelaySet so callers get snapshot copies rework groupByRelayPresence to build relay snapshots via sinceRelaySet,     filtering with immutable lists to prevent ConcurrentModificationException when relays update mid-iteration
  Hardened EOSEAccountFast against concurrent access so callers no longer iterate over live mutable maps
  Adjusted subscription cleanup to avoid mutating the watcher map while iterating it, preventing the ConcurrentModificationException when accounts switch

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt
2025-11-06 15:15:34 -05:00
Vitor Pamplona
6727bd0430 Improves padding of the ListItem interfaces 2025-11-06 14:15:59 -05:00
Vitor Pamplona
34aca458ed migrates to njump.to 2025-11-06 13:49:15 -05:00
KotlinGeekDev
3613bed5fa Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-06 16:22:38 +00:00
Vitor Pamplona
ba082d86e2 Merge pull request #1550 from davotoula/reduce-errors-ConcurrentModificationException
Reduce errors in log: concurrent modification exception
2025-11-06 09:50:22 -05:00
davotoula
409b3b43f7 optimise imports 2025-11-06 10:19:32 +01:00
davotoula
545cd2ff6d synchronize all cache mutations and supply sinceRelaySet so callers get snapshot copies
rework groupByRelayPresence to build relay snapshots via sinceRelaySet,
    filtering with immutable lists to prevent ConcurrentModificationException when relays update mid-iteration
2025-11-06 10:12:07 +01:00
davotoula
a82d6565fa Hardened EOSEAccountFast against concurrent access so callers no longer iterate over live mutable maps 2025-11-06 10:12:07 +01:00
davotoula
c07202944d Adjusted subscription cleanup to avoid mutating the watcher map while iterating it, preventing the ConcurrentModificationException when accounts switch 2025-11-06 10:12:07 +01:00
KotlinGeekDev
19fa7e25d5 Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-06 01:35:11 +00:00
Vitor Pamplona
178582f3c4 Showing the list option in all Follow Buttons 2025-11-05 19:03:01 -05:00
Vitor Pamplona
c2c3442b74 Fixes showing the list of people button when the user is blocked 2025-11-05 19:02:45 -05:00
Vitor Pamplona
43942891b7 Changes the following marker to be a follow from all lists and follow sets 2025-11-05 18:02:49 -05:00
Vitor Pamplona
fc488064b7 Changes animations for profile buttons to come from the bottom of the screen 2025-11-05 17:58:56 -05:00
Vitor Pamplona
4535966c32 Refactoring of class names and breaking down bigger files into many 2025-11-05 17:48:16 -05:00
Vitor Pamplona
83f460c01d Selects the full text when adding users to list 2025-11-05 17:06:59 -05:00
Vitor Pamplona
6c630c75c2 Improves design of the lists and User management screen. 2025-11-05 16:40:20 -05:00
Vitor Pamplona
8d33ea8f99 Remove description from top nav bar of the list 2025-11-05 16:39:59 -05:00
Vitor Pamplona
558d952a00 Generalize List counters 2025-11-05 16:39:37 -05:00
Vitor Pamplona
1eb2309952 sort by title instead 2025-11-05 16:39:07 -05:00
Vitor Pamplona
0ffbbee31a Switch to vector graphics 2025-11-05 16:04:03 -05:00
Vitor Pamplona
ed8b285700 Sort lists by identifier 2025-11-05 15:54:01 -05:00
Vitor Pamplona
452082c6cb Fixes multiline list name 2025-11-05 15:53:47 -05:00
KotlinGeekDev
7accbc198b Merge branch 'vitorpamplona:main' into labeled-bookmarks 2025-11-05 20:25:46 +00:00
Vitor Pamplona
8168d353dd Improves the look of the list of lists of people screen 2025-11-05 14:11:15 -05:00
KotlinGeekDev
e2df96ea4b Introduce LabeledBookmarkListEvent. 2025-11-05 17:51:12 +01:00
Vitor Pamplona
59cdb222d5 Removes the Mute List from the All Follows TopNav option 2025-11-05 10:51:10 -05:00
Vitor Pamplona
712965cc8f Fixes the unfollow button on profile (removes the follow signal including all lists for now)
Also fixes spacing of the Follow button
2025-11-04 19:50:57 -05:00
Vitor Pamplona
5741914c2a Fixes User profile banner being off place in short images 2025-11-04 19:41:23 -05:00
Vitor Pamplona
b8cce80d9f Updates all libs 2025-11-04 19:41:05 -05:00
Vitor Pamplona
7dfa343ad8 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  code simplification and deduplication
  code simplification and deduplication
  Add blurhash and dim to Nip96Uploader.kt
  added blurhash to blossom upload
  New Crowdin translations by GitHub Action
  Update README to mark video capture as complete
  Parse for video segments and exclude them from image gallery
  add VideoSegment and failing tests
  Fix location being added to note even after deselecting it
  optimise imports
  New Crowdin translations by GitHub Action
  update cs, de, sv, pt translations
  Revert "update buffer to 64kb"
  update buffer to 64kb
  Change length to Long from Int: avoids potential overflow, Long seems to be used everywhere else
  Create a CountingInputStream utility to avoid duplication Prevent INT overflow in BlossomUploader.kt Connection Cleanup in ImageDownloader.kt Added try-finally in Sha256Hasher.jvmAndroid.kt
  Add streaming hash utility function to quartz multiplatform, follow the existing pool/worker design Change hashing in ImageDownloader.kt to use streaming
  stream file to calculate both hash and size without loading it all at once

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetScreen.kt
2025-11-04 19:28:45 -05:00
Vitor Pamplona
be804092d7 - Refactors people lists
- Adds people and follow list cache on the account class
- Updates TopNav to use the new caching
- Removes redundant viewModels for list feeds
- Adds the ability to add and remove users from the List screen
2025-11-04 19:24:24 -05:00
Vitor Pamplona
50b551bb80 Updates emoji and user autocomplete state to use Account and avoid linking AccountViewModel 2025-11-04 19:21:04 -05:00
Vitor Pamplona
7c439f2448 Merge pull request #1549 from davotoula/optimise-imports
Optimise imports
2025-11-03 14:06:59 -05:00
davotoula
5ce184d400 Merge branch 'main' into optimise-imports 2025-11-03 19:45:12 +01:00
Vitor Pamplona
bda1fcadf8 Merge pull request #1548 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-03 12:47:34 -05:00
Crowdin Bot
379e539e89 New Crowdin translations by GitHub Action 2025-11-03 17:39:48 +00:00
Vitor Pamplona
f14b545947 Merge pull request #1531 from davotoula/1530-added-streaming-hashing-utility-to-avoid-OOM-on-large-files
Fix OutOfMemoryError for Large File Uploads
2025-11-03 12:38:38 -05:00
Vitor Pamplona
b8fff0c428 Merge pull request #1546 from davotoula/1537-dont-include-video-media-in-image-gallery
dont include video media in image gallery
2025-11-03 12:37:53 -05:00
davotoula
942de50148 code simplification and deduplication 2025-11-02 20:37:20 +01:00
davotoula
76be7b0d58 code simplification and deduplication 2025-11-02 19:56:38 +01:00
davotoula
4b260cfc07 Add blurhash and dim to Nip96Uploader.kt 2025-11-02 18:25:49 +01:00
davotoula
eee1487883 added blurhash to blossom upload 2025-11-02 18:25:49 +01:00
David Kaspar
27380f276c Merge pull request #1547 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-11-01 19:26:09 +01:00
Crowdin Bot
f62b1667c3 New Crowdin translations by GitHub Action 2025-11-01 18:22:14 +00:00
David Kaspar
eb4f0b9af9 Update README to mark video capture as complete 2025-11-01 18:20:58 +00:00
davotoula
7c374fb586 Parse for video segments and exclude them from image gallery 2025-11-01 16:37:38 +01:00
davotoula
1e12decd36 add VideoSegment and failing tests 2025-11-01 16:23:35 +01:00
Vitor Pamplona
1411de32a0 Adds search of events and addresses in the Deletion Event 2025-10-31 18:43:45 -04:00
Vitor Pamplona
7ea61824c8 Moves filter interfaces to their own files 2025-10-31 18:41:41 -04:00
Vitor Pamplona
f0923fb393 Fixes crash when checking and creating new users from tags. 2025-10-31 18:38:29 -04:00
Vitor Pamplona
3eb662851b Adds a flatten to set utility 2025-10-31 18:36:05 -04:00
Vitor Pamplona
f9bd660774 Merge pull request #1545 from greenart7c3/main
Fix location being added to note even after deselecting it
2025-10-31 08:26:00 -04:00
greenart7c3
bdb2637803 Fix location being added to note even after deselecting it 2025-10-31 06:34:49 -03:00
davotoula
c3c75b46a7 optimise imports 2025-10-30 19:20:02 +01:00
David Kaspar
3c7104fb6a Merge pull request #1544 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-10-30 19:19:05 +01:00
Crowdin Bot
c111912d2f New Crowdin translations by GitHub Action 2025-10-30 17:56:08 +00:00
David Kaspar
0f4e514308 Merge pull request #1543 from davotoula/update-tr
update cs, de, sv, pt translations
2025-10-30 18:54:44 +01:00
davotoula
ccf9d4ce80 update cs, de, sv, pt translations 2025-10-30 18:19:38 +01:00
Vitor Pamplona
95cc0783fd Adds a req that returns the list of events in order of arrival 2025-10-29 18:30:00 -04:00
Vitor Pamplona
f9f7cdf12f Renames internal Req objects 2025-10-29 18:29:23 -04:00
Vitor Pamplona
40d417038e adds a Start request to the req listener 2025-10-29 18:28:59 -04:00
Vitor Pamplona
895562b4a9 Simplifies readme 2025-10-29 18:28:38 -04:00
Vitor Pamplona
b8511b5ac3 Adds simplified methods to create Requests from NostrClient 2025-10-29 16:51:49 -04:00
Vitor Pamplona
40ce627de0 Merge 2025-10-29 15:57:48 -04:00
Vitor Pamplona
56a856cf71 Better filter for People Lists 2025-10-29 15:55:33 -04:00
Vitor Pamplona
904b8c445f Merge pull request #1540 from davotoula/1535-dont-use-own-follow-sets-for-follows-check
only check follow sets when user1 is the logged-in user
2025-10-29 15:47:30 -04:00
davotoula
3a744b9c1d only check follow sets when user1 is the logged-in user 2025-10-29 20:33:39 +01:00
Vitor Pamplona
6d8b5fa85d Moves message button to Profile Actions 2025-10-29 15:19:11 -04:00
Vitor Pamplona
a913194c41 Updates deprecated Clipboard manager 2025-10-29 15:18:57 -04:00
Vitor Pamplona
a9ebf561e7 Merge pull request #1539 from greenart7c3/main
Fix DecryptZapRequest CommandType
2025-10-29 14:00:05 -04:00
greenart7c3
fb7610cafd Fix DecryptZapRequest CommandType 2025-10-29 14:38:54 -03:00
Vitor Pamplona
cd35c8e30e uses faster spotless check on commits 2025-10-29 12:20:37 -04:00
Vitor Pamplona
f3209d2415 Moves SettingsCategory to take integers instead of Strings 2025-10-29 12:19:38 -04:00
Vitor Pamplona
d44b24936c Activates reverse scrolling for the new post screen in order to avoid hiding the cursor behind the keyboard when typing long texts. 2025-10-29 11:23:09 -04:00
Vitor Pamplona
a9aadb60fc Merge pull request #1538 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-10-29 09:56:13 -04:00
Crowdin Bot
59f2cdcb2f New Crowdin translations by GitHub Action 2025-10-29 13:21:40 +00:00
Vitor Pamplona
00eb6ee3f1 Adds a missing outbox popup on posting new notes 2025-10-28 19:09:24 -04:00
Vitor Pamplona
09e4b2ea44 Fixes inconsistent relay lists 2025-10-28 18:41:43 -04:00
Vitor Pamplona
f8b920a128 Adds a popup to warn users of the lack of inbox relays 2025-10-28 13:12:36 -04:00
Vitor Pamplona
0705ee9581 Unifying the parser for the tag array 2025-10-28 13:05:57 -04:00
Vitor Pamplona
bf664ea0c5 Avoids interning of large strings and objects where the duplication is not common 2025-10-28 12:18:19 -04:00
Vitor Pamplona
39f3aef7b5 Don't intern signatures, content and tags since they are not usually duplicated 2025-10-28 12:11:34 -04:00
davotoula
7254dce3a9 Revert "update buffer to 64kb"
This reverts commit 4e31a9a5ac.
2025-10-26 10:25:40 +01:00
davotoula
4e31a9a5ac update buffer to 64kb 2025-10-26 00:09:17 +02:00
davotoula
d01f6a2178 Change length to Long from Int: avoids potential overflow, Long seems to be used everywhere else 2025-10-25 23:54:56 +02:00
davotoula
2332623cde Create a CountingInputStream utility to avoid duplication
Prevent INT overflow in BlossomUploader.kt
Connection Cleanup in ImageDownloader.kt
Added try-finally in Sha256Hasher.jvmAndroid.kt
2025-10-25 23:43:24 +02:00
davotoula
93994564f6 Add streaming hash utility function to quartz multiplatform, follow the existing pool/worker design
Change hashing in ImageDownloader.kt to use streaming
2025-10-25 23:22:31 +02:00
davotoula
e1c54f52e3 stream file to calculate both hash and size without loading it all at once 2025-10-25 21:24:12 +02:00
Vitor Pamplona
371d3bf0f7 Improves paddings, language and add stickiness backgrounds 2025-10-24 19:08:26 -04:00
Vitor Pamplona
b0e7082ec6 Solves crash when the same user is in the private and public part of the list 2025-10-24 19:07:31 -04:00
Vitor Pamplona
34971211f1 Better icon padding 2025-10-24 19:06:36 -04:00
Vitor Pamplona
b065956aec Normalize thicknesses of the divisors 2025-10-24 19:05:35 -04:00
Vitor Pamplona
36f79ca581 Correct animations for the list screens 2025-10-24 19:02:52 -04:00
Vitor Pamplona
7aeb4e7582 Enabling more threads in debug 2025-10-24 18:51:18 -04:00
Vitor Pamplona
25d6562500 Solving lack of stream name 2025-10-24 18:08:39 -04:00
Vitor Pamplona
67e3b05a42 Uses the new nostrnwc deep link for connections 2025-10-24 17:53:31 -04:00
Vitor Pamplona
a157c60697 update compose 2025-10-24 14:59:06 -04:00
Vitor Pamplona
50d63a0501 making sure the disappearing modifier is not doing anything when the top or bottom bars are not present. 2025-10-23 19:24:23 -04:00
Vitor Pamplona
e1cbfbb280 Fixes large top bars on these screens 2025-10-23 17:46:26 -04:00
Vitor Pamplona
b6aec40e9a New Disappearing Top, Bottom and Action Scaffold 2025-10-23 17:45:21 -04:00
Vitor Pamplona
e70a191191 No need for disappearing scaffolds in these screens 2025-10-23 17:42:10 -04:00
Vitor Pamplona
59a5a15866 Merge pull request #1528 from davotoula/image-gallery-improve-layout
Image gallery improve layout
2025-10-23 09:13:44 -04:00
davotoula
b82145ac10 Merge branch 'main' into image-gallery-improve-layout 2025-10-23 15:01:29 +02:00
Vitor Pamplona
ee80361478 - Removes the deprecated hacks from Contact Lists
- Removes the use of kind 3 to show hashtags which fixes issues when people unfollowing the hashtag and it would just affect the InterestListEvent
2025-10-22 18:31:00 -04:00
Vitor Pamplona
2d551d4adf Fixes the visual references to communities and hashtags in the top right of the post 2025-10-22 18:27:53 -04:00
Vitor Pamplona
35da3b4efe Makes sure the list of geohashes and hashtags use lowercase words 2025-10-22 18:26:52 -04:00
Vitor Pamplona
5306dd52d7 spotless needs to clean the compiled files to compute all unused imports 2025-10-22 16:29:47 -04:00
Vitor Pamplona
2ada3855d0 Remove imports 2025-10-22 16:28:20 -04:00
Vitor Pamplona
cabdfea8f9 No need to avoid annotations with test 2025-10-22 16:28:10 -04:00
Vitor Pamplona
d9c0a9af43 Reactivates removing imports on ktint by default 2025-10-22 16:27:52 -04:00
Vitor Pamplona
a7c846f641 Removes NIP-04 DMs with hidden words from the Notification screen 2025-10-22 16:16:15 -04:00
davotoula
f7ed4d2aaa NEEDS testing:
Swapped the local var orientation by remember { mutableStateOf(...) } for an explicit MutableState: we now create orientationState = remember(firstImage) { mutableStateOf(...) }, update it with
    orientationState.value = …, and return orientationState.value.
2025-10-22 09:32:26 +01:00
Vitor Pamplona
031e604923 Only avoids sending a message for the request state if it is a new open request 2025-10-21 20:17:04 -04:00
Vitor Pamplona
551bd344e0 Makes sure to trigger null pointer exception on message deserializer 2025-10-21 20:15:48 -04:00
Vitor Pamplona
c21b2ca60f Removes the choice of Dispatchers from Quartz 2025-10-21 20:15:05 -04:00
Vitor Pamplona
db1d5c7e82 Adds the user outbox relays when loading addressables by that user. 2025-10-21 20:09:13 -04:00
Vitor Pamplona
17618ac361 Fixes bug when loading users 2025-10-21 19:29:43 -04:00
Vitor Pamplona
bf94d0d91c For reports, only use the outbox relay if the user has it. (don't use our default bootstrapping relays) 2025-10-21 19:09:58 -04:00
Vitor Pamplona
66096e5b98 Allows an empty metadata to reload everything about the user 2025-10-21 19:09:25 -04:00
Vitor Pamplona
c8a608718c Merge 2025-10-21 18:42:11 -04:00
Vitor Pamplona
df54f7bb67 Moves all Dispatchers.Default to IO because of the amount of Synchronized code that blocks the thread underneath each coroutine and since Default only has the exact number of threads the processor offers, once they block, we lose computing power 2025-10-21 18:30:08 -04:00
David Kaspar
5b230a4c9b Merge branch 'main' into image-gallery-improve-layout 2025-10-22 00:10:34 +02:00
Vitor Pamplona
d8907e3dfb Caches the User object to avoid being deleted by the soft reference on Local Cache 2025-10-21 17:52:45 -04:00
Vitor Pamplona
889e3d63ff Merge pull request #1527 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-10-21 16:06:01 -04:00
Crowdin Bot
5f7b318647 New Crowdin translations by GitHub Action 2025-10-21 20:04:21 +00:00
Vitor Pamplona
e2b2b10994 - Separates a relay failure tracker to a module
- Updates finding the outbox relay set to include the set of connected relays before all of the relays.
2025-10-21 15:55:32 -04:00
davotoula
a055f473cb prevent race condition: Added a state-aware rememberFirstImageOrientation helper so each gallery watches the cache until the first image’s real aspect ratio is known 2025-10-21 15:56:18 +01:00
davotoula
c735c358bb extracted common logic 2025-10-21 15:47:20 +01:00
davotoula
50cbbb55b6 three images case: test for aspect ratio of first image and adapt gallery based on that 2025-10-21 15:41:40 +01:00
davotoula
103845225b two images case: test for aspect ratio of first image and adapt gallery based on that 2025-10-21 15:19:35 +01:00
Vitor Pamplona
a27df3a9b6 Improving click and long press interactions with the relay list item and status bar items 2025-10-21 09:19:11 -04:00
Vitor Pamplona
d51d1f4453 Updates OkHttp, Tor and Spotless back 2025-10-21 08:38:53 -04:00
Vitor Pamplona
f899d446bc Merge 2025-10-21 08:37:53 -04:00
Vitor Pamplona
c1295ed97f Reverts composable naming conventions on spotless 2025-10-21 08:34:29 -04:00
Vitor Pamplona
774c5412d3 Merge pull request #1526 from davotoula/revert-spotless-plugin
revert spotless plugin  version
2025-10-21 08:32:47 -04:00
davotoula
38d5527b91 revert spotless plugin (need to find a solution for @Composable function names)
Remove unused imports
2025-10-21 12:47:42 +01:00
Vitor Pamplona
1ce352e69f Changes the icon of account preferences to translation for now 2025-10-20 21:00:11 -04:00
Vitor Pamplona
bf3e5c4568 no need for this anymore 2025-10-20 20:57:20 -04:00
Vitor Pamplona
200db73ce4 remove fmt.wiz.biz from bootstrap relays 2025-10-20 20:22:58 -04:00
Vitor Pamplona
2d1fd4a48a Remove companions 2025-10-20 20:22:37 -04:00
Vitor Pamplona
a0cb2a7db6 Creates a default relay list for indexers in new accounts 2025-10-20 20:22:20 -04:00
Vitor Pamplona
382319addf Adds a declared follows state 2025-10-20 20:21:18 -04:00
Vitor Pamplona
37625987c1 Only listens to notification changes when the UI is visible 2025-10-20 19:41:27 -04:00
Vitor Pamplona
63bd0b6cae Watches the okHttp client for web calls (only active when screen is visible) 2025-10-20 19:40:04 -04:00
Vitor Pamplona
48a69f1d26 Watches the correct OKHttp for relays 2025-10-20 19:39:33 -04:00
Vitor Pamplona
f663bb78aa Reduce error log sizes 2025-10-20 18:41:43 -04:00
Vitor Pamplona
a163c4188f Adjustments to the lazy initialization of the app 2025-10-20 18:41:00 -04:00
Vitor Pamplona
fabec35593 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-10-20 17:13:25 -04:00
Vitor Pamplona
e4ce7415a3 Don't use "when" when just one line is needed 2025-10-20 17:13:11 -04:00
Vitor Pamplona
911fdffa95 Turns the EOSE class into a mutable map for speed 2025-10-20 17:12:36 -04:00
Vitor Pamplona
3a73de6828 Separates the new GaleryParser from Composables so that later we can cache it and parse it on the Default thread. 2025-10-20 17:12:12 -04:00
Vitor Pamplona
c4653b147c Adds new benchmark cases 2025-10-20 17:10:08 -04:00
Vitor Pamplona
cbba2fe423 Merge pull request #1525 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-10-18 18:14:05 -04:00
Crowdin Bot
8aa7c57a24 New Crowdin translations by GitHub Action 2025-10-18 22:13:15 +00:00
Vitor Pamplona
e45b4b11dc Restructuring Relay systems to:
- Maintain order of incoming messages for relay listeners
- Defers all processing of incoming messages to coroutines via channels, freeing OkHttp's thread as soon as possible.
- Simplifies the main relay class by using attached listener modules for each function of the relay client.
- Migrate defaultOnConnect calls to become listener based and moved to NostrClients
- Treat counts as query only, not subscriptions.
- Coordinates REQs so that if an update is required to be sent but the server has not finished processing events, waits for it to finish and sends it later as soon as EOSE or Close arrives
- Correctly sustain the local state of each Req.
- Creates an Account follow list per Relay state that only includes shared relays as a better source of functioning relays
- Changes UserLoading features in a tentative to make them faster since they are used by all functions in the app.
- Correctly marks EOSE for filters that are aligned with the Req State from NostrClient
- Avoid subsequent REQ updates before EOSE or CLOSE calls.
- Refactoring RelayClient listener to be not dependent of which module is active for a relay client.
- Refactors authenticators to do complete operation as a module
- Breaks down Relay Client modules (Auth, Reqs, Counts, Event submissions) in the Relay Pool class.
- Creates listeners just for special REQ situations
- Move statistics to outside the base relay class as a listener
- Move logs to outside the base relay class as a listener
- Better structures a Standalone Relay client
- More appropriately communicate errors to the listeners
- Remove relay state on listeners
2025-10-18 18:10:45 -04:00
Vitor Pamplona
e20afd39a3 Removing onClosing listener because it isn't being used. 2025-10-18 17:12:10 -04:00
Vitor Pamplona
4c633c7dec Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-10-18 08:44:21 -04:00
Vitor Pamplona
97fc10d6ea Increases local video disk cache to 1GB 2025-10-17 18:00:38 -04:00
Vitor Pamplona
b8d0f057c5 Adds a way to remove everyone but some keys from the list of EOSEs 2025-10-17 14:30:13 -04:00
Vitor Pamplona
990cca3d64 Moving the EmptyOTS resolver to Quartz for test cases 2025-10-17 14:29:46 -04:00
Vitor Pamplona
4a0c924d4f Merge pull request #1524 from davotoula/use-runTest-instead-of-runBlocking
change from runBlocking to runTest where appropriate
2025-10-15 18:57:42 -04:00
davotoula
5d6e7d1921 change from runBlocking to runTest where appropriate 2025-10-15 21:00:00 +02:00
Vitor Pamplona
563e985a2b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-10-14 16:13:16 -04:00
David Kaspar
50adf82d90 Merge pull request #1523 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-10-14 22:11:49 +02:00
Vitor Pamplona
7a05f527dd Moving Log.java to Log.kt 2025-10-14 15:18:45 -04:00
Vitor Pamplona
0668f2caa4 Updates Compose, Firebase, GMS, JNA, mock, okhttp, secp256, spotless, vico, zoomable, androidXCamera 2025-10-14 14:38:28 -04:00
Vitor Pamplona
4af576f5f4 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-10-14 14:15:38 -04:00
Crowdin Bot
fdbf261455 New Crowdin translations by GitHub Action 2025-10-14 17:43:52 +00:00
Vitor Pamplona
e422dcda50 Merge pull request #1519 from davotoula/1516-Private-Public-Bookmark-Removal-Issue
Private public bookmark removal issue
2025-10-14 13:42:42 -04:00
David Kaspar
a210bd1c8f Merge pull request #1522 from davotoula/update-translations
Updated cs, se, de, pt
2025-10-14 19:23:26 +02:00
davotoula
49f77af3e8 Updated cs, se, de, pt 2025-10-14 18:59:54 +02:00
David Kaspar
7d22ef0d12 Merge pull request #1521 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-10-14 17:58:02 +02:00
Crowdin Bot
deee1a1c85 New Crowdin translations by GitHub Action 2025-10-14 13:50:15 +00:00
Vitor Pamplona
7a1dcbc76e Merge pull request #1520 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-10-14 09:49:15 -04:00
Vitor Pamplona
68264f2c7f Merge pull request #1518 from davotoula/bugfix-dont-render-gallery-for-mixed-images-and-videos
Bugfix: dont render gallery for mixed images and videos
2025-10-14 09:49:06 -04:00
Vitor Pamplona
b1d3aa968d Merge branch 'main' into bugfix-dont-render-gallery-for-mixed-images-and-videos 2025-10-14 09:48:58 -04:00
Crowdin Bot
2ebdedafaa New Crowdin translations by GitHub Action 2025-10-14 13:47:57 +00:00
Vitor Pamplona
4ffc121003 Merge pull request #1517 from davotoula/fix-streamable-videos
Use latest LightCompressor-enhanced
2025-10-14 09:46:37 -04:00
Vitor Pamplona
a4f1e45aac Merge pull request #1507 from KotlinGeekDev/followset-improvements
More follow set improvements.
2025-10-14 09:46:07 -04:00
davotoula
3a3d1a6f25 bugfix: don't remove from public bookmarks when removing from private 2025-10-14 13:31:27 +02:00
davotoula
85bdf67ac3 added failing test: Public tags should be preserved when removing from private bookmarks 2025-10-14 13:21:46 +02:00
KotlinGeekDev
9e991f8a49 Fix user removal functions, by updating them to take into account backend modifications made earlier. 2025-10-13 21:31:22 +01:00
KotlinGeekDev
e6c841373b String resources. Remove unused string resource in other locale-specific files, to avoid much work. 2025-10-13 21:04:42 +01:00
KotlinGeekDev
ba756c9acd Take into account profile membership when removing profile. 2025-10-13 20:30:18 +01:00
davotoula
349156f380 fix: Only render as gallery if all segments are images 2025-10-13 19:32:25 +02:00
davotoula
133c897731 Added junit and mockK for androidTest UI dependencies
Added failing androidTest for mixed image/video parsing
2025-10-13 18:38:14 +02:00
davotoula
13402fa5d4 Use lightcompressor-enhanced 1.6.0 release 2025-10-13 09:25:11 +02:00
davotoula
2a3c31cc5b Use latest LightCompressor-enhanced:
Fix for Progressive download
Replaced obsolete MP4 parser libraries with native MediaMuxer/MediaCodec
2025-10-12 21:13:12 +02:00
Vitor Pamplona
5fcf0c5938 Using a week as the default cut off for notifications from random relays. 2025-10-10 12:54:58 -04:00
Vitor Pamplona
0fab3afea2 Moves static relay stats to a injectable object 2025-10-10 11:38:22 -04:00
Vitor Pamplona
0bc38c1fa4 Removes sub stats from the Subscription controller to move outside. 2025-10-10 11:34:25 -04:00
Vitor Pamplona
0c0410d1dd Adds an isValid function to all commands so we can check before sending 2025-10-10 11:33:18 -04:00
Vitor Pamplona
fa971b1822 Removes stats from Orchestrator to move out of the inner classes and be an option for app developers instead. 2025-10-10 11:31:32 -04:00
Vitor Pamplona
24f7d76dc4 Adds a Count reply message to relays 2025-10-10 11:17:43 -04:00
Vitor Pamplona
973cfd24fa Creates our own Coil Logger to minimize trash stacks in the logs 2025-10-10 10:56:25 -04:00
Vitor Pamplona
5c09cd0b55 Rejects additional urls with %20 and fixes Wss ones. 2025-10-10 10:55:46 -04:00
Vitor Pamplona
5975130824 Avoids logging at the BasicOkHttp level 2025-10-10 10:51:09 -04:00
Vitor Pamplona
0a23e23801 Moves quartz to Java 21 due to binary inconsistencies between the multiple builds of KMP (tests conflicting with main) 2025-10-10 10:49:35 -04:00
Vitor Pamplona
a0c1b01a4b Adds a build template option for NIP42 auths 2025-10-10 10:48:39 -04:00
Vitor Pamplona
0708936c23 Mocks the android log on test cases 2025-10-10 10:47:59 -04:00
KotlinGeekDev
ac60e3d221 String resources. 2025-10-09 20:13:00 +01:00
KotlinGeekDev
3c622f544c Make a method specially for cloning/copying. Refactor createListWithDescription(). 2025-10-09 19:55:40 +01:00
KotlinGeekDev
a8d01e3667 Put in the relevant code in PeopleListEvent. Use DescriptionTag instead of manual construction. 2025-10-09 17:02:42 +01:00
KotlinGeekDev
9aa5f1bd9f Add support for modifying follow set descriptions. 2025-10-09 16:56:40 +01:00
KotlinGeekDev
192a257780 Merge branch 'vitorpamplona:main' into followset-improvements 2025-10-08 01:53:14 +00:00
Vitor Pamplona
0045426840 Fixes a bug of not loading some notification message kinds 2025-10-06 19:15:48 -04:00
Vitor Pamplona
0ab7dd9797 Adds test cases for the new sub map filters. 2025-10-06 18:55:23 -04:00
Vitor Pamplona
a6e4a8d4fc Performance improvement for the address serializer. 2025-10-06 18:05:03 -04:00
Vitor Pamplona
5f59f1ab41 Adds submap queries to the addressable large cache 2025-10-06 18:04:37 -04:00
KotlinGeekDev
049430cd2e Merge branch 'vitorpamplona:main' into followset-improvements 2025-10-06 21:25:27 +00:00
Vitor Pamplona
b546294078 getOrCreateUser already checks for Hex64. No need to do it twice 2025-10-06 15:44:13 -04:00
Vitor Pamplona
456ef86c85 Speeding up OkHttp startup 2025-10-06 15:17:37 -04:00
Vitor Pamplona
b8ad2961b9 Speeds up loading of the Follow lists in start up by using the new isHex64 method 2025-10-06 15:04:12 -04:00
Vitor Pamplona
0348454e36 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-10-06 14:47:00 -04:00
Vitor Pamplona
b55cdda52a Makes the order of the Tor Relay settings match across data objects 2025-10-06 14:42:25 -04:00
Vitor Pamplona
8c93560058 Fixes trusted relay lists incorrectly only using DM relays 2025-10-06 14:41:56 -04:00
David Kaspar
72b800bfb4 Merge pull request #1514 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-10-06 19:19:40 +01:00
Crowdin Bot
4a6dbd4ba5 New Crowdin translations by GitHub Action 2025-10-06 18:18:10 +00:00
David Kaspar
52686c3488 Merge pull request #1513 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-10-06 19:16:56 +01:00
David Kaspar
d8613da3ad Merge branch 'main' into l10n_crowdin_translations 2025-10-06 19:16:34 +01:00
Crowdin Bot
bea393c4ef New Crowdin translations by GitHub Action 2025-10-06 17:34:07 +00:00
Vitor Pamplona
cf4104292c Merge pull request #1511 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-10-06 13:32:25 -04:00
Vitor Pamplona
deaaa30b92 Merge pull request #1512 from davotoula/fix-nwc-string-deleted-on-restart
BUGFIX: Fix nwc string deleted on restart
2025-10-06 13:32:13 -04:00
davotoula
1fdf869c05 create putOrRemove overload for Nip47URI? 2025-10-06 18:56:12 +02:00
davotoula
701acb004f remove unnecessary suspend 2025-10-06 18:52:14 +02:00
davotoula
341994e505 The putOrRemove(key: String, event: Any?) signature loses type information. When JsonMapper.toJson() tries to serialize, it sees Any? and can't find the serializer for Nip47URI, causing:
SerializationException: Serializer for class 'Any' is not found
2025-10-06 18:50:15 +02:00
KotlinGeekDev
c432a54561 Merge branch 'vitorpamplona:main' into followset-improvements 2025-10-06 10:40:57 +00:00
KotlinGeekDev
55198b1546 Move one last string to string resources. 2025-10-06 11:39:43 +01:00
Crowdin Bot
b9d369a88c New Crowdin translations by GitHub Action 2025-10-06 08:01:52 +00:00
David Kaspar
6c72c36b9a Merge pull request #1510 from davotoula/update-translations
Update local versions of CS, DE since crowdin only uses those?
2025-10-06 09:00:33 +01:00
David Kaspar
369aab2c0c Merge pull request #1509 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-10-06 09:00:14 +01:00
davotoula
e129796418 Update local versions of CS, DE since crowdin only uses those? 2025-10-06 09:54:40 +02:00
KotlinGeekDev
eb99febb2b Merge branch 'vitorpamplona:main' into followset-improvements 2025-10-04 06:48:59 +00:00
Crowdin Bot
da79d5b0ba New Crowdin translations by GitHub Action 2025-10-03 22:39:37 +00:00
Vitor Pamplona
0843d5db95 Separates EOSE for drafts alone. 2025-10-03 18:37:48 -04:00
Vitor Pamplona
df77bd6633 Only loads drafts if the account is writeable. 2025-10-03 18:18:16 -04:00
Vitor Pamplona
cf8c8e47a2 Creates an option to ignore reconnection relays that should be used when the network settings change. 2025-10-03 18:03:04 -04:00
Vitor Pamplona
26462a150e Makes sure the scope is cancelled when the NostrClient is done 2025-10-03 18:01:34 -04:00
Vitor Pamplona
66a5412961 Migrates fDroid flavor to the new version of the markdown 2025-10-03 17:08:16 -04:00
Vitor Pamplona
93f33cca70 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
# Conflicts:
#	gradle/libs.versions.toml
#	quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt
2025-10-03 16:46:03 -04:00
Vitor Pamplona
4b959577f0 Corrects follow list logs for performance and uses the right scope 2025-10-03 16:37:20 -04:00
Vitor Pamplona
88747ad1b7 Improves log messages in the relay services 2025-10-03 16:35:10 -04:00
Vitor Pamplona
13d05dc599 Loads the main account in parallel faster before the screen needs it 2025-10-03 15:59:43 -04:00
Vitor Pamplona
b002f9f37e Uses the new isHex64 in the ContactList 2025-10-03 15:58:38 -04:00
Vitor Pamplona
7994945209 30% Faster isHex for strings with 64 bytes. 2025-10-03 15:58:01 -04:00
Vitor Pamplona
9085f4bbea Merge pull request #1508 from greenart7c3/main
Fix sendAndWaitForResponse never receiving a response
2025-10-03 13:03:10 -04:00
greenart7c3
beff7e60b7 Fix sendAndWaitForResponse never receiving a response 2025-10-03 13:30:45 -03:00
KotlinGeekDev
e418a26f41 Add support for copying/cloning follow sets in the UI. 2025-10-03 16:26:20 +01:00
KotlinGeekDev
2df575d4af Modify the models to support creating a new list with several profiles. Use IO dispatcher in FollowSetState. Introduce follow set copying/cloning, with custom names/descriptions for the clones. 2025-10-03 16:02:15 +01:00
KotlinGeekDev
88fbee84dc Merge branch 'vitorpamplona:main' into followset-improvements 2025-10-03 14:39:45 +00:00
Vitor Pamplona
21c1d705a1 Only downloads DMs if the account is writeable 2025-10-03 09:18:53 -04:00
Vitor Pamplona
97668513b5 Fixes passing onClosed messages forward from NostrClient 2025-10-03 09:11:28 -04:00
Vitor Pamplona
f6213c2d9a Slightly better user profile post filter 2025-10-03 08:25:19 -04:00
Vitor Pamplona
85b234d372 Don't show default relays in the relay screen as a user-chosen list 2025-10-03 08:10:13 -04:00
Vitor Pamplona
28df59c00d Expand the number of notes tracked by the EOSE event to 10000 2025-10-02 17:55:43 -04:00
Vitor Pamplona
bc8e18a01b Initial test cases for NostrClient and extensions 2025-10-02 17:54:56 -04:00
Vitor Pamplona
1b36d7e189 Exposes the sample rate on BaseEoseManager class 2025-10-02 17:50:33 -04:00
Vitor Pamplona
5f83379459 Offers an interface to BaseEoseManager objects 2025-10-02 17:50:10 -04:00
Vitor Pamplona
90b682a38f Avoid sending close to subs that are already closed. 2025-10-02 17:48:06 -04:00
Vitor Pamplona
c6107dd784 Starts NostrClient in active instead of waiting for a connect() call 2025-10-02 17:47:39 -04:00
Vitor Pamplona
fd6be80b7e Offers a way to destroy subscriptions 2025-10-02 17:46:54 -04:00
Vitor Pamplona
f901f8ab8f Must use unlimited channels to avoid blocking the OkHttp threads. 2025-10-02 17:46:35 -04:00
Vitor Pamplona
b83ea61522 Moves OKHttp relay implementations to use a Channel in order to guarantee incoming message order. 2025-10-02 17:45:58 -04:00
Vitor Pamplona
168caf0ec6 Fixes comment 2025-10-02 17:42:33 -04:00
Vitor Pamplona
04f5e8738a Forces an updateFilter at the start of new subs. 2025-10-02 16:54:34 -04:00
Vitor Pamplona
c8f51380aa Fixes error messages in BasicRelayClient 2025-10-02 16:41:47 -04:00
Vitor Pamplona
612dd175b3 Creates the relay on sending new requests instead of waiting for updatePool method. 2025-10-02 16:40:45 -04:00
Vitor Pamplona
a56d9631ed Solves the issue of not connecting in the first time reconnect is called 2025-10-02 16:40:16 -04:00
Vitor Pamplona
1fd5ba0451 Adds ease of use functions for the downloadFirst Event extension method 2025-10-02 16:39:47 -04:00
Vitor Pamplona
132c00d434 Fixes suspending on coroutineScope and never returning bug 2025-10-02 16:39:20 -04:00
Vitor Pamplona
47cc8fc4ae Improves log class for JVM 2025-10-02 16:38:34 -04:00
David Kaspar
e12c52ec2c Merge pull request #1506 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-10-02 21:16:19 +01:00
Crowdin Bot
34cd26b004 New Crowdin translations by GitHub Action 2025-10-02 19:54:33 +00:00
Vitor Pamplona
1c82417a0a Updating the documentation on the use of Quartz 2025-10-02 15:53:10 -04:00
Vitor Pamplona
c3b87e83cb Merge pull request #1505 from davotoula/update-translations
update translations
2025-10-02 13:39:02 -04:00
davotoula
e72a588dab update CS, DE, SE, PT 2025-10-02 19:11:53 +02:00
David Kaspar
3033a4cbc8 Merge pull request #1504 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-10-01 17:10:27 +01:00
Crowdin Bot
ec82f89225 New Crowdin translations by GitHub Action 2025-10-01 15:30:00 +00:00
Vitor Pamplona
d7cf8305d3 Merge pull request #1503 from davotoula/smarter-video-compression--h265
Smarter video compression: h265
2025-10-01 11:28:34 -04:00
davotoula
7c41da0c4c Update lightcompressor to release 1.5.0 2025-10-01 16:57:21 +02:00
davotoula
59be0986eb Fix bug: useH265 parameter wasn't passed to compressor 2025-10-01 15:44:52 +02:00
davotoula
4554813d72 Add codec based multiplier (h265 = 0.7x) 2025-10-01 15:44:52 +02:00
davotoula
7d69122409 remove unused imports 2025-09-30 22:10:24 +02:00
davotoula
a6e306f3de Update to use lightcompressor library version that supports h265
Add h265 toggle to NewMediaView.kt and ImageVideoDescription.kt
Touch many files to add optional h265 boolean (default is false)
2025-09-30 22:02:18 +02:00
KotlinGeekDev
f6b3fdcab4 Refactor UI to adapt to correct way of managing follow sets. 2025-09-30 15:38:16 +01:00
KotlinGeekDev
dfb683e00d Continue refactoring of models to make things correct. 2025-09-30 15:37:02 +01:00
Vitor Pamplona
bb1d805080 Removes the need to use kotlinx.datetime on quartz 2025-09-29 18:06:58 -04:00
Vitor Pamplona
c936e91ec8 Merge pull request #1499 from KotlinGeekDev/follows-and-followsets-unified
Unification of follows and follow sets.
2025-09-29 13:47:39 -04:00
Vitor Pamplona
5d2e718b93 Merge pull request #1502 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-29 13:47:31 -04:00
Crowdin Bot
4cffd6d868 New Crowdin translations by GitHub Action 2025-09-29 17:25:18 +00:00
Vitor Pamplona
26005a062d Updates Quartz instructions to Maven 2025-09-29 13:23:57 -04:00
KotlinGeekDev
80906f2c5a Refactor models to take into account mixed lists, due to misunderstanding on my part. 2025-09-29 11:17:12 +01:00
Vitor Pamplona
631f1d9cfc Makes the relayDB not private in HintIndexer 2025-09-26 16:10:30 -04:00
Vitor Pamplona
a8a6a10d6e With filter serialization, prints kinds before anything else to help debug 2025-09-26 16:10:04 -04:00
Vitor Pamplona
401a3c47d9 Removes unnecessary log of object 2025-09-26 16:09:33 -04:00
Vitor Pamplona
ff0a4eb88f Fixes userList of the FollowList state 2025-09-26 16:09:07 -04:00
Vitor Pamplona
1384a68ee3 Dumps the number of relay hints urls in the DB 2025-09-26 16:08:37 -04:00
Vitor Pamplona
86e50ac984 Logs the early start of the Application for benchmarking purposes 2025-09-26 16:08:22 -04:00
KotlinGeekDev
d2d811f670 Just use remember{} instead of adding derivedStateOf{} to avoid weird state issues. 2025-09-26 16:21:13 +01:00
KotlinGeekDev
61459187d7 Fix duplicate copyright and use derivedStateOf for followSet presence checking variable. 2025-09-26 15:46:29 +01:00
KotlinGeekDev
509d703226 Merge branch 'vitorpamplona:main' into follows-and-followsets-unified 2025-09-26 12:20:47 +00:00
Vitor Pamplona
194ba08df0 Merge pull request #1498 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-26 08:20:19 -04:00
KotlinGeekDev
bf1188addf Merge branch 'vitorpamplona:main' into follows-and-followsets-unified 2025-09-26 12:12:28 +00:00
Crowdin Bot
7ddce17bdd New Crowdin translations by GitHub Action 2025-09-26 12:07:45 +00:00
Vitor Pamplona
ee8ac08845 Merge pull request #1496 from davotoula/smarter-video-compression--sub-1mbps
Smarter video compression - switch to LightCompressor-enhanced
2025-09-26 08:06:19 -04:00
KotlinGeekDev
45192d1cf7 Remove statements to avoid polluting the logs. Modify the timing on the flow producer. 2025-09-26 12:32:44 +01:00
KotlinGeekDev
9c024e334b Replace first hack with a better solution for determining a follow.
Add FollowSetState to MergedFollowListsState and modify it to take into account users from follow sets when displaying a user's follows feed.
2025-09-26 12:24:42 +01:00
KotlinGeekDev
058278956b Merge remote-tracking branch 'origin/follows-and-followsets-unified' into follows-and-followsets-unified 2025-09-26 10:55:48 +01:00
KotlinGeekDev
564cebea9d Merge branch 'vitorpamplona:main' into follows-and-followsets-unified 2025-09-26 09:55:31 +00:00
davotoula
4fc9386393 Combine calculation of videoBitrateInBps, resizer to use compressionRules only once 2025-09-25 23:06:16 +02:00
KotlinGeekDev
6368d17d09 Refactor: Create FollowSetState and move functionality from other places to it. Modify usages accordingly. 2025-09-25 21:39:02 +01:00
KotlinGeekDev
b694e143d0 Finish renaming refactor. 2025-09-25 21:37:33 +01:00
KotlinGeekDev
4b60c3e20b Refactor: From List -> Set, and Viewmodel rename as well. Move FollowSet and related classes to model package. 2025-09-25 21:36:34 +01:00
davotoula
6e4e432adf Use new bps api of lightcompressorlibrary fork 2025-09-25 22:01:25 +02:00
davotoula
4c3eaf972c move to LightCompressor-enhanced fork
corrected image vs video compressor library names
2025-09-25 22:01:25 +02:00
KotlinGeekDev
ba0fc67a94 First hack for bolting follow sets onto follows, for determining if a user follows another. 2025-09-25 18:24:31 +01:00
Vitor Pamplona
f31292a201 Updates compose, navigation, windowCore and tests 2025-09-25 12:59:24 -04:00
Vitor Pamplona
1f6135d45d Updates markdown parser to the latest 2025-09-25 12:53:45 -04:00
Vitor Pamplona
d04cf97418 Better logs on BasicRelayClient 2025-09-24 19:24:27 -04:00
Vitor Pamplona
8466020137 Increase the delay to update filters 2025-09-24 14:34:35 -04:00
Vitor Pamplona
54afc2c4dc Remove logs 2025-09-24 14:34:24 -04:00
Vitor Pamplona
ed7fa2a80f removes log 2025-09-24 12:43:15 -04:00
Vitor Pamplona
06f94150f9 Okhttp log helper class. 2025-09-24 12:43:04 -04:00
Vitor Pamplona
361e46c99f Limits the number of posts coming from big relays with too many authors to 500 max 2025-09-24 12:38:48 -04:00
Vitor Pamplona
6ba60dce3f Throws an error if the RelayStats cannot be created. 2025-09-24 12:38:01 -04:00
Vitor Pamplona
f9e0c82fc8 No need to import 500 communities for each relay it connects to 2025-09-24 12:37:35 -04:00
Vitor Pamplona
45b7b5601b Ups the max requests thread allowed to support people with over 1000 follows 2025-09-24 12:37:04 -04:00
Vitor Pamplona
ccf64687a8 Separates index relays from the default and put it into the hint-based queries for unkown users. 2025-09-24 12:36:39 -04:00
Vitor Pamplona
0ba3385771 Merge pull request #1494 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-24 08:41:08 -04:00
Vitor Pamplona
381692a0a2 Merge pull request #1491 from KotlinGeekDev/set-management-fixes
Set management UI fixes
2025-09-24 08:40:58 -04:00
Crowdin Bot
206f8847fc New Crowdin translations by GitHub Action 2025-09-24 12:39:55 +00:00
Vitor Pamplona
5b969d92f3 Merge pull request #1492 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-24 08:38:36 -04:00
Vitor Pamplona
621290ec33 Merge pull request #1493 from greenart7c3/main
Show a dialog to select a signer when using multiple signers
2025-09-24 08:38:20 -04:00
Crowdin Bot
94a2d9e07d New Crowdin translations by GitHub Action 2025-09-24 12:19:05 +00:00
Vitor Pamplona
8b60aeaa94 Merge pull request #1489 from davotoula/smarter-video-compression
Smarter video compression
2025-09-24 08:17:33 -04:00
greenart7c3
6391ddbbaa Show a dialog to select a signer when using multiple signers 2025-09-24 09:09:51 -03:00
KotlinGeekDev
6ac2a7150f Change appearance of CustomSetItem. Use horizontal dividers instead of rounded borders. Adjust padding. 2025-09-24 11:50:15 +01:00
KotlinGeekDev
ab6d0961e8 Even more size and color adjustments. 2025-09-24 11:37:25 +01:00
KotlinGeekDev
3c87925b38 Make border and padding adjustments to some components. 2025-09-24 10:57:44 +01:00
KotlinGeekDev
54963067aa Adjust colors for some components. 2025-09-24 09:55:53 +01:00
davotoula
ec9d229392 Merge branch 'main' into smarter-video-compression 2025-09-23 23:25:23 +02:00
Vitor Pamplona
ce81157dad Adds a maven publishing to ship quartz 2025-09-23 15:16:26 -04:00
Vitor Pamplona
0499dcc647 Refining the test case to avoid json comparisons 2025-09-23 13:57:34 -04:00
Vitor Pamplona
748085078d Fixes imports for Tlv tests 2025-09-23 13:46:04 -04:00
Vitor Pamplona
fadd9c1940 Creates a shared JVM-test module and moves the jackson inlining tests there 2025-09-23 13:45:48 -04:00
Vitor Pamplona
313e2519b2 Fixes the fdroid version of the new translation component api 2025-09-23 13:33:55 -04:00
Vitor Pamplona
d8c403d3d5 Removes the list view model from the LoginPage 2025-09-23 13:26:04 -04:00
Vitor Pamplona
b30d87266d Improves borders for zap messages in notifications 2025-09-23 13:11:15 -04:00
Vitor Pamplona
f9204d37f2 Updates vico charts 2025-09-23 12:36:12 -04:00
Vitor Pamplona
d3f1db4367 Updates std lib 2025-09-23 12:34:02 -04:00
Vitor Pamplona
9851c4b6be Updates firebase and lifecycleRuntime 2025-09-23 11:32:48 -04:00
Vitor Pamplona
811ad5e00a Merge pull request #1169 from KotlinGeekDev/profiles-list-management
List/Set management: Part 1 - Profiles.
2025-09-23 11:20:21 -04:00
KotlinGeekDev
f2ca1d78da Merge branch 'vitorpamplona:main' into profiles-list-management 2025-09-23 14:00:49 +00:00
KotlinGeekDev
00d5e2fed3 Move strings to stringResources. Refactor names for consistency. Some cleanup. 2025-09-23 14:51:05 +01:00
David Kaspar
807eda8401 Merge pull request #1490 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-23 13:21:59 +01:00
Crowdin Bot
6cffda09bb New Crowdin translations by GitHub Action 2025-09-23 11:59:59 +00:00
Vitor Pamplona
1c0766cc76 Merge pull request #1480 from davotoula/add-feed-indicator-to-live-bubbles
Add feed indicator to live bubbles
2025-09-23 07:58:26 -04:00
KotlinGeekDev
5175c61171 Merge branch 'upstream-main' into profiles-list-management 2025-09-23 12:24:28 +01:00
KotlinGeekDev
3f8abd74ba Merge branch 'main' of https://github.com/vitorpamplona/amethyst into upstream-main 2025-09-23 12:22:45 +01:00
KotlinGeekDev
04b8f6cca2 Move Fab back action button to separate component. Adjust colors for the fabs in CustomListsScreen. 2025-09-23 12:19:04 +01:00
KotlinGeekDev
ec10d53195 Add menu option for adding an author to a follow set from a note. 2025-09-23 12:16:58 +01:00
davotoula
ccc5d03d84 converted VideoCompressionHelper to singleton 2025-09-23 11:28:09 +02:00
davotoula
e5df870ace move framerate adjustment into getBitrateMbpsInt to prevent ,precision errors
Clean up logging
2025-09-23 10:24:48 +02:00
davotoula
b030486e7e fixes after rebase 2025-09-23 09:55:03 +02:00
davotoula
544d6d3933 remove plan 2025-09-23 09:25:20 +02:00
davotoula
db9160a6c2 finally block ensures release() is called even if exceptions occur 2025-09-23 09:25:20 +02:00
davotoula
41a233d175 fix for library bug. Temp file name with "_temp" is returned instead of final file name 2025-09-23 09:25:19 +02:00
davotoula
b6dd6a988b change to enum for video resolutions 2025-09-23 09:25:19 +02:00
davotoula
9af1e10ec2 refactor:
Safe file size lookup using OpenableColumns.SIZE
if (continuation.isActive) before resuming.
Better logging levels (Log.e for errors, Log.w for warnings).
Configurable compression timeout
2025-09-23 09:25:19 +02:00
davotoula
ed758ef13e remove unused code 2025-09-23 09:25:18 +02:00
davotoula
947f59fa6f add bitrate multiplier for 60fps content
fix toast when new file larger than old
2025-09-23 09:25:18 +02:00
davotoula
524ead2eac move video compression into helper 2025-09-23 09:25:16 +02:00
davotoula
49dc63c876 Sanity check: if compressed file is larger than original, return original 2025-09-23 09:24:25 +02:00
davotoula
8588c9a6fc update plan 2025-09-23 09:24:25 +02:00
davotoula
f805b70ec0 added log statements and a toast 2025-09-23 09:24:22 +02:00
davotoula
f5202dd8a9 added log statements 2025-09-23 09:22:13 +02:00
davotoula
97d6c791c0 determine resizer and bitrate from input source combined with our compression rules
use 1 as lowest BitrateMbps
2025-09-23 09:22:09 +02:00
davotoula
87f224b6b8 get video resolution, framerate and rotation 2025-09-23 09:21:32 +02:00
davotoula
931c6681c6 added compression rules 2025-09-23 09:21:32 +02:00
davotoula
96b8c77bcd there is a plan 2025-09-23 09:21:32 +02:00
David Kaspar
366534d1d5 Merge pull request #1488 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-23 08:11:41 +01:00
Crowdin Bot
054cfa0de9 New Crowdin translations by GitHub Action 2025-09-23 00:07:59 +00:00
Vitor Pamplona
d93a8d94d2 Reduces number of Notifications from random relays. 2025-09-22 20:05:07 -04:00
Vitor Pamplona
0026668aca Merging with upstream 2025-09-22 19:49:26 -04:00
Vitor Pamplona
7a280a3cde - Migrates Quartz from Android to CommonMain
- Fully converts OpenTimestamp Java codebase to Kotlin, migrating the sync and async HTTP call interfaces to OkHttp and coroutines
- Redesigns parsing of relay commands, messages and filters for performance in Jackson.
- Starts the use of KotlinX Serialization when speed is not a requirement
- Migrates all Jackson field annotations to Kotlin Serialization
- Migrates Regex use in Quarts to Kotlin's Regex class
- Migrates Base64 from Android to Kotlin
- Migrates UUID from Android/Java to Kotlin
- Migrates LRUCache usage from Android/Java to Kotlin collections
- Migrates all String to bytearray conversions to Kotlin methods
- Migrates all System.arraycopy calls to kotlin native ones.
- Separates parsing code from the data classes Companion objects
- Exposes Rfc3986 normalizations to each platform.
- Exposes URI parsing classes to each platform.
- Exposes URL Encoders to each platform.
- Exposes BigDecimal to each platform.
- Exposes the Url Detector to each platform.
- Exposes MacInstances to each platform
- Exposes Diggest instances to each platform.
- Exposes a BitSet to each platform.
- Exposes GZip to each platform.
- Exposes Secp256k1 to each platform.
- Exposes SecureRandom to each platform.
- Exposes Time in seconds to each platform.
- Exposes the LargeCache to each platform.
- Exposes AES CBC and AES GCM encryption/decryption to each platform
- Migrate test assertions to Kotlin Tests
- Exposes Address class to each platform because of the Parceleable
- Creates our own ByteArrayOutputStream.
- Removes Lock features inside the Bloomfilters because we don't need data consistency/
- Migrates UserMetadata parser from Jackson to Kotlin serialization
- Removes the need for Static methods in each tag.
- Adds an event template serializer
- Adds KotlinX Datetime to migrate some of the date-based logs
- Adds support for LibSodium in the JVM platform
- Creates a shared test build for iOS targets
- Fixes several usages of Reflection when serializing classes
- Fixes a bug on loading RelayDB for the HintBloom filter test
- Increases the Bloom filter space to better use hints in the app.
- Removes support for iOS in x86
- Creates a Jackson mapper just for NIP-55, which stays in the Android build only.
- Keeps the event store in the android build as well.
- Removes @Syncronized tags in favor of Mutexes.
- Improved sendAndWaitForResponse NostrClient method to properly account for returns from each relay.
- Removes the need for GlobalScope and async calls in the downloadFirstEvent method.
- Restructures the parser and serialization of the relay messages and commands for performance with Jackson
- Removes the dependency on Jackson's error classes across the codebase.
- Moves the hint to quote tag extension methods to their own packages.
- Speeds up the generation of Bech32 addresses
- Migrates NIP-6 and Blossom uploads to use Kotlin Serialization
2025-09-22 19:44:51 -04:00
KotlinGeekDev
3eb02cbbd8 implement a fix for when a follow set becomes empty(or is empty), which is probably a Nostr limitation. 2025-09-22 15:44:11 +01:00
KotlinGeekDev
1e18edfc02 Refactor FollowSetsManagementDialog UI to correctly adapt to follow set state. Small adjustment in the NostrUserListFeedViewModel. 2025-09-22 15:41:17 +01:00
KotlinGeekDev
9f5301d30a Make a different menu style for new list creation in FollowSetsManagementDialog. Use toBestDisplayName() instead of manual method(which was the same anyways). 2025-09-22 14:24:15 +01:00
KotlinGeekDev
a07ebc6e12 Update addFollowSet() method to optionally create a list with first member. 2025-09-22 14:20:59 +01:00
KotlinGeekDev
3301371d82 Fix color of Fabs in CustomListsScreen. 2025-09-22 14:19:10 +01:00
Vitor Pamplona
7949fc66b2 Fixes test case cast 2025-09-22 09:17:54 -04:00
davotoula
1d0f584c3e use produceState instead of LaunchedEffect 2025-09-22 13:58:48 +02:00
KotlinGeekDev
6a3ebc1d95 Move FollowSetsManagementDialog to correct package. Refactor accordingly. 2025-09-19 18:22:41 +01:00
KotlinGeekDev
e10c429de0 Completely change the UI for list management when adding/removing a profile from lists. 2025-09-19 18:15:18 +01:00
KotlinGeekDev
e4a8a512d2 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-09-19 15:23:13 +00:00
David Kaspar
8373f6ba19 Merge branch 'vitorpamplona:main' into add-feed-indicator-to-live-bubbles 2025-09-18 21:12:14 +01:00
Vitor Pamplona
d2d5ade84e Merge pull request #1485 from davotoula/fix-for-orientation-issue-following-removing-windowSizeClass-from-global-settings
Fix for properly switch between single and two-pane layouts on rotation
2025-09-18 14:45:23 -04:00
Vitor Pamplona
3acdde7c53 Merge pull request #1483 from davotoula/reduce-sonar-code-quality-warnings
Reduce sonar code quality warnings
2025-09-18 14:45:02 -04:00
Vitor Pamplona
897396ff24 Merge pull request #1484 from davotoula/remove-void-cat-from-default-servers
remove void.cat from default servers
2025-09-18 14:44:00 -04:00
davotoula
ee39b3ae18 Fix for properly switch between single and two-pane layouts on rotation 2025-09-18 20:37:56 +02:00
David Kaspar
6c892ba733 Merge branch 'vitorpamplona:main' into add-feed-indicator-to-live-bubbles 2025-09-18 15:55:20 +01:00
davotoula
64cf81b523 remove void.cat from default servers 2025-09-18 16:33:32 +02:00
KotlinGeekDev
acade3c762 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-09-18 13:32:24 +00:00
davotoula
4cd86c8b1c return the boolean results from file(npub).delete() 2025-09-18 15:16:40 +02:00
davotoula
fdf372719c Replace with interface delegation using "by" in the class header
Replace abstract class with an interface
2025-09-18 15:16:40 +02:00
davotoula
50f173e406 replace repeated text string with top-level constant 2025-09-18 15:16:40 +02:00
Vitor Pamplona
edc7f49ce2 Merge pull request #1482 from davotoula/investigate-disappearing-ping-in-ms-on-relays-screen
Fix disappearing stats on relay screen
2025-09-18 08:12:06 -04:00
davotoula
b20eb860ff Increase LruCache max size from 100 to 1,000 since we have so many more relays active with outbox 2025-09-17 20:38:40 +02:00
Vitor Pamplona
aba60a87d6 Merge pull request #1481 from davotoula/remove-unused-imports
Remove unused imports and fix a test
2025-09-17 12:16:04 -04:00
davotoula
4f3b7b24f2 remove unused imports 2025-09-17 16:19:40 +02:00
davotoula
6f44d7c28a remove unused imports amethyst
Fix ThreadDualAxisChartAssemblerTest (not sure how it was not breaking the build)
2025-09-17 16:10:23 +02:00
Vitor Pamplona
a96ab764b3 Updates all dependencies 2025-09-17 09:08:49 -04:00
Vitor Pamplona
1be46b7167 Merge pull request #1478 from davotoula/reduce-max-requets-when-in-emulator
Reduce max requests when in emulator
2025-09-17 09:06:14 -04:00
Vitor Pamplona
5c601d74b9 Merge branch 'main' into reduce-max-requets-when-in-emulator 2025-09-17 09:06:00 -04:00
davotoula
2027c7d77d use default settings (64) when in emulator 2025-09-17 15:01:46 +02:00
Vitor Pamplona
faffe2a936 About 30-40% event hashing performance boost 2025-09-17 08:40:06 -04:00
Vitor Pamplona
c375fa0ab8 Adds another catch to get object errors when parsing NIP-28 metadata 2025-09-17 08:40:06 -04:00
Vitor Pamplona
123eca1f90 Fixes NPE on the invoice parser 2025-09-17 08:40:06 -04:00
Vitor Pamplona
83e87c2cf4 Adds an event id hash check that doesn't create a separate bytearray to compare between the incoming hex from the relay and the recalculated byte array from SHA256. 2025-09-17 08:40:06 -04:00
davotoula
f4501d799d Added withContext(Dispatchers.IO) for network call 2025-09-17 14:29:42 +02:00
davotoula
5796052f36 added live/offline status indicator for the DisplayLiveBubbles method
Checks streaming URLs for LiveActivitiesChannel and relay URLs for both channel types
2025-09-17 14:29:42 +02:00
davotoula
c1012bd859 let's remove the maxRequestsPerHost increase, can be done in a separate PR 2025-09-17 11:22:28 +02:00
davotoula
6440b86a5e remove commented out code 2025-09-17 11:10:40 +02:00
davotoula
dddf65e60f Added test for emulator to set maxRequests to 32 to prevent crashing emulator
Increased maxRequestsPerHost in release version to 20 from default 5 to allow for more concurrent connections to a relay (subscribing to timeline + mentions + DMs + profile updates + notifications, etc..)
2025-09-17 11:10:10 +02:00
David Kaspar
80c884a4d1 Merge pull request #1477 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-17 06:10:11 +01:00
Crowdin Bot
497b019093 New Crowdin translations by GitHub Action 2025-09-16 20:57:54 +00:00
Vitor Pamplona
03b320e5b7 Merge pull request #1476 from davotoula/fix-benchmark-apk-location
resolve intermittent CI build failures - fix benchmark apk location
2025-09-16 16:56:43 -04:00
davotoula
00c0472155 remove second identical(?) test run 2025-09-16 18:38:46 +02:00
davotoula
8a66276755 fix benchmark apk location 2025-09-16 18:06:14 +02:00
KotlinGeekDev
6eab098f2e Merge branch 'vitorpamplona:main' into profiles-list-management 2025-09-16 13:02:13 +00:00
Vitor Pamplona
eaf8cb3384 Don't check for the max size in string anymore 2025-09-15 17:25:47 -04:00
Vitor Pamplona
cd0b1b65fb Merge expanding and checking Hmac functions to avoid re-creating the Mac instance. 2025-09-15 17:25:36 -04:00
Vitor Pamplona
e329b096ba Moves the message key to the HKDF function 2025-09-15 16:21:11 -04:00
Vitor Pamplona
239cac430e Adds a test for native vs libsodium chacha20 2025-09-15 16:15:07 -04:00
Vitor Pamplona
24a5750155 Generalizes fixed key setup 2025-09-15 16:14:43 -04:00
Vitor Pamplona
cb5299e265 Simple refactoring 2025-09-15 16:14:09 -04:00
Vitor Pamplona
c3c8b87b91 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-09-15 15:15:56 -04:00
Vitor Pamplona
98aaef61c7 Faster Hkdf functions with less array copying and allocations (which can be impactful if the ciphertext is large) 2025-09-15 15:15:44 -04:00
KotlinGeekDev
1d3dadace2 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-09-15 18:39:36 +00:00
Vitor Pamplona
bd70058f78 Avoids creating a byte array and recreating it again inside of the Mac instance. 2025-09-15 12:30:56 -04:00
Vitor Pamplona
67f9557081 Reduces the need to keep the memory for checking the Mac and decrypting at the same time, allowing the GC to run after the check was correct. 2025-09-15 12:30:26 -04:00
Vitor Pamplona
689066fd44 Benchmark for NIP-44 operations 2025-09-15 12:29:26 -04:00
David Kaspar
cdd5995513 Merge pull request #1474 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-15 17:11:16 +01:00
Crowdin Bot
0c4cd4f940 New Crowdin translations by GitHub Action 2025-09-15 16:09:54 +00:00
Vitor Pamplona
c84554576c Fixes flow.value warnings. 2025-09-15 12:08:04 -04:00
Vitor Pamplona
7616d01166 Implements NIP-44 extension for bigger payloads https://github.com/nostr-protocol/nips/pull/1907 2025-09-15 11:58:43 -04:00
Vitor Pamplona
60b71a5561 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-09-15 08:51:41 -04:00
Vitor Pamplona
a7b89cb778 Removing unused imports 2025-09-15 08:50:16 -04:00
David Kaspar
6b18711fe6 Merge pull request #1473 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-15 13:49:59 +01:00
Crowdin Bot
f0b95a3593 New Crowdin translations by GitHub Action 2025-09-15 12:42:18 +00:00
Vitor Pamplona
52cf9e1576 Merge pull request #1468 from davotoula/gallery-view-for-multiple-media
Gallery view for multiple images
2025-09-15 08:40:48 -04:00
davotoula
43f8053b30 Extracted Parsing logic into helper class 2025-09-14 13:19:12 +02:00
davotoula
74cc9535ac Added a data structure for ParagraphImageAnalysis
Single pass analysis
All image-related decisions are made based on the single analysis result
2025-09-14 12:12:44 +02:00
davotoula
7e80ed2af9 Fix for images are mixed with text in a single paragraph
nevent1qqs04pmdf8guxpyhjuvpeg72ccwuzje7vhlm09szxl2d8x28cse5fzgpz9mhxue69uhkummnw3ezuamfdejj7q3qwl89d7yazg500lehg08p45dj2jzhhyqg2erj067458e3wd30djnsxpqqqqqqzmdjgxd
2025-09-14 11:40:20 +02:00
davotoula
1788dfc0b9 Merge branch 'refs/heads/main' into gallery-view-for-multiple-media 2025-09-13 23:32:49 +02:00
davotoula
8f0a829a4d Optimised collectConsecutiveImageParagraphs: continue statements for early loop continuation 2025-09-13 23:32:17 +02:00
davotoula
bfc920ca04 Added logging of image gallery computation time 2025-09-13 21:09:27 +02:00
davotoula
a21b6115c7 Single pass to avoid multiple intermediate lists
O(n) traversal (no drop/takeWhile/filter allocations)
Mutable buffer for collecting images
2025-09-13 20:44:00 +02:00
davotoula
8e39da347a don't use image gallery for secret message 2025-09-13 18:16:43 +02:00
Vitor Pamplona
58277cf717 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-09-13 10:51:28 -04:00
Vitor Pamplona
7ef6da1a17 Merge pull request #1470 from davotoula/fix-for-trying-to-share-when-image-not-loaded
Fix for crash when attempting to share an image that is still loading
2025-09-13 10:51:11 -04:00
Vitor Pamplona
ec48977a6f Merge pull request #1471 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-13 10:50:29 -04:00
Vitor Pamplona
c5b60c3bd8 Refactor the UISharedPreferences into two files 2025-09-13 10:50:19 -04:00
Crowdin Bot
613978d7ba New Crowdin translations by GitHub Action 2025-09-13 14:48:30 +00:00
Vitor Pamplona
afd20d3f8f Avoids using JSON parsers with DataStore to speed up loading time (loading the parser itself takes ~300ms) 2025-09-13 10:46:27 -04:00
KotlinGeekDev
ea07e52e63 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-09-12 22:52:03 +00:00
Vitor Pamplona
da469754c4 Adds a All User Follows to fix: https://github.com/vitorpamplona/amethyst/issues/1431 2025-09-12 16:18:12 -04:00
davotoula
6dd2f47fc9 catch exception when sharing image
Show toast
2025-09-12 21:39:57 +02:00
Vitor Pamplona
47d201f936 Fixes unnecessary typecasting from long to int in created ats 2025-09-12 15:08:10 -04:00
Vitor Pamplona
46fec1d480 Adds a payment failed enum 2025-09-12 14:24:11 -04:00
Vitor Pamplona
557ba959e2 Fixes crash on dont translate from 2025-09-12 14:17:41 -04:00
Vitor Pamplona
e121b2849c Fixes subject calculations on rendering 2025-09-12 13:58:37 -04:00
Vitor Pamplona
2e16a1dca6 Fixes a bug when comparing int and long in the comparator 2025-09-12 13:36:25 -04:00
Vitor Pamplona
33155efa99 Bugfix in loading communities from Global 2025-09-12 12:52:36 -04:00
Vitor Pamplona
bd3eed4a29 No need to duplicate these relays anymore 2025-09-12 12:52:13 -04:00
Vitor Pamplona
ac48705980 Fixes showing blog posts in the future 2025-09-12 12:39:12 -04:00
Vitor Pamplona
bc10d7184f Fixes mark as read when drafts are the latest message in the chat 2025-09-12 11:37:58 -04:00
Vitor Pamplona
0886af4386 Migrates memory counters from Long to Int 2025-09-12 11:11:16 -04:00
Vitor Pamplona
3628091511 Applying Memory trimming service to all logged in accounts. 2025-09-12 10:09:25 -04:00
KotlinGeekDev
f5061e470a Merge upstream-adjusted changes. 2025-09-12 14:20:36 +01:00
KotlinGeekDev
a0ba3802d9 Merge upstream changes and adjust. 2025-09-12 14:14:52 +01:00
KotlinGeekDev
4118e3d45d Initialize NostrUserListVM at LoggedInPage level, to reuse it at different places(in prep for other UI changes). 2025-09-12 13:48:59 +01:00
KotlinGeekDev
f850d5291e Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2025-09-12 13:37:53 +01:00
David Kaspar
cb3bc605bc Merge pull request #1469 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-12 11:49:40 +01:00
davotoula
16e3cba651 found a place where image gallery was used for single images 2025-09-12 12:14:05 +02:00
davotoula
ee9e102eb2 removed lazy component from 20+ images case to avoid nesting lazy components 2025-09-12 10:54:17 +02:00
Crowdin Bot
f01895b3e9 New Crowdin translations by GitHub Action 2025-09-11 23:05:11 +00:00
Vitor Pamplona
00a3918d20 Fixes F-droid build 2025-09-11 19:02:58 -04:00
Vitor Pamplona
35a123a185 No need for this log 2025-09-11 18:53:03 -04:00
Vitor Pamplona
c9dc30d976 - Only shows live stream bubbles that are not 24/7
- Only shows user pictures of the active users in the past 15 minutes.
2025-09-11 18:52:14 -04:00
Vitor Pamplona
c06c03928e Fixes the use of index relays and search relays to load users and events as well as become the default for global feeds. 2025-09-11 18:36:42 -04:00
Vitor Pamplona
1f8d0295d5 - Separates Application dependencies into an AppModules class to create only after the OnCreate event.
- Switches TorSettings to be per Application and not per Account anymore
- Since TorSettings is now global, moves the okHttpClient determinations out of the Account-based classes into the Application.
- Since TorSettings is now global, set's up Coil's image loader only once when creating the Application
- Moves UISettings state to App Modules instead of viewmodel
- Migrates TorSettings and UISettings to DataStore
- Accounts now have their own coroutine scopes to allow cancellation when they are unloaded/logged off
- New tor evaluator service for relay connections now uses all account's trusted relays and dm relays at the same time.
- Migrates composable-state-based UISettings to Flow-based UI settings, while observing connectivity status
- Removes the displayFeatures and windowSizeClass from the shared model
- Fixes not requesting Notification Permissions for APIs older than Tiramisu in the FDroid flavor
- Moves the NIP-11 document cache from singleton to the App Modules
- Avoids using AccountViewModel to check NIP-11 Relay documents
- Moves the UI Settings usage in composables to functions that do not observe the state since they don't need to refresh the screen when changed.
- Refactors UI Settings screen to separate components and remove the sharedViewModel
- Only starts Internal Tor if that option is selected in the TorSettings.
- Turn TorSettings into a data class to observe changes to it
- Drops the SharedPreferences ViewModel to use UISettingsFlow directly from App Modules
- Reorganizes OTS Events after simplification of the OkHttp based on TorSettings.
2025-09-11 18:04:10 -04:00
davotoula
8f1027b55d refactor
Removed duplicate GalleryImage boilerplate.
Use .chunked()
Pull out constants (ASPECT_RATIO, IMAGE_SPACING).
2025-09-11 21:52:36 +02:00
davotoula
4dace5aaf8 refactor using takeWhile
Fix edge case with white space before images
2025-09-11 21:03:06 +02:00
davotoula
8cb9d13567 Use constrained LazyVerticalGrid with calculated height for more than 20 images for performance reasons
added vertical spacing for some padding
Fixing scrollable gallery issue
2025-09-11 21:01:12 +02:00
davotoula
f092326dcd Fixing scrollable gallery issue 2025-09-11 20:58:39 +02:00
davotoula
1f6d7d3fd2 Simplify RenderRegularWithGallery
Simplify collectConsecutiveImageParagraphs: just return the end index
Extracting common signature parameters into a RenderContext data class
Extracting GalleryImage helper function
2025-09-11 20:58:15 +02:00
davotoula
062182a7ec fix for when images are separated by empty lines
refactor to reduce duplication:
Extract RenderParagraphWithFlowRow
Extract collectConsecutiveImageParagraphs
2025-09-11 20:50:21 +02:00
davotoula
9534582141 Collect batches of images
Send them to new image gallery
2025-09-11 20:25:05 +02:00
Vitor Pamplona
a078df6159 Fixes parameters on the findEarliestOtsForNote 2025-09-11 11:28:46 -04:00
Vitor Pamplona
90a5c15ee9 Moves the OTS processor from Account's decrypt process to Application since it doesn't need the account information. 2025-09-11 11:27:29 -04:00
Vitor Pamplona
bb9a03ec5b Fixes invalid hexes on preview 2025-09-11 11:23:45 -04:00
Vitor Pamplona
825d890921 Removes the otsFinding method from accountViewModel, since it is not account dependent. 2025-09-11 11:19:27 -04:00
Vitor Pamplona
05c364eff4 removes unnecessary import 2025-09-11 11:17:08 -04:00
Vitor Pamplona
404d0ab40e Removes the dependency on windowSizeClass and displayFeatures to be passed in the global settings of the app (which is activity independenty) 2025-09-11 11:16:26 -04:00
Vitor Pamplona
bf88e376de Generalizes the dependencies from Application to Context 2025-09-11 11:12:20 -04:00
Vitor Pamplona
1e74a5769d Passes appcontext down on MediaSession Pool to avoid the direct dependency on Amethyst 2025-09-11 11:09:36 -04:00
Vitor Pamplona
e1f7fae70e Removes dependency in the Amethyst class from Playback calculator 2025-09-11 11:08:04 -04:00
Vitor Pamplona
5127907acd Makes the root okhttp client for an Empty manager a lazy property to allow loading in Preview screens 2025-09-11 11:07:00 -04:00
Vitor Pamplona
2eda4d40e8 Makes max exoplayer pool size to a variable in the class 2025-09-11 11:06:09 -04:00
Vitor Pamplona
7f428a9976 Switches account cache to a flow 2025-09-11 11:05:03 -04:00
Vitor Pamplona
4347a1b104 Generalizes the type of OtsResolver needed by OtsState 2025-09-11 11:03:05 -04:00
Vitor Pamplona
abd6e6f2ac Makes the torControl option nullable to allow an Orbot-based Active flow 2025-09-11 11:02:25 -04:00
Vitor Pamplona
1af5daffcf Fixes the default Tor Settings 2025-09-11 10:58:35 -04:00
Vitor Pamplona
0e04425095 Fixes the option switching animations when loading the screen. 2025-09-11 10:57:49 -04:00
Vitor Pamplona
bbbdc05243 Fixes animations when selecting Tor options in the privacy screen 2025-09-11 10:54:22 -04:00
Vitor Pamplona
d4c39bd9cb Adds a flow to TorSettings so that any change to the inner properties can be tracked. 2025-09-11 10:46:26 -04:00
Vitor Pamplona
11597d2448 Documents OTS methods 2025-09-11 10:45:35 -04:00
Vitor Pamplona
5c3ea0a302 removes duplicated copyright notice 2025-09-11 10:45:18 -04:00
Vitor Pamplona
0c65176ecc Adds a DefaultOtsResolver builder using the internal web request classes from java. 2025-09-11 10:45:02 -04:00
KotlinGeekDev
e72bb5044b Adjust size of delete button for a Follow set item. 2025-09-11 15:02:38 +01:00
KotlinGeekDev
e8d04a2205 Modify Follow and Unfollow buttons to only change looks when in profile actions. 2025-09-11 15:01:27 +01:00
KotlinGeekDev
31328089cb FollowSetScreen: -Make a separate component for the title and description.
- Fix onBroadcastList, making sure to get the latest changes to the set before broadcast.
-Make a separate component for FollowSet list items, and make the delete button look a bit better(?).
NostrUserListVM: - Minor cleanup.
2025-09-11 14:32:58 +01:00
KotlinGeekDev
bd41354009 Make some UI adjustments for the list creation dialog. 2025-09-11 14:05:28 +01:00
KotlinGeekDev
bac3c575cb Make some UI adjustments for the list rename dialog. 2025-09-11 14:03:29 +01:00
KotlinGeekDev
3c4865c4b0 Parse follow set descriptions as-is. 2025-09-11 14:01:12 +01:00
Vitor Pamplona
ba2707e7b4 Changes the name of the nav to avoid confusing the auto import 2025-09-10 19:59:48 -04:00
Vitor Pamplona
f9590055b0 Removes dependency on the application class from DecryptAndIndexProcessor 2025-09-09 12:17:28 -04:00
Vitor Pamplona
dc97e5004e Uses local copy of cache instead of global singleton 2025-09-09 12:16:52 -04:00
Vitor Pamplona
4fb629e893 Fixes not sending the live stream events anywhere when the stream doesn't have a relay set declared. 2025-09-09 11:25:59 -04:00
Vitor Pamplona
44a34ffcfe Removes livestream chats from the home bubbles that are placed after the live stream is done 2025-09-09 11:25:30 -04:00
Vitor Pamplona
f00207d53c Fixes reply routes when clicking in the Conversations tab when the event is a PublicChat, LiveStream or Ephemeral Chat 2025-09-09 10:28:49 -04:00
Vitor Pamplona
d25e6cd07b Small speed up with contains of a char and not a string 2025-09-09 10:28:01 -04:00
Vitor Pamplona
bbec0d93d2 Avoids breaking the contract (changes during sorting) 2025-09-08 19:38:18 -04:00
Vitor Pamplona
4158d3bd45 Fixes logs for account 2025-09-08 18:47:31 -04:00
Vitor Pamplona
49e4f7bb46 Fixes feed filter update when changing the top nav filter 2025-09-08 18:03:01 -04:00
Vitor Pamplona
4249b9f872 Fixes not having author picture in the long form previews 2025-09-08 18:01:26 -04:00
Vitor Pamplona
eac2e54953 Merge pull request #1467 from davotoula/prevent-resource-leaks-with-streams
Prevent resource leaks with streams
2025-09-08 17:27:47 -04:00
Vitor Pamplona
2455a34987 Moves account saving from StateViewModel to Account 2025-09-08 17:22:54 -04:00
Vitor Pamplona
ba0ce3366c Fixes f-droid code 2025-09-08 16:58:14 -04:00
Vitor Pamplona
ccf9f47305 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-09-08 16:50:06 -04:00
Vitor Pamplona
95cf3fb6eb Changing the DVM feed to sort by follows that liked or zapped the DVM 2025-09-08 16:49:30 -04:00
Vitor Pamplona
81dbebc5a4 Fixes deletion computation in the update of feeds (it was not considering the a-tag's date and deleting newer events than the deletion request) 2025-09-08 16:49:09 -04:00
Vitor Pamplona
26599fe24f Removes dependency on the viewModel on AccountFeedStates 2025-09-08 15:58:50 -04:00
Vitor Pamplona
ed1ea4d289 Removing the dependency on an application class from AccountViewModel 2025-09-08 15:50:54 -04:00
Vitor Pamplona
e0764da095 Creates an interface for the DualHttpClientManager 2025-09-08 15:50:28 -04:00
Vitor Pamplona
4c8456c4af Fixes the crash on starting when the contentResolver is not ready yet. 2025-09-08 15:50:07 -04:00
Vitor Pamplona
ac2609b4f6 Finish migration to INostrClient 2025-09-08 15:45:50 -04:00
Vitor Pamplona
67e2189676 Moves the event processor to the Account class 2025-09-08 15:06:15 -04:00
Vitor Pamplona
222615a116 Establishing a scope for each account loaded. 2025-09-08 14:37:45 -04:00
davotoula
8f623ebb91 add TODO to investigate. Double closing of stream most often is ignored. 2025-09-08 20:32:28 +02:00
davotoula
adc4aa7906 cleaner code 2025-09-08 20:32:28 +02:00
davotoula
ec362ea0ee Use .use for streams
cleaner code
2025-09-08 20:32:28 +02:00
davotoula
e66e71a05c clener code 2025-09-08 20:32:28 +02:00
davotoula
9be55eda07 close FileInputStreams to prevent resource leak 2025-09-08 20:32:28 +02:00
David Kaspar
16b5d44c3d Merge pull request #1466 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-08 18:38:01 +01:00
Vitor Pamplona
93a639fea5 Modifies the Notification processor to use full accounts and their cached structrues again. 2025-09-08 13:29:02 -04:00
Vitor Pamplona
360f71c79a Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-09-08 13:28:23 -04:00
Crowdin Bot
1f38ae5deb New Crowdin translations by GitHub Action 2025-09-08 17:26:27 +00:00
Vitor Pamplona
f633d57277 Merge pull request #1465 from greenart7c3/main
When using intents check if it's rejected
2025-09-08 13:25:19 -04:00
greenart7c3
196a7de6a3 When using intents check if it's rejected 2025-09-08 14:05:00 -03:00
David Kaspar
4c1d0ac60c Merge pull request #1464 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-08 07:58:20 +01:00
Crowdin Bot
1bdf7d9884 New Crowdin translations by GitHub Action 2025-09-08 06:57:03 +00:00
David Kaspar
9e1d2c89bb Merge pull request #1463 from davotoula/update-translations
added CZ, DE translations
2025-09-08 07:55:46 +01:00
davotoula
a58dc09b80 added CZ, DE translations 2025-09-08 08:34:16 +02:00
David Kaspar
91f85a1e1c Merge pull request #1462 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-08 07:05:42 +01:00
Crowdin Bot
cf9d630032 New Crowdin translations by GitHub Action 2025-09-08 06:03:52 +00:00
David Kaspar
f55b0f528c Merge pull request #1461 from davotoula/update-translations
updated PT, CZ, SE, GE translations
2025-09-08 07:02:34 +01:00
davotoula
463762f268 added PT, CZ, SE, GE translations 2025-09-07 19:17:53 +02:00
Vitor Pamplona
82770c204c Merge pull request #1460 from davotoula/update-secp256k1
update secp256k1KmpJniAndroid to 0.19.0
2025-09-07 12:56:13 -04:00
Vitor Pamplona
0621cfb46c Small correction in the method name 2025-09-07 12:23:34 -04:00
davotoula
1f1a7eb12c update secp256k1KmpJniAndroid to 0.19.0 2025-09-07 17:59:08 +02:00
Vitor Pamplona
93e2f66559 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-09-06 11:59:38 -04:00
Vitor Pamplona
cc2b836cd5 Migrates Account management to an Application class. 2025-09-06 11:59:01 -04:00
Vitor Pamplona
fd2a227674 Fixes the new theme parameter 2025-09-06 11:59:01 -04:00
Vitor Pamplona
67937b9fbc Nicer way to create the account view model 2025-09-06 11:59:01 -04:00
Vitor Pamplona
ea58b4fa3d Adds a dependency on Account and to the NWC state to avoid linking directly with the Application class 2025-09-06 11:59:01 -04:00
Vitor Pamplona
7087fb85f0 Changes the Theme class to only take the preferred theme directly. 2025-09-06 11:59:01 -04:00
Vitor Pamplona
caf325f240 Sets the instance of the Application class before anything else 2025-09-06 11:59:01 -04:00
Vitor Pamplona
18b2fdb7e4 Removes one of the Application dependencies in viewmodel 2025-09-06 11:59:01 -04:00
Vitor Pamplona
66eef96ccb Offers way to load the Application class from Context 2025-09-06 11:59:01 -04:00
Vitor Pamplona
757806341c Adds an isMobileOrFalse flow in connectivity 2025-09-06 11:59:01 -04:00
Vitor Pamplona
69e3d2d0d8 Creates an interface for NostrClient 2025-09-06 11:59:01 -04:00
Vitor Pamplona
a03ba7b260 Refactors zap the devs card view 2025-09-06 11:59:01 -04:00
Vitor Pamplona
51f81dce73 Merge pull request #1459 from davotoula/main
Setting JvmTarget to 21 for quartz
2025-09-06 11:57:43 -04:00
davotoula
32d2ee698a fix for missing lazysodium 2025-09-06 15:53:11 +02:00
davotoula
8aaea960f0 Set quartz JVM target to 1.8 to allow for lower requirements for building library 2025-09-06 15:34:06 +02:00
davotoula
ab9ee36263 Setting JvmTarget to 21 for quartz to fix build/push issue due to mixed Java versions when using Java 23 locally 2025-09-05 20:01:49 +02:00
KotlinGeekDev
f9cf348f8f Merge branch 'vitorpamplona:main' into profiles-list-management 2025-09-05 12:56:23 +00:00
Vitor Pamplona
254ac1a1cb Trying to remove firebase analytics 2025-09-04 21:05:06 -04:00
Vitor Pamplona
2ffa029790 Small refactoring on NotificationUtils 2025-09-04 21:04:43 -04:00
Vitor Pamplona
b0e27d0748 Forces relay status updates when connecting and disconnecting 2025-09-04 21:02:20 -04:00
Vitor Pamplona
a4569675c2 Merge pull request #1458 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-04 11:16:18 -04:00
Crowdin Bot
88457eb101 New Crowdin translations by GitHub Action 2025-09-04 15:13:49 +00:00
Vitor Pamplona
103ef01be4 Adds support for NIP-53 Meeting room to quartz 2025-09-04 11:10:54 -04:00
Vitor Pamplona
fcef6df7db Merge pull request #1457 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-09-03 18:10:34 -04:00
Crowdin Bot
19c24af592 New Crowdin translations by GitHub Action 2025-09-03 22:05:06 +00:00
Vitor Pamplona
7de119dd4f removes logs 2025-09-03 17:48:24 -04:00
Vitor Pamplona
8d73bf2cc4 Makes the new Video events non replaceable, while keeping the old ones in the replaceable 2025-09-03 17:44:40 -04:00
Vitor Pamplona
8131f3db39 Update AGP 2025-09-03 16:57:38 -04:00
Vitor Pamplona
e4e52ec4e6 Unify outbox calculation in the RelayOutbox loader. 2025-09-03 16:51:27 -04:00
Vitor Pamplona
43c9df23c4 Removes common mistakes from the outbox list from follows 2025-09-03 16:37:30 -04:00
Vitor Pamplona
85fb6dcc83 Avoids breaking line in the relay info top nav bar title 2025-09-03 16:35:10 -04:00
Vitor Pamplona
c9cdc74c91 Removes duplicated Loading of Relay Info 2025-09-03 15:45:21 -04:00
Vitor Pamplona
c64e65ddb4 Migrates to use suspending routines for OTS, statuses and edits 2025-09-02 19:20:44 -04:00
Vitor Pamplona
c46cf79501 Correctly sorts by online status when the app has already cached the status of a streaming 2025-09-02 18:34:21 -04:00
Vitor Pamplona
eef0adc6f9 Resets streaming url check if offline and entering a chat of that streaming 2025-09-02 18:30:47 -04:00
Vitor Pamplona
480ef63aed adds a border on the users of a given relay in the connected relay list 2025-09-02 18:30:01 -04:00
Vitor Pamplona
7a807e94af Exposes active filters per relay on the Nostr Client for debugging purposes 2025-09-02 18:28:41 -04:00
Vitor Pamplona
116bd1ae29 Removes any relay url that has the null byte, regardless of size 2025-09-02 18:28:14 -04:00
Vitor Pamplona
fb5c20d8da Abandoning the precision of Mills to seconds on the isOnline Check 2025-09-02 18:27:46 -04:00
Vitor Pamplona
0bc0f503be Reduces errors when trying to parse unparseable NIP-11s 2025-09-02 17:34:15 -04:00
KotlinGeekDev
70c0668962 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-09-02 21:23:29 +00:00
Vitor Pamplona
f6d283dafb Refining the performance of the QuickAction menu bar 2025-09-02 15:32:28 -04:00
Vitor Pamplona
7048a1da50 Fixes invalid dependency on Quartz 2025-09-02 15:30:57 -04:00
Vitor Pamplona
36b541e0c7 Fixes NPE when updating a metadata with null name 2025-09-02 14:35:43 -04:00
Vitor Pamplona
501fa25e98 v1.03.0 2025-09-02 14:01:15 -04:00
Vitor Pamplona
b2516bbae9 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-09-02 13:43:56 -04:00
Vitor Pamplona
fcd56fdb3a Improves list of visible authors on live stream bubbles 2025-09-02 13:26:19 -04:00
Vitor Pamplona
8db90728aa Removes wss://%2A%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00.isekco.re/ 2025-09-02 12:37:38 -04:00
Vitor Pamplona
35146cc794 Adds the error code to avoid testing it again 2025-09-02 12:36:43 -04:00
KotlinGeekDev
bc395a09bd Merge branch 'vitorpamplona:main' into profiles-list-management 2025-09-01 17:28:50 +00:00
Vitor Pamplona
b6cd8d7b05 Merge pull request #1453 from KotlinGeekDev/quartz-kmp-build-fixes
Fix Quartz build issues.
2025-09-01 12:29:58 -04:00
KotlinGeekDev
822edf1273 Re-enable publish in androidTarget KMP section, as it works in conjunction with the Android publish block. 2025-09-01 16:47:56 +01:00
KotlinGeekDev
2f2919090e Disable publishing in androidTarget KMP configuration. Re-enable in Android block, and use updated syntax. 2025-09-01 16:40:36 +01:00
KotlinGeekDev
edf5b28e06 Verifying some changes. 2025-09-01 16:26:57 +01:00
KotlinGeekDev
c828299793 Update sourceset folder names. 2025-09-01 15:24:22 +01:00
KotlinGeekDev
cf13d24c93 Revert to using androidLibrary plugin until KMP publishing guidance is updated. Use default sourcesets for Android. 2025-09-01 15:22:53 +01:00
Vitor Pamplona
56f93170e5 Fixes product title when in a quoted post 2025-08-31 21:25:08 -04:00
Vitor Pamplona
ed4839bc4d Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-08-31 12:27:57 -04:00
Vitor Pamplona
b8c39ca5b2 Updating firebase 2025-08-31 12:23:32 -04:00
Vitor Pamplona
5397e4e22e Moves SoftCache logs to the app 2025-08-31 12:10:51 -04:00
Vitor Pamplona
ea7a2f3426 Fixes live flag when the URL is not available anymore. 2025-08-31 12:09:01 -04:00
Vitor Pamplona
f2ae8afe3b Merge pull request #1450 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-08-31 11:26:43 -04:00
Crowdin Bot
ee235ce682 New Crowdin translations by GitHub Action 2025-08-31 15:17:12 +00:00
Vitor Pamplona
408a9725e0 Fixes bug on opening the Relay Settings page with duplicated Trusted and Blocked relays. 2025-08-31 11:13:56 -04:00
Vitor Pamplona
1112fa96aa Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-08-30 14:22:45 -04:00
Vitor Pamplona
7da309c9cf Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-08-30 14:22:33 -04:00
Vitor Pamplona
caf4b5ca0b Merge pull request #1449 from newrizen/English-strings.xml
Update english strings.xml
2025-08-30 14:18:32 -04:00
Neil Rizen
c8d6a46444 Update english strings.xml
According to PR #1444, commit 64df323, which changed the hyphen that was being applied to all languages ​​to a space, this is the adjustment made in English and will be followed for other languages.

[pt_rbr already adjusted]
2025-08-30 15:08:35 -03:00
Vitor Pamplona
dd5ea51575 Updates the User Profile's Relay List to an outbox version 2025-08-30 13:49:24 -04:00
Vitor Pamplona
9d4c690760 Adds a follow list state from kind 3 to keep following users in memory. 2025-08-30 13:18:04 -04:00
Vitor Pamplona
d7a3cf94b7 Moves from RelationshipStatus to ContactCard as per NIP update 2025-08-30 13:11:29 -04:00
Vitor Pamplona
a063107a15 Adds a normalized parser to all nip65 relays 2025-08-30 12:45:32 -04:00
Vitor Pamplona
f06fc8a9cb Merge pull request #1448 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-08-30 11:05:21 -04:00
Crowdin Bot
0c386811e0 New Crowdin translations by GitHub Action 2025-08-30 15:02:29 +00:00
Vitor Pamplona
4c34c6b8e3 Simple fix on url normalization 2025-08-30 10:59:53 -04:00
David Kaspar
4c43399cce Merge pull request #1447 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-08-30 15:44:31 +01:00
Crowdin Bot
b8ffb01073 New Crowdin translations by GitHub Action 2025-08-30 13:35:16 +00:00
Vitor Pamplona
50275548b8 Merge pull request #1443 from davotoula/capture-and-post-video
Capture and post video
2025-08-30 09:34:14 -04:00
Vitor Pamplona
64df3236ce Merge pull request #1444 from newrizen/Fix-TranslatableRichTextViewer.kt
Fix hyphen applied in all languages in TranslatableRichTextViewer.kt
2025-08-30 09:33:45 -04:00
Vitor Pamplona
c5a7293120 Merge pull request #1445 from davotoula/remove-unused-imports-amethyst
remove unused imports [amethyst]
2025-08-30 09:33:24 -04:00
Vitor Pamplona
4a969cb77c Merge pull request #1446 from davotoula/remove-unused-imports-quartz
remove unused imports [quartz]
2025-08-30 09:33:17 -04:00
davotoula
8fffe3f662 remove unused imports 2025-08-30 09:10:28 +01:00
davotoula
f5722eb963 remove unused imports 2025-08-30 08:25:30 +01:00
Neil Rizen
f5289cb3d3 Update TranslatableRichTextViewer.kt
Changed the hyphen to a space to move the word "auto-translated" (and its translations) to "translations_auto" string and "from" (and its translations) to "translated_from" string, as this hyphen only exists in some languages.
2025-08-29 21:52:48 -03:00
Vitor Pamplona
0064a4c596 Trying to avoid an NPE in the maxOfOrNull iterator 2025-08-29 17:58:39 -04:00
Vitor Pamplona
e9d9ef197f Activates live streams to the top feed bubble 2025-08-29 16:55:29 -04:00
Vitor Pamplona
a5a6c875fa Adds a listener to check the use of custom TTS Engines 2025-08-29 12:27:17 -04:00
Vitor Pamplona
8e8521fd6d Assemble report properties as a table and crash as a code block 2025-08-29 10:02:39 -04:00
davotoula
e387e04b6d Added "record and post video" button to GenericCommentPostScreen.kt (GeoHashTag post and HashTag post) 2025-08-29 14:47:25 +01:00
davotoula
001167ff23 Added "record and post video" button to NewProductScreen 2025-08-29 14:38:19 +01:00
Vitor Pamplona
449a3f541e Improves the design of the Crash Report permission screen with a cancel button.
Deleted copy stack to clipboard
2025-08-29 09:36:33 -04:00
Vitor Pamplona
3b59b11e8b Ignores out of memory exceptions because the stack trace is irrelevant in those cases. 2025-08-29 09:35:05 -04:00
Vitor Pamplona
aaae806505 Makes the exception name the first line in the report 2025-08-29 09:34:41 -04:00
Vitor Pamplona
458a7c7377 Adds support for expirations in DMs 2025-08-29 09:34:26 -04:00
davotoula
c95ab2d6b7 Added "record and post video" button to NewPublicMessageScreen (I'm not sure it's used) 2025-08-29 14:27:59 +01:00
davotoula
6a3aae7dbc Added "record and post video" button to NewGroupDMScreen 2025-08-29 14:14:03 +01:00
davotoula
9179e18b06 Added "record and post video" button to media tab 2025-08-29 14:06:39 +01:00
Vitor Pamplona
ecf530d512 Merge pull request #1442 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2025-08-29 08:02:42 -04:00
Crowdin Bot
51f7081214 New Crowdin translations by GitHub Action 2025-08-29 11:41:25 +00:00
Vitor Pamplona
7f7debbb73 v1.02.1 2025-08-29 07:38:42 -04:00
Vitor Pamplona
60377c0c35 Fixes inability to delete relays from certain relay lists. 2025-08-29 07:38:18 -04:00
Vitor Pamplona
a594d741b1 Creates an additional pointer for Account lists to make sure the GC doesn't clear instances that are only stored in the LocalCache's WeakReference DB. 2025-08-29 07:37:24 -04:00
davotoula
3dd791fbbe Change LOW video compression to use VERY_LOW from lightcompressorlibrary for better streaming experience
LOW: 20% of original
VERY_LOW: 10% of original
2025-08-29 10:35:27 +01:00
davotoula
87afb7f491 refactor getVideoUri and getPhotoUri to reduce duplication 2025-08-29 10:33:39 +01:00
davotoula
5f8519d737 added translations 2025-08-29 09:19:58 +01:00
davotoula
f83166e576 Add take video functionality
Add take video button to new post screen
I18N
2025-08-29 09:13:26 +01:00
Vitor Pamplona
df85c3969c Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2025-08-28 18:19:49 -04:00
Vitor Pamplona
e4311ddfa3 Creates a bootstrap relay list for inbox that doesn't have nostr.wine to avoid sending posts there and getting the notify 2025-08-28 18:12:55 -04:00
Vitor Pamplona
3a9fd925e1 Merge pull request #1440 from davotoula/upgrade-agp
Upgrade agp and add JvmTarget.JVM_21 to Quartz
2025-08-28 18:08:39 -04:00
davotoula
dbcca9cbd2 Use JvmTarget JVM_1_8 for quartz to be more forgiving about requirements. 2025-08-28 22:48:18 +01:00
davotoula
ef250eb0f1 Consistent JvmTarget JVM_21 across all modules 2025-08-28 22:17:44 +01:00
davotoula
8c26af7d40 upgrade agp to 8.12.2 2025-08-28 22:17:44 +01:00
KotlinGeekDev
3cfe195561 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-08-28 15:43:27 +00:00
KotlinGeekDev
e3cfa76843 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-08-28 15:08:48 +00:00
KotlinGeekDev
a23bc875cf Merge latest upstream changes. 2025-08-28 14:42:53 +01:00
KotlinGeekDev
92fac53705 Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2025-08-28 14:42:09 +01:00
KotlinGeekDev
1b011acbd7 Merge branch 'main' of https://github.com/vitorpamplona/amethyst ite resolve branch. 2025-08-28 14:38:56 +01:00
KotlinGeekDev
f651dd964d Refactor FollowSetScreen parameters. Add new toast message type that includes an action to perform on dismissal. 2025-08-28 14:27:50 +01:00
KotlinGeekDev
53b74e6f82 Add Nav route for FollowSetScreen. Refactor other places accordingly. Make minor cleanup. 2025-08-28 14:25:39 +01:00
KotlinGeekDev
c1a5052cbb Fix bug in addFollowSet, and refactor to use Boolean for checking list visibility. Use runBlocking in getFollowSetNOte(for now). 2025-08-28 14:20:07 +01:00
KotlinGeekDev
3b23b9eeef Merge branch 'vitorpamplona:main' into profiles-list-management 2025-08-27 14:35:06 +00:00
KotlinGeekDev
685365eb12 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-08-25 20:34:00 +00:00
KotlinGeekDev
34a75dd28f Merge branch 'vitorpamplona:main' into profiles-list-management 2025-08-25 14:18:23 +00:00
KotlinGeekDev
a3be27723d Merge branch 'vitorpamplona:main' into profiles-list-management 2025-08-20 21:54:12 +00:00
KotlinGeekDev
62f2c8c01c Remove followSetsFlow from AccountViewModel in favor of using the NostrUserListFeedViewModel. Remove unused code, and refactor accordingly. 2025-08-18 21:23:22 +01:00
KotlinGeekDev
c06408537a Merge branch 'vitorpamplona:main' into profiles-list-management 2025-08-18 16:23:06 +00:00
KotlinGeekDev
850b9afc66 Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2025-08-18 17:22:26 +01:00
KotlinGeekDev
909c704bda Improved version of NostrUserListFeedViewModel. Remove unneeded LaunchedEffect in CustomListsScreen. 2025-08-18 17:21:26 +01:00
KotlinGeekDev
bcebfa58f1 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-08-16 01:34:30 +00:00
KotlinGeekDev
8585620045 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-08-14 04:22:54 +00:00
KotlinGeekDev
2238f4a807 Use runBlocking in mapEventToSet(). Rework and simplify fetching in FollowSetFeedFilter. Make FollowSetActions dropdown menu more reliable. 2025-08-10 02:22:20 +01:00
KotlinGeekDev
653494e922 Cleanup/Refactor:Remove followSetNotes property from User, and refactor accordingly. Make getFollowSetNotes() return the notes. 2025-08-10 02:18:03 +01:00
KotlinGeekDev
9e7d3146fa Merge branch 'vitorpamplona:main' into profiles-list-management 2025-08-07 19:52:15 +00:00
KotlinGeekDev
2ff56e8af8 Rename FollowList ViewModel to match it's actual use. 2025-08-06 22:17:06 +01:00
KotlinGeekDev
b1e4f65be8 Remove code that was reintroduced during merge with upstream. Fix Lists route. 2025-08-06 21:45:34 +01:00
KotlinGeekDev
1ce829ba23 Bring back some lost changes, and fix styling. 2025-08-06 20:26:03 +01:00
KotlinGeekDev
e92e36ceda Sync with upstream 2025-08-06 19:11:07 +00:00
KotlinGeekDev
b8b4377891 Upstream + adjustments. 2025-08-06 20:09:02 +01:00
KotlinGeekDev
981486f09a Add other functions and adapt them to the code already present. 2025-08-06 18:56:15 +01:00
KotlinGeekDev
7f8b2a6ccf Fix unresolved conflicts. 2025-08-06 13:34:08 +01:00
KotlinGeekDev
a38d198b70 Use background coroutines for modification operations. Use FollowSet in callbacks, rather than just the set identifier. 2025-06-07 02:42:27 +01:00
KotlinGeekDev
af2b2328cf Generalise the implementation of PeopleListEvent.removeTag() to also support public list events. Use defaults in already dependent code. 2025-06-06 18:34:45 +01:00
KotlinGeekDev
94243f4060 Make adjustments to UI code following the ViewModel method signatures. 2025-06-04 21:41:53 +01:00
KotlinGeekDev
63177b15a1 Make adjustments to UI code following the ViewModel method signatures. 2025-05-30 20:26:42 +01:00
KotlinGeekDev
69ae42d344 Implement relevant methods in NostrListFeedViewModel. Remove unnecessary subclass and bring ViewModelProvider in the actual VM itself. 2025-05-30 20:21:45 +01:00
KotlinGeekDev
0b3380ac6e Use dTag instead of the note's address. 2025-05-30 19:03:21 +01:00
KotlinGeekDev
af0061ae09 Add new methods for list management to PeopleListEvent. 2025-05-30 13:57:23 +01:00
KotlinGeekDev
44b567b01f Write down implementation for FollowSetScreen. Introduce functions(hacks, really) for making sure of the UI/UX design.
- Implement dialog for creating a new list.
2025-05-12 20:47:12 +01:00
KotlinGeekDev
5a6cfad848 Separate components out into their logical groups(by file). Add new icons for public/private list creation, and add FloatingActionButtons for each. Add test code for iterating on changes quickly(TEMPORARY). 2025-04-30 17:11:28 +01:00
KotlinGeekDev
0693e4412f Re-arrange components so as to better fit the ListOptionsButton. 2025-04-25 14:20:24 +01:00
KotlinGeekDev
b52943a826 Add stub for GeneralBookmarks tab. Use a different icon for representing private lists. Add dropdown menu for follow set component(for actions such as delete, rename, etc). 2025-04-25 13:42:39 +01:00
KotlinGeekDev
3a5e6dbe90 Use a tabbed layout for the different types of lists/sets, and migrate follow sets. Use empty info for next tab(labeled bookmarks). 2025-04-21 18:27:25 +01:00
KotlinGeekDev
2aa2605b48 Migrate Lists route to new navigation setup. 2025-04-18 14:24:51 +01:00
KotlinGeekDev
f97c97e00e Merge modified upstream changes. 2025-04-15 11:43:10 +01:00
KotlinGeekDev
dc22c7ea76 Keep local changes for later refactoring. Apply styling fixes. 2025-04-15 11:42:41 +01:00
KotlinGeekDev
acc59d2b40 Manual merge, due to other unfinished work. 2025-04-15 11:39:25 +01:00
KotlinGeekDev
4d45f86e06 Add CuratedBookmarkList type. Introduce FollowSetScreen. 2025-04-15 11:17:31 +01:00
KotlinGeekDev
0dae538c31 Use better naming for the view models, reflecting their true nature, and preparing for future work here. 2025-04-14 17:45:44 +01:00
KotlinGeekDev
7d90b9f87b Remove map in FollowSetFeedFilter, in favor of retrieving note from cache if modification is needed. Amend FollowSet to carry the address of the note. Add Refreshable for the Lists screen. 2025-03-31 21:31:50 +01:00
KotlinGeekDev
28093256e7 Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2025-03-31 21:19:28 +01:00
KotlinGeekDev
d7d8554626 Make naming clearer. Some refactoring. Add a function to retrieve the not corresponding to a follow set. 2025-03-31 21:19:05 +01:00
KotlinGeekDev
bfa673ca2c Make naming clearer. Some refactoring. Add a function to retrieve the not corresponding to a follow set. 2025-03-31 21:15:06 +01:00
KotlinGeekDev
82f8fe96a1 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-03-31 17:50:33 +00:00
KotlinGeekDev
c57153221c Merge branch 'vitorpamplona:main' into profiles-list-management 2025-03-30 23:05:31 +00:00
KotlinGeekDev
ab32518b8e Merge branch 'vitorpamplona:main' into profiles-list-management 2025-03-28 23:02:13 +00:00
KotlinGeekDev
24a9d7659a Merge branch 'upstream-main' into profiles-list-management
Merge upstream changes.
2025-03-28 12:12:25 +01:00
KotlinGeekDev
85fb56bb22 Updates to conflict-causing files. 2025-03-28 12:10:58 +01:00
KotlinGeekDev
c1f14cea2e Use a general type to represent lists, NostrList. Make FollowSet a subclass. Rename ListType to ListVisibility. Refactor accordingly. 2025-03-27 18:47:57 +01:00
KotlinGeekDev
f2a06184b4 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-03-25 10:53:49 +00:00
KotlinGeekDev
2ae9d272f4 Use Set rather than List, to avoid duplication. Introduce follow sets flow, and use it in FollowSetsActionMenu. 2025-03-22 21:02:10 +01:00
KotlinGeekDev
1d90e37f71 Fix mixup in follow set categorization logic. 2025-03-22 19:55:53 +01:00
KotlinGeekDev
c802c395a6 Unify the underlying implementation for follow sets and use it in both profile actions and CustomListsScreen. 2025-03-21 13:04:16 +01:00
KotlinGeekDev
fa7425e114 Remove unnecessary delay call. 2025-03-20 17:30:01 +01:00
KotlinGeekDev
5af094ab9b Re-introduce the follow sets action menu(with test data) in ProfileActions. 2025-03-20 15:56:11 +01:00
KotlinGeekDev
74ccbb3d07 Re-introduce the follow sets action menu(in a separate file, this time). 2025-03-20 15:47:23 +01:00
KotlinGeekDev
d9c3badf8a Re-introduce button styling changes(border shape). 2025-03-20 15:11:33 +01:00
KotlinGeekDev
dde59d4741 Switch to nameOrTitle() for follow set titles. 2025-03-18 13:49:35 +01:00
KotlinGeekDev
3e0f8a81b4 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-03-18 12:05:14 +00:00
KotlinGeekDev
c9dbe7d203 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-03-14 23:18:09 +00:00
KotlinGeekDev
aae0e1bb4b Merge branch 'vitorpamplona:main' into profiles-list-management 2025-03-11 23:59:51 +00:00
KotlinGeekDev
4855126d6d Fix some imports and remove unneeded functions(as per the upstream code). 2025-03-11 13:56:16 +01:00
KotlinGeekDev
3b140b16eb Merge pull request #4 from vitorpamplona/main
Merge upstream changes.
2025-03-11 11:47:57 +00:00
KotlinGeekDev
9f23bd034c Merge changes from upstream. 2025-03-08 00:52:38 +01:00
KotlinGeekDev
33d690c403 Updates to conflict-causing files. 2025-03-08 00:52:10 +01:00
KotlinGeekDev
1dcbcc04d2 Fix merge-related conflicts. 2025-03-08 00:49:18 +01:00
KotlinGeekDev
0fe6fd40be Add component for displaying the number of people in the follow list. Make some minor refactoring. 2025-02-06 14:12:09 +01:00
KotlinGeekDev
e25dce32e4 Refactor to adjust to previous change. Make other little changes. 2025-02-05 15:42:46 +01:00
KotlinGeekDev
606f630cb6 Use data classes/objects for state. Only update follow sets through exposed functions. 2025-02-05 15:31:48 +01:00
KotlinGeekDev
c2857664d3 Just use simple List. Remove explicit dispatcher calls in unneeded places. 2025-01-30 14:50:05 +01:00
KotlinGeekDev
a300a4b25c Remove the tick as list indicator in favour of textual indication. Make add/remove button look slightly better. 2025-01-30 13:52:07 +01:00
KotlinGeekDev
fad545a094 Introduce user removal functionality. 2025-01-30 10:00:25 +01:00
KotlinGeekDev
a066285667 Make some visual changes to the add/remove user button, and move the add callback to the right place. 2025-01-29 23:45:52 +01:00
KotlinGeekDev
d13bf43de0 Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2025-01-28 11:42:40 +01:00
KotlinGeekDev
7325644689 Fix some code after upstream changes. 2025-01-28 11:42:05 +01:00
KotlinGeekDev
4b7b5f1276 Merge branch 'upstream' into profiles-list-management
Sync with upstream
2025-01-28 11:20:12 +01:00
KotlinGeekDev
467b8d4184 Merge upstream, cont'd. 2025-01-28 10:18:03 +01:00
KotlinGeekDev
410888b271 Merge branch 'vitorpamplona:main' into profiles-list-management 2025-01-04 21:34:56 +00:00
KotlinGeekDev
920241abab Merge branch 'vitorpamplona:main' into profiles-list-management 2025-01-03 14:38:02 +00:00
KotlinGeekDev
8ca827a816 Merge branch 'vitorpamplona-main' into profiles-list-management
Merge upstream changes.
2025-01-01 13:39:19 +01:00
KotlinGeekDev
b2dd2a2adb Merge upstream with local changes. 2025-01-01 13:29:00 +01:00
KotlinGeekDev
7166439b97 Arrange the placement of list status indicators. 2025-01-01 13:06:56 +01:00
KotlinGeekDev
966936bba3 Use FilterChip instead of Custom Text component for presence indication. Add some test code(to be removed later). 2024-11-18 19:57:23 +01:00
KotlinGeekDev
732ce790c2 Introduce FollowSetItem, with funny defaults for now. 2024-11-07 20:20:48 +01:00
KotlinGeekDev
276b6cbf46 Use a different icon for public lists. 2024-11-07 19:21:02 +01:00
KotlinGeekDev
c485f75206 Make custom button border naming clearer, and fix list dropdown button border. 2024-11-07 12:27:00 +01:00
KotlinGeekDev
883f7a975f Experiment with Icon as a button. Refactor FollowButton and UnfollowButton to take into account the modified design, without affecting other components. 2024-11-06 10:56:33 +01:00
KotlinGeekDev
aa64e42bfe Introduce FollowSetsActionMenu. 2024-11-05 21:17:08 +01:00
KotlinGeekDev
aed914dfcf Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2024-11-01 22:28:20 +01:00
KotlinGeekDev
d763f43b40 Restore function for getting follow sets(due to merge of upstream). 2024-11-01 22:27:55 +01:00
KotlinGeekDev
2fcd6af24b Merge branch 'vitorpamplona:main' into profiles-list-management 2024-11-01 15:42:11 +00:00
KotlinGeekDev
4b76f56293 Merge branch 'vitorpamplona:main' into profiles-list-management 2024-10-31 22:15:32 +00:00
KotlinGeekDev
3ad26f0039 Merge branch 'vitorpamplona-main' into profiles-list-management
Merging upstream changes/resolving conflicts.
2024-10-31 19:42:05 +01:00
KotlinGeekDev
2366f26ac7 Merge branch 'main' of https://github.com/vitorpamplona/amethyst into vitorpamplona-main
merging upstream changes.
2024-10-31 19:36:20 +01:00
KotlinGeekDev
f2640deeaf Minor refactor. 2024-10-31 19:27:15 +01:00
KotlinGeekDev
8f798d1003 Add some error handling in the feed filter. 2024-10-31 19:02:38 +01:00
KotlinGeekDev
a39834dc87 Use FollowSetState in in CustomListsScreen. Make other fixes. 2024-10-31 18:43:07 +01:00
KotlinGeekDev
00b7ace00b Change followSets signature again. Refactor followSetNotesFLow(). 2024-10-31 18:39:05 +01:00
KotlinGeekDev
ee8148ae50 Just use NavigationRow instead of IconRow. 2024-10-31 16:58:36 +01:00
KotlinGeekDev
63654b742a Refactor NostrUserFollowSetFeedViewModel to use the FollowSetViewModel. Change follow sets signature. Might be changed later. 2024-10-31 16:55:13 +01:00
KotlinGeekDev
f3edc2d43b Introduce FollowSetFeedViewModel and FollowSetState. 2024-10-31 16:48:45 +01:00
KotlinGeekDev
0e9cc54245 Refactor FollowSetFeedFilter and Account. 2024-10-31 16:45:12 +01:00
KotlinGeekDev
3d4483ed48 Move FollowSet to it's own class. 2024-10-31 16:37:05 +01:00
KotlinGeekDev
ddbaca383f Navigation: Add custom lists to main app navigation control helper. 2024-10-30 15:01:27 +01:00
KotlinGeekDev
5b51e97594 Replace TODOs with provided feed state implementations. 2024-10-30 14:57:27 +01:00
KotlinGeekDev
d348e7a4ad Introduce ListsScreen. Current implementation is focused on follows. 2024-10-30 14:51:46 +01:00
KotlinGeekDev
0b014c5c7c Move mapping function from FollowSetFeedFilter to FollowSet. 2024-10-30 14:48:58 +01:00
KotlinGeekDev
bc7d9eef9b Make FollowSet stable. The class may be moved to another location. 2024-10-30 13:31:37 +01:00
KotlinGeekDev
7dec46f3fd Create Viewmodel for Follow sets. Refactor User.followSets to be a collection of Notes. Adjust FollowSetFeedFilter. 2024-10-27 20:19:23 +01:00
KotlinGeekDev
caad5066ff Refactor followSetNotesFlow() and introduce FollowSetFeedFilter. 2024-10-27 17:48:52 +01:00
KotlinGeekDev
c86ce1b1ce Load and populate follow sets in Account(since Account is initialized in AccountViewModel anyway). 2024-10-27 00:20:35 +01:00
KotlinGeekDev
aa1175ce1d Implement method for retrieving follow sets. 2024-10-26 18:00:09 +01:00
KotlinGeekDev
afca104b87 Add FollowSet class, and property in User. 2024-10-26 13:15:43 +01:00
KotlinGeekDev
c79157f415 Add description() for list events. 2024-10-24 18:12:50 +01:00
KotlinGeekDev
5a38267fc1 Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2024-10-23 15:29:35 +01:00
KotlinGeekDev
0c9bea3c19 Add function to get all follow sets from cache. 2024-10-23 15:28:50 +01:00
KotlinGeekDev
27d9b6384e Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2024-10-20 10:42:58 +01:00
KotlinGeekDev
bcd220191e Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2024-10-18 09:10:46 +01:00
KotlinGeekDev
1076e00aa0 Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2024-10-17 19:39:23 +01:00
KotlinGeekDev
9bca9187a6 Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2024-10-17 09:39:33 +01:00
KotlinGeekDev
bbeec18e0e Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2024-10-16 16:28:01 +01:00
KotlinGeekDev
3d11845396 Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2024-10-16 15:50:14 +01:00
KotlinGeekDev
34a6be67cf Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2024-10-09 13:54:29 +01:00
KotlinGeekDev
3c22129c4f Merge remote-tracking branch 'origin/profiles-list-management' into profiles-list-management 2024-10-05 13:50:00 +01:00
KotlinGeekDev
c5be20b4d2 Add icon for Lists and create Drawer/Route entry. 2024-10-01 20:18:11 +01:00
3323 changed files with 246014 additions and 32377 deletions

272
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,272 @@
# Amethyst
## Project Overview
Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching
over to a Kotlin Multiplatform project. This project has 4 main modules: `quartz`, `commons`,
`amethyst` and `desktopApp`. Quartz should contain implementations of Nostr specifications and
utilities to help implement them. Commons stores shared code between Amethyst Android (`amethyst`)
and Amethyst Desktop (`desktopApp`). The Desktop App is designed to be mouse first and so uses a
completely different screen and navigation architecture while sharing the back end components with
the android counterpart.
## Architecture
```
amethyst/
├── quartz/ # Nostr KMP library (protocol only, no UI)
│ └── src/
│ ├── commonMain/ # Shared Nostr protocol, data models
│ ├── androidMain/ # Android-specific (crypto, storage)
│ ├── jvmMain/ # Desktop JVM-specific
│ └── iosMain/ # iOS-specific
├── commons/ # Shared UI components (convert to KMP)
│ └── src/
│ ├── commonMain/ # Shared composables, icons, state
│ ├── androidMain/ # Android-specific UI utilities
│ └── jvmMain/ # Desktop-specific UI utilities
├── desktopApp/ # Desktop JVM application (layouts, navigation)
├── amethyst/ # Android app (layouts, navigation)
└── ammolite/ # Support module (unused)
```
**Sharing Philosophy:**
- `quartz/` = Nostr business logic, protocol, data (no UI)
- `commons/` = Shared UI components, icons, composables, flows and ViewModels
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
## Tech Stack
| Layer | Technology |
|-------|------------|
| **Core** | Quartz (Nostr KMP) |
| **UI** | Compose Multiplatform 1.7.x |
| **Async** | kotlinx.coroutines + Flow |
| **Network** | OkHttp (JVM) |
| **Serialization** | Jackson |
| **DI** | Manual / Koin |
| **Build** | Gradle 8.x, Kotlin 2.1.0 |
## Skills
Specialized skills provide domain expertise with bundled resources and patterns:
| Skill | Expertise | When to Use |
|-------|-----------|-------------|
| `nostr-expert` | Nostr protocol (Quartz library) | Event types, NIPs, tags, signing, Bech32 |
| `kotlin-expert` | Advanced Kotlin patterns | StateFlow, sealed classes, @Immutable, DSLs |
| `kotlin-coroutines` | Advanced async patterns | supervisorScope, callbackFlow, relay pools, testing |
| `kotlin-multiplatform` | Platform abstraction | expect/actual, source sets, sharing decisions |
| `compose-expert` | Shared UI components | Material3, state hoisting, recomposition |
| `android-expert` | Android platform | Navigation, permissions, lifecycle, Material3 |
| `desktop-expert` | Desktop platform | Window, MenuBar, Tray, keyboard shortcuts |
| `gradle-expert` | Build system | Dependencies, versioning, packaging, optimization |
## Workflow
**When you ask for a feature:**
1. **Quick skill assessment** - I identify which skills are relevant
2. **Propose which skills** - I present which skills I'll use for the task
3. **Get approval** - You review and approve (or adjust) the skill selection
4. **Review plan using approved skills** - I invoke the approved skills to create detailed implementation plan
5. **Execute with skills** - Skills collaborate to implement the feature
**Example:**
```
You: "Add video support to notes"
Me: "I'll use:
- /nostr-expert (NIP-71 video events)
- /compose-expert (video player UI)
- /android-expert (platform video APIs)
Proceed?"
You: "yes"
Me: [invokes skills to create plan]
"Plan from skills:
1. nostr-expert: Use NIP-71 kind 34235 for video events...
2. compose-expert: Create VideoPlayer composable in commons...
3. android-expert: Use ExoPlayer for Android...
Proceed with implementation?"
You: "yes"
Me: [implements using skill guidance]
```
## Commands
- `/desktop-run` - Build and run desktop app
- `/nip <number>` - Get NIP implementation guidance
## Feature Workflow
**CRITICAL: Always check existing implementations first before creating new code!**
When picking up a new task or feature, follow this process:
### Step 0: Survey Existing Implementation (MANDATORY)
**Before writing ANY code, thoroughly audit ALL modules:**
1. **Search for existing implementations across all modules:**
```bash
# Search in quartz for protocol/business logic
grep -r "class.*Manager\|object.*Cache\|class.*Filter" quartz/src/commonMain/
# Search in commons for UI components
grep -r "@Composable.*Card\|@Composable.*View\|@Composable.*Dialog" commons/src/
# Search in amethyst for Android patterns
grep -r "class.*ViewModel\|class.*Account\|class.*State" amethyst/src/main/java/
# Search for specific functionality
grep -r "fun isFollowing\|fun subscribe\|fun getMetadata" {quartz,commons,amethyst}/src/
```
2. **Understand existing architecture patterns:**
- Event stores and caching systems
- State management patterns (StateFlow, mutable states)
- ViewModel patterns and lifecycle handling
- Filter builders and relay subscription patterns
- UI component hierarchies
3. **Key principle:** Most logic already exists! Your job is to:
- **Reuse** existing protocol/business logic from quartz
- **Extract** shareable UI components AND ViewModels from amethyst to commons
- Create **platform-specific** layouts/navigation for Desktop
- **NOT** duplicate existing managers, caches, or state systems
4. **Document findings in implementation plan as a matrix:**
| File/Component | Status | Location | Action |
|----------------|--------|----------|--------|
| FilterBuilders | ✅ Exists | quartz/relay/filters/ | Reuse as-is |
| NoteCard | 📦 Extract | amethyst/ui/note/ → commons/ | Extract to commons |
| HomeFeedViewModel | 📦 Extract | amethyst/ → commons/commonMain/viewmodels/ | Extract to commons |
| ProfileCache | ⚠️ Avoid | N/A | Already in User/Account pattern |
**Legend:**
- ✅ **Reuse** - Exists and can be used directly
- 📦 **Extract** - Exists in Android, needs extraction to commons
- 🆕 **New** - Doesn't exist, needs creation (platform-specific only)
- ⚠️ **Avoid** - Duplicate functionality, use existing pattern instead
### Step 1: Analyze Android Implementation
After surveying (Step 0), deeply examine the Android implementation:
1. Find the relevant feature/component in `amethyst/` module
2. Understand the current implementation patterns
3. Identify dependencies and integrations
4. Map out what code can be shared vs platform-specific
### Step 2: Create Implementation Plan
Before coding, create a plan that categorizes work into three buckets:
| Category | Description | Location |
|----------|-------------|----------|
| **Android-Specific** | Platform-native layouts, navigation patterns | `amethyst/`, `androidMain/` |
| **Reusable (Shared)** | Business logic, UI components, **ViewModels**, state management | `quartz/commonMain/`, `commons/commonMain/` |
| **Desktop-Specific** | Desktop-native layouts, navigation patterns, platform APIs | `desktopApp/`, `jvmMain/` |
### Step 3: Code Sharing Strategy
**Share:**
- Business logic and data models → `quartz/commonMain/`
- Major UI components (cards, lists, dialogs) → `commons/commonMain/`
- **ViewModels** (state, business logic) → `commons/commonMain/viewmodels/`
- Icons and visual assets → `commons/commonMain/`
**Keep Platform-Native:**
- **Screen composables** (layout, scaffolding) - Desktop uses `Window`, Android uses `Activity`
- Navigation patterns (sidebar vs bottom nav)
- Platform-specific interactions (gestures, keyboard shortcuts)
- System integrations (notifications, file pickers)
**Rationale:** ViewModels contain platform-agnostic state management (StateFlow/SharedFlow) and business logic. Screens consume ViewModels but render differently (Desktop sidebar + content area vs Android bottom nav).
### Step 4: Extract Shared Components
When extracting UI components:
1. Identify reusable composables in Android code
2. Move to `commons/commonMain/` (consult `/compose-expert` for patterns)
3. Create expect/actual declarations for platform-specific behavior (consult `/kotlin-multiplatform`)
4. Update both Android and Desktop to use shared component
**Note:** `quartz/` is protocol-only (no composables). Shared UI goes in `commons/` after converting it to KMP.
## Build Commands
```bash
# Run desktop app
./gradlew :desktopApp:run
# Run Android app
./gradlew :amethyst:installDebug
# Build Quartz for all targets
./gradlew :quartz:build
# Run tests
./gradlew test
# Format code
./gradlew spotlessApply
```
## Quartz KMP Structure
The Quartz library uses expect/actual for platform-specific implementations:
```kotlin
// commonMain - shared protocol logic
expect class CryptoProvider {
fun sign(message: ByteArray, privateKey: ByteArray): ByteArray
fun verify(message: ByteArray, signature: ByteArray, publicKey: ByteArray): Boolean
}
// androidMain - uses secp256k1-kmp-jni-android
actual class CryptoProvider { /* Android implementation */ }
// jvmMain - uses secp256k1-kmp-jni-jvm
actual class CryptoProvider { /* JVM implementation */ }
```
## Key Patterns
### Platform Abstraction
```kotlin
// commonMain
expect fun openExternalUrl(url: String)
// androidMain
actual fun openExternalUrl(url: String) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
// jvmMain (Desktop)
actual fun openExternalUrl(url: String) {
Desktop.getDesktop().browse(URI(url))
}
```
## Code Formatting
After completing any task that modifies Kotlin files, always run:
```
./gradlew spotlessApply
```
Do this before considering the task complete.
### Navigation Shell
- **Desktop**: Sidebar + main content area
- **Android**: Bottom navigation
## Git Workflow
- Branch: `feat/desktop-<feature>` or `fix/desktop-<issue>`
- Commits: Conventional commits (`feat:`, `fix:`, etc.)
- Never use `--no-verify`
## Resources
- [Nostr NIPs](https://github.com/nostr-protocol/nips)
- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/)
- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html)

View File

@@ -0,0 +1,45 @@
---
description: Build and run the desktop app
---
Build and run the Amethyst Desktop application:
```bash
./gradlew :desktopApp:run
```
## Troubleshooting
If the build fails, check:
1. **JDK Version**: Requires JDK 17+
```bash
java -version
```
2. **Compose Multiplatform Plugin**: Verify version in `gradle/libs.versions.toml`
3. **Quartz Build**: Ensure Quartz compiles first
```bash
./gradlew :quartz:build
```
4. **Desktop Dependencies**: Check `desktopApp/build.gradle.kts` has:
```kotlin
implementation(compose.desktop.currentOs)
```
## Creating Distributable
```bash
# macOS
./gradlew :desktopApp:packageDmg
# Windows
./gradlew :desktopApp:packageMsi
# Linux
./gradlew :desktopApp:packageDeb
```
Outputs will be in `desktopApp/build/compose/binaries/`

View File

@@ -0,0 +1,50 @@
---
description: Extract a composable from amethyst to shared code
---
Extract the component `$ARGUMENTS` from the Android app to shared KMP code:
## Process
1. **Locate the component** in the amethyst module:
```bash
find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*"
grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/
```
2. **Analyze dependencies**:
- Android-specific imports (Context, Intent, etc.)
- Platform APIs (Camera, MediaStore, etc.)
- Android Compose specifics vs standard Compose
3. **Identify what can be shared**:
- Pure Composable functions → `shared-ui/commonMain/`
- Business logic → `quartz/commonMain/`
- Platform-specific → create expect/actual
4. **Create shared version**:
- Move to appropriate shared module
- Replace Android imports with multiplatform alternatives
- Add expect declarations for platform-specific parts
5. **Update references**:
- Change imports in amethyst module
- Add implementations in desktopApp if needed
## Common Replacements
| Android | Multiplatform |
|---------|---------------|
| `LocalContext.current` | expect/actual or parameter |
| `stringResource()` | `Res.string.*` |
| `painterResource()` | `painterResource(Res.drawable.*)` |
| `Toast.makeText()` | Custom snackbar/notification |
| `Intent` | expect/actual for navigation |
## Example
```
/extract NoteCard
```
This will find NoteCard, analyze its dependencies, and guide you through extracting it to shared code.

32
.claude/commands/nip.md Normal file
View File

@@ -0,0 +1,32 @@
---
description: Get NIP specification and implementation guidance
---
Fetch and explain NIP-$ARGUMENTS from the Nostr protocol:
1. **Get the specification** from https://github.com/nostr-protocol/nips/blob/master/$ARGUMENTS.md
2. **Show key details**:
- Event kind(s) used
- Required and optional fields
- Tag structure
- Message flow between client and relay
3. **Check implementation status** in Quartz:
```bash
grep -r "NIP-$ARGUMENTS\|nip$ARGUMENTS\|kind.*=" quartz/src/
```
4. **Provide implementation guidance**:
- Which Quartz classes to use or create
- Event construction example
- Relay subscription filters
- Verification/validation logic
## Example Usage
```
/nip 01 # Basic protocol
/nip 44 # Versioned encryption
/nip 57 # Zaps
```

322
.claude/core-skills-plan.md Normal file
View File

@@ -0,0 +1,322 @@
# AmethystMultiplatform Skills Creation Plan
## Overview
Create 8 hybrid domain skills combining general expertise with AmethystMultiplatform-specific patterns.
**Approach:** Each skill provides domain knowledge + project-specific implementation patterns from codebase.
## Skills to Implement
### 1. kotlin-multiplatform ✅ COMPLETED
**Focus:** KMP architecture, jvmAndroid source set pattern, expect/actual
**SKILL.md sections:**
- Mental model: KMP hierarchy as dependency graph
- Source set architecture: commonMain → jvmAndroid → {androidMain, jvmMain}
- The jvmAndroid pattern (unique to this project, verified in quartz/build.gradle.kts:132-149)
- expect/actual mechanics with 24+ examples from codebase
- iOS framework setup for Quartz distribution
**Bundled resources:**
- `references/source-set-hierarchy.md` - Visual diagram + examples
- `references/expect-actual-catalog.md` - All 24 expect/actual pairs with patterns
- `scripts/validate-kmp-structure.sh` - Verify source set dependencies
- `assets/kmp-hierarchy-diagram.png` - Visual graph
**Differentiation:** Existing kotlin-multiplatform agent = general KMP. This skill = Amethyst's unique jvmAndroid pattern, concrete examples.
**Status:** ✅ Skill created and packaged at `.claude/skills/kotlin-multiplatform/`
---
### 2. gradle-expert ✅ COMPLETED
**Focus:** Build optimization, dependency resolution, multi-module KMP troubleshooting
**SKILL.md sections:**
- Build architecture: 4 modules, dependency flow
- Version catalog mastery (libs.versions.toml)
- Module dependency patterns (api vs implementation)
- Android-specific: compileSdk, proguard
- Desktop packaging: TargetFormat, distributions
- Build performance: daemon, parallel, caching
- Common errors: compose version conflicts, secp256k1 JNI variants
**Bundled resources:**
- `references/build-commands.md` - Common gradle tasks
- `references/dependency-graph.md` - Module visualization
- `references/version-catalog-guide.md` - Version catalog patterns
- `references/common-errors.md` - Troubleshooting guide
- `scripts/analyze-build-time.sh` - Performance report
- `scripts/fix-dependency-conflicts.sh` - Conflict patterns
**Differentiation:** Focus on 4-module structure, KMP + Android + Desktop combo, specific issues (compose conflicts).
**Status:** ✅ SKILL.md (549 lines) + 4 references + 2 scripts created at `.claude/skills/gradle-expert/`
---
### 3. kotlin-expert ✅ DRAFT COMPLETE
**Focus:** Flow state management, sealed hierarchies, immutability, DSL builders, inline/reified
**SKILL.md sections:**
- Flow state management: StateFlow/SharedFlow patterns (AccountManager, RelayConnectionManager)
- Sealed hierarchies: sealed class vs sealed interface decision trees (AccountState, SignerResult)
- Immutability: @Immutable for Compose performance (173+ event classes)
- DSL builders: Type-safe fluent APIs (TagArrayBuilder, TlvBuilder)
- Inline functions: reified generics, performance optimization (OptimizedJsonMapper)
- Value classes: Zero-cost wrappers (optimization opportunity)
**Bundled resources:**
- `references/flow-patterns.md` - StateFlow/SharedFlow with AccountManager, RelayManager patterns
- `references/sealed-class-catalog.md` - All 8 sealed types in quartz with usage patterns
- `references/dsl-builder-examples.md` - TagArrayBuilder, PrivateTagArrayBuilder, TlvBuilder, custom DSL patterns
- `references/immutability-patterns.md` - @Immutable annotation, data classes, ImmutableList/Map/Set
**Differentiation:** Complements kotlin-coroutines agent (deep async). This skill = Amethyst Kotlin idioms (StateFlow state management, sealed for type safety, @Immutable for Compose, DSL builders).
**Status:** ✅ SKILL.md (455 lines) + 4 references created at `.claude/skills/kotlin-expert/`
**10-Step Progress:**
1. ✅ UNDERSTAND - Defined scope (Flow/sealed/DSL/immutability/inline)
2. ✅ EXPLORE - Found 173 @Immutable events, StateFlow in AccountManager/RelayManager, SignerResult generics, TagArrayBuilder
3. ✅ RESEARCH - StateFlow vs SharedFlow, sealed class vs interface best practices 2025
4. ✅ SYNTHESIZE - Extracted Amethyst patterns (hot flows for state, sealed for results, @Immutable for perf)
5. ✅ DRAFT - Created SKILL.md + 4 reference files (flow, sealed, dsl, immutability)
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
8. ⏸️ TEST - Deferred to later (requires real usage scenarios)
9. ⏸️ FINALIZE - Deferred to later
10. ✅ DOCUMENT - Updated plan
---
### 4. compose-expert ✅ COMPLETED
**Focus:** Shared composables, state management, animations, Material3
**SKILL.md sections:**
- Shared composables philosophy (100+ already shared in commons/commonMain)
- State management: remember, derivedStateOf, produceState (visual patterns)
- Recomposition optimization: @Stable/@Immutable (visual usage)
- Material3 conventions: theming
- Custom icons: ImageVector builders (robohash pattern)
- Platform differences: Desktop vs Android UI
- Performance: lazy lists, image loading
- Decision framework: share by default in commonMain
**Bundled resources:**
- `references/shared-composables-catalog.md` - Complete catalog with patterns
- `references/state-patterns.md` - State hoisting, derivedStateOf examples
- `references/icon-assets.md` - ImageVector patterns, roboBuilder DSL
- `scripts/find-composables.sh` - Grep @Composable utility
**Differentiation:** Multiplatform Compose patterns, shared vs platform UI philosophy, Amethyst conventions (robohash, custom icons). Delegates navigation to platform experts, defers Kotlin language details to kotlin-expert.
**Status:** ✅ SKILL.md (578 lines) + 3 references + 1 script created at `.claude/skills/compose-expert/`
---
### 5. ios-expert
**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation
**SKILL.md sections:**
- iOS source sets: iosMain, iosArm64Main
- Swift interop: type mapping, nullability
- expect/actual iOS: 10+ examples from quartz/iosMain
- XCFramework setup: baseName = "quartz-kmpKit"
- Platform APIs: platform.posix, CFNetwork, Security
- CocoaPods integration
- XCode project setup
**Bundled resources:**
- `references/ios-actual-implementations.md` - 10 iosMain actuals
- `references/swift-interop-guide.md` - Type mapping
- `references/xcode-integration.md` - XCode setup
- `scripts/generate-xcframework.sh` - Build all iOS targets
**Differentiation:** iOS platform specialization with Amethyst iosMain patterns, Quartz framework setup.
---
### 6. desktop-expert ✅ DRAFT COMPLETE
**Focus:** Desktop UX, window management, Compose Desktop APIs, OS-specific conventions
**SKILL.md sections:**
- Desktop entry point: application {} DSL
- Window management: WindowState, positioning, multi-window
- Menu system: MenuBar, keyboard shortcuts (OS-aware)
- System tray: minimize to tray
- Desktop navigation: NavigationRail pattern (vs Android bottom nav)
- File system: Desktop.getDesktop(), file pickers, drag-drop
- Desktop UX principles: keyboard-first, native feel, tooltips
- OS-specific behavior: macOS vs Windows vs Linux
- Platform detection: PlatformDetector utility
- Packaging: DMG, MSI, DEB distribution
**Bundled resources:**
- `references/desktop-compose-apis.md` - Complete Desktop API catalog (Window, Tray, MenuBar, Dialog, etc.)
- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns
- `references/keyboard-shortcuts.md` - Standard shortcuts by OS with DesktopShortcuts helper
- `references/os-detection.md` - Platform detection, file paths, system integration
**Differentiation:** Desktop-only APIs, OS conventions (Cmd vs Ctrl), NavigationRail, delegates build to gradle-expert and shared code to kotlin-multiplatform/compose-expert.
**Status:** ✅ SKILL.md + 4 references created at `.claude/skills/desktop-expert/`
**10-Step Progress:**
1. ✅ UNDERSTAND - Defined desktop usage scenarios
2. ✅ EXPLORE - Analyzed desktopApp/ module patterns (Main.kt, FeedScreen.kt, LoginScreen.kt)
3. ✅ RESEARCH - Compose Desktop APIs, OS-specific UX conventions (JetBrains docs, HIG)
4. ✅ SYNTHESIZE - Extracted desktop principles from codebase
5. ✅ DRAFT - Created SKILL.md + 4 reference files
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
8. ⏸️ TEST - Deferred to later (requires real desktop scenarios)
9. ⏸️ FINALIZE - Deferred to later
10. ✅ DOCUMENT - Updated plan
---
### 7. android-expert ✅ DRAFT COMPLETE
**Focus:** Android platform APIs, navigation, permissions, Material Design
**SKILL.md sections:**
- Android module structure: amethyst/ layout
- Navigation: Navigation Compose, bottom nav
- Permissions: runtime (camera, biometric)
- Platform APIs: Intent, Context, ContentResolver
- Lifecycle: Lifecycle-aware, ViewModel
- Material Design: Android Material 3
- Build config: Proguard, R8
- Android UX: mobile-first patterns
**Bundled resources:**
- `references/android-navigation.md` - Navigation Compose
- `references/android-permissions.md` - Permission handling
- `references/proguard-rules.md` - Proguard explanation
- `scripts/analyze-apk-size.sh` - APK optimization
**Differentiation:** amethyst module structure, Android vs desktop patterns, Amethyst conventions.
**Status:** ✅ SKILL.md + 3 references + 1 script created at `.claude/skills/android-expert/`
**10-Step Progress:**
1. ✅ UNDERSTAND - Defined Android usage scenarios
2. ✅ EXPLORE - Analyzed amethyst/ module patterns
3. ✅ RESEARCH - Android best practices + KMP Android patterns
4. ✅ SYNTHESIZE - Extracted Android principles from codebase
5. ✅ DRAFT - Initialized skill, created resources
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
8. ⏸️ TEST - Deferred to later
9. ⏸️ FINALIZE - Deferred to later
10. ✅ DOCUMENT - Updated plan
---
### 8. nostr-expert ✅ COMPLETED
**Focus:** Nostr protocol, NIPs, Quartz architecture, event patterns
**SKILL.md sections:**
- Quartz architecture: package structure by NIP (57 NIPs implemented)
- Event anatomy: IEvent, Event, kinds, tags
- EventTemplate & TagArrayBuilder DSL patterns
- Common event types: TextNoteEvent, MetadataEvent, ReactionEvent, Addressable events
- Tag patterns: e-tag, p-tag, a-tag, d-tag with builders
- Threading (NIP-10): reply/root markers
- Cryptography: secp256k1 signing, NIP-44 encryption
- Bech32 encoding: npub, nsec, note, nevent
- Event validation & verification
- Common workflows: publishing, querying, zaps, gift-wrapped DMs
**Bundled resources:**
- `references/nip-catalog.md` - All 57 NIPs with package locations (179 lines)
- `references/event-hierarchy.md` - Event class hierarchy, kind classifications (293 lines)
- `references/tag-patterns.md` - Tag structure, TagArrayBuilder DSL, parsing (251 lines)
- `scripts/nip-lookup.sh` - Find NIP implementations by number or search term
**Differentiation:** nostr-protocol agent = NIP specs. This skill = Quartz implementation patterns (57 NIPs), concrete code examples from codebase.
**Status:** ✅ SKILL.md (552 lines) + 3 references + 1 script created at `.claude/skills/nostr-expert/`
---
## Implementation Workflow
Using skill-creator 10-step methodology per skill:
**Overall Plan:**
1. **UNDERSTAND** ✅ - 8 skills defined, user clarifications obtained
2. **EXPLORE** ✅ - Codebase analyzed via Explore agent
3. **RESEARCH** ✅ - Domain patterns identified via Plan agent
4. **SYNTHESIZE** ✅ - Skills designed above
**Per-Skill Implementation:**
- kotlin-multiplatform: ✅ COMPLETED
- gradle-expert: ✅ COMPLETED
- kotlin-expert: ✅ COMPLETED
- compose-expert: ✅ COMPLETED
- desktop-expert: ✅ COMPLETED
- android-expert: ✅ COMPLETED
- nostr-expert: ✅ COMPLETED
- ios-expert: ⏸️ DEFERRED (iOS not yet implemented in AmethystMultiplatform)
## Critical Files Referenced
**Build patterns:**
- `/quartz/build.gradle.kts:132-149` - jvmAndroid source set
- `/commons/build.gradle.kts` - Shared UI setup
**Code patterns:**
- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt` - Event structure
- `/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt` - StateFlow pattern
- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt` - expect/actual
**Documentation:**
- `/docs/shared-ui-analysis.md` - UI migration strategy
## Output Location
`.claude/skills/<skill-name>/` for each skill
## Next Steps
1. ✅ Save this plan as `.claude/core-skills-plan.md` for reference
2. ✅ Completed kotlin-multiplatform skill
3. ✅ Completed gradle-expert skill
4. ✅ Completed kotlin-expert skill
5. ✅ Completed compose-expert skill
6. ✅ Completed desktop-expert skill
7. ✅ Completed android-expert skill
8. ✅ Completed nostr-expert skill
9. ⏸️ Deferred ios-expert (iOS not yet implemented in codebase)
## Current Status: 7/8 Skills Completed
**Completed Skills (Auto-loaded from `.claude/skills/`):**
1. ✅ kotlin-multiplatform (KMP architecture, jvmAndroid pattern, expect/actual)
2. ✅ gradle-expert (Build system, dependencies, version catalog, troubleshooting)
3. ✅ kotlin-expert (Flow state, sealed classes, @Immutable, DSL builders)
4. ✅ compose-expert (Shared composables, state management, Material3, ImageVector)
5. ✅ desktop-expert (Desktop UX, window management, Compose Desktop APIs)
6. ✅ android-expert (Android platform APIs, navigation, permissions)
7. ✅ nostr-expert (Nostr protocol, Quartz implementation, NIPs, events, tags)
**Deferred:**
- ⏸️ ios-expert (iOS not implemented yet in AmethystMultiplatform)
## Skill Loading
**All completed skills are automatically loaded** when this project opens. Skills are auto-discovered from `.claude/skills/` directory.
To manually verify skills are loaded:
```bash
ls -1 .claude/skills/
```
Should show:
- android-expert/
- compose-expert/
- desktop-expert/
- gradle-expert/
- kotlin-expert/
- kotlin-multiplatform/
- nostr-expert/

180
.claude/hooks/session-start.sh Executable file
View File

@@ -0,0 +1,180 @@
#!/bin/bash
# Session start hook: Configure proxy auth, SSL trust, and Android SDK for Claude Code on the web
set -euo pipefail
# Only run in remote (web) environments
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
exit 0
fi
# --- Proxy credentials: configure Maven/Gradle if authenticated proxy is set ---
proxy="${https_proxy:-${HTTPS_PROXY:-}}"
if [ -n "$proxy" ] && echo "$proxy" | grep -q '@'; then
rest="${proxy#*://}"
userpass="${rest%@*}"
hostport="${rest##*@}"
user="${userpass%%:*}"
pass="${userpass#*:}"
host="${hostport%%:*}"
port="${hostport##*:}"
port="${port%/}"
mkdir -p ~/.m2
cat > ~/.m2/settings.xml << EOF
<settings>
<proxies>
<proxy>
<id>ccw</id><active>true</active><protocol>https</protocol>
<host>$host</host><port>$port</port>
<username>$user</username>
<password><![CDATA[$pass]]></password>
</proxy>
</proxies>
</settings>
EOF
# Force wagon transport for Maven 3.9+ proxy auth compatibility
cat > ~/.mavenrc << 'MAVENRC'
MAVEN_OPTS="$MAVEN_OPTS -Dmaven.resolver.transport=wagon"
MAVENRC
mkdir -p ~/.gradle
cat > ~/.gradle/gradle.properties << EOF
systemProp.https.proxyHost=$host
systemProp.https.proxyPort=$port
systemProp.https.proxyUser=$user
systemProp.https.proxyPassword=$pass
systemProp.http.proxyHost=$host
systemProp.http.proxyPort=$port
systemProp.http.proxyUser=$user
systemProp.http.proxyPassword=$pass
# Override nonProxyHosts: route all external traffic (incl. *.google.com) through proxy
systemProp.http.nonProxyHosts=localhost|127.0.0.1
systemProp.https.nonProxyHosts=localhost|127.0.0.1
# Use Ubuntu's Java trust store (includes Anthropic TLS inspection CA) for all Gradle JVMs.
# This is needed because Gradle may download a custom JDK (e.g. JetBrains) whose bundled
# trust store doesn't include the Anthropic CA, causing TLS inspection failures.
systemProp.javax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts
systemProp.javax.net.ssl.trustStoreType=JKS
systemProp.javax.net.ssl.trustStorePassword=changeit
systemProp.jdk.http.auth.tunneling.disabledSchemes=
systemProp.jdk.http.auth.proxying.disabledSchemes=
EOF
echo "Configured Maven/Gradle proxy from HTTPS_PROXY" >&2
fi
# --- SSL trust: import Anthropic TLS inspection CA into JVM trust stores ---
ANTHROPIC_CA_PEM=$(python3 -c "
import re, ssl, sys
try:
with open('/etc/ssl/certs/ca-certificates.crt') as f:
certs = re.findall(r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', f.read(), re.DOTALL)
for cert in certs:
der = ssl.PEM_cert_to_DER_cert(cert)
if b'Anthropic' in der and b'sandbox-egress-production' in der:
print(cert)
break
except Exception as e:
sys.stderr.write(f'CA extraction failed: {e}\n')
" 2>/dev/null)
if [ -n "$ANTHROPIC_CA_PEM" ]; then
TMPCA=$(mktemp /tmp/anthropic-ca.XXXXXX.pem)
echo "$ANTHROPIC_CA_PEM" > "$TMPCA"
for cacerts in \
/usr/lib/jvm/java-21-openjdk-amd64/lib/security/cacerts \
/root/.gradle/jdks/*/lib/security/cacerts; do
[ -f "$cacerts" ] || continue
keytool -list -keystore "$cacerts" -storepass changeit \
-alias anthropic-egress-production-ca >/dev/null 2>&1 && continue
keytool -import \
-alias anthropic-egress-production-ca \
-file "$TMPCA" \
-keystore "$cacerts" \
-storepass changeit \
-noprompt >/dev/null 2>&1 && \
echo "Imported Anthropic CA into $cacerts" >&2
done
rm -f "$TMPCA"
fi
ANDROID_SDK_DIR="/root/android-sdk"
SDK_REPO_BASE="https://dl.google.com/android/repository"
# Install Android SDK packages by downloading directly with curl
# (sdkmanager cannot reach the SDK repository through the proxy)
install_sdk_package() {
local zip_url="$1"
local dest_dir="$2"
local inner_dir="$3" # top-level dir inside the zip
if [ -d "$dest_dir" ]; then
return 0
fi
echo "Downloading $zip_url..."
local TMP_ZIP
TMP_ZIP=$(mktemp /tmp/sdk-pkg.XXXXXX.zip)
curl -fsSL "$zip_url" -o "$TMP_ZIP"
local TMP_DIR
TMP_DIR=$(mktemp -d)
unzip -q "$TMP_ZIP" -d "$TMP_DIR"
rm -f "$TMP_ZIP"
mkdir -p "$(dirname "$dest_dir")"
mv "$TMP_DIR/$inner_dir" "$dest_dir"
rm -rf "$TMP_DIR"
echo "Installed to $dest_dir"
}
# Install Android platform 36
install_sdk_package \
"$SDK_REPO_BASE/platform-36_r02.zip" \
"$ANDROID_SDK_DIR/platforms/android-36" \
"android-36"
# Install build-tools 36.0.0 (zip uses "android-16" as inner dir name)
install_sdk_package \
"$SDK_REPO_BASE/build-tools_r36_linux.zip" \
"$ANDROID_SDK_DIR/build-tools/36.0.0" \
"android-16"
# Install platform-tools
install_sdk_package \
"$SDK_REPO_BASE/platform-tools_r37.0.0-linux.zip" \
"$ANDROID_SDK_DIR/platform-tools" \
"platform-tools"
# Accept SDK licenses (create license files manually)
echo "Writing SDK license files..."
mkdir -p "$ANDROID_SDK_DIR/licenses"
# android-sdk-license
echo -e "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_DIR/licenses/android-sdk-license"
echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" >> "$ANDROID_SDK_DIR/licenses/android-sdk-license"
# android-sdk-preview-license
echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_DIR/licenses/android-sdk-preview-license"
echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licenses/android-sdk-preview-license"
# intel-android-extra-license
echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license"
# Create local.properties if missing
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/Amber}")"
LOCAL_PROPS="$REPO_ROOT/local.properties"
if [ ! -f "$LOCAL_PROPS" ]; then
echo "sdk.dir=$ANDROID_SDK_DIR" > "$LOCAL_PROPS"
echo "Created local.properties with sdk.dir=$ANDROID_SDK_DIR"
fi
# Export ANDROID_HOME for the session
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
echo "export ANDROID_HOME=$ANDROID_SDK_DIR" >> "$CLAUDE_ENV_FILE"
echo "export ANDROID_SDK_ROOT=$ANDROID_SDK_DIR" >> "$CLAUDE_ENV_FILE"
echo "export PATH=\$PATH:$ANDROID_SDK_DIR/platform-tools" >> "$CLAUDE_ENV_FILE"
fi
cd "$CLAUDE_PROJECT_DIR"
./gradlew --version > /dev/null 2>&1
echo "Android SDK setup complete."

26
.claude/settings.json Normal file
View File

@@ -0,0 +1,26 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "./gradlew spotlessApply 2>/dev/null || spotless-apply",
"timeout": 120
}
]
}
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,615 @@
# Android Navigation Patterns
Complete navigation implementation patterns for Amethyst Android app using Navigation Compose with type safety.
## Type-Safe Routes (Navigation 2.8.0+)
### Route Definitions
```kotlin
// Routes.kt - All 40+ routes in Amethyst
@Serializable
sealed class Route {
// Bottom nav routes
@Serializable object Home : Route()
@Serializable object Messages : Route()
@Serializable object Video : Route()
@Serializable object Discover : Route()
@Serializable object Notification : Route()
// Content routes with parameters
@Serializable data class Profile(val pubkey: String) : Route()
@Serializable data class Note(val id: String) : Route()
@Serializable data class Channel(val id: String) : Route()
@Serializable data class Thread(
val id: String,
val replyTo: String? = null
) : Route()
// New content routes
@Serializable data class NewPost(
val message: String? = null,
val attachment: String? = null,
val replyTo: String? = null
) : Route()
// Settings
@Serializable object Settings : Route()
@Serializable object Security : Route()
@Serializable object Relays : Route()
// Search
@Serializable data class Search(val query: String = "") : Route()
// Media
@Serializable data class Image(val url: String) : Route()
@Serializable data class Video(val url: String) : Route()
}
```
## NavHost Configuration
### Basic Setup
```kotlin
@Composable
fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel,
drawerState: DrawerState
) {
val scope = rememberCoroutineScope()
val nav = remember {
Nav(navController, drawerState, scope)
}
NavHost(
navController = navController,
startDestination = Route.Home,
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) },
popEnterTransition = { fadeIn(animationSpec = tween(200)) },
popExitTransition = { fadeOut(animationSpec = tween(200)) }
) {
// Define routes
composable<Route.Home> {
HomeScreen(accountViewModel, nav)
}
composable<Route.Profile> { backStackEntry ->
val profile = backStackEntry.toRoute<Route.Profile>()
ProfileScreen(
pubkey = profile.pubkey,
accountViewModel = accountViewModel,
nav = nav
)
}
composable<Route.Note> { backStackEntry ->
val note = backStackEntry.toRoute<Route.Note>()
NoteScreen(
noteId = note.id,
accountViewModel = accountViewModel,
nav = nav
)
}
composable<Route.NewPost> { backStackEntry ->
val newPost = backStackEntry.toRoute<Route.NewPost>()
NewPostScreen(
initialMessage = newPost.message,
initialAttachment = newPost.attachment,
replyTo = newPost.replyTo,
accountViewModel = accountViewModel,
onPost = { nav.popBack() }
)
}
}
}
```
### Custom Transitions
```kotlin
composable<Route.Profile>(
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(300)
)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(300)
)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = tween(300)
)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = tween(300)
)
}
) { backStackEntry ->
val profile = backStackEntry.toRoute<Route.Profile>()
ProfileScreen(profile.pubkey, accountViewModel, nav)
}
```
## Navigation Manager
### Nav Wrapper Class
```kotlin
class Nav(
val controller: NavHostController,
val drawerState: DrawerState,
val scope: CoroutineScope
) {
/**
* Navigate to a route, closing drawer if open
*/
fun nav(route: Route) {
scope.launch {
if (!controller.popBackStack(route, inclusive = false)) {
controller.navigate(route) {
launchSingleTop = true
}
}
drawerState.close()
}
}
/**
* Navigate with new stack (clear back stack to Home)
*/
fun newStack(route: Route) {
scope.launch {
controller.navigate(route) {
popUpTo(Route.Home) {
inclusive = false
}
launchSingleTop = true
}
drawerState.close()
}
}
/**
* Pop back stack
*/
fun popBack() {
controller.popBackStack()
}
/**
* Pop up to specific route
*/
inline fun <reified T : Route> popUpTo(inclusive: Boolean = false) {
controller.popBackStack<T>(inclusive = inclusive)
}
/**
* Get current route
*/
fun currentRoute(): Route? {
return controller.currentBackStackEntry?.toRoute<Route>()
}
}
```
## Bottom Navigation
### Material3 NavigationBar
```kotlin
@Composable
fun AppBottomBar(
currentRoute: Route?,
nav: Nav
) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
) {
BottomBarRoute.entries.forEach { item ->
NavigationBarItem(
selected = currentRoute?.let { it::class == item.route::class } ?: false,
onClick = { nav.nav(item.route) },
icon = {
Icon(
imageVector = if (currentRoute?.let { it::class == item.route::class } == true) {
item.selectedIcon
} else {
item.unselectedIcon
},
contentDescription = item.label
)
},
label = { Text(item.label) },
alwaysShowLabel = false
)
}
}
}
enum class BottomBarRoute(
val route: Route,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val label: String
) {
HOME(
route = Route.Home,
selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home,
label = "Home"
),
MESSAGES(
route = Route.Messages,
selectedIcon = Icons.Filled.Message,
unselectedIcon = Icons.Outlined.Message,
label = "Messages"
),
VIDEOS(
route = Route.Video,
selectedIcon = Icons.Filled.VideoLibrary,
unselectedIcon = Icons.Outlined.VideoLibrary,
label = "Videos"
),
DISCOVER(
route = Route.Discover,
selectedIcon = Icons.Filled.Explore,
unselectedIcon = Icons.Outlined.Explore,
label = "Discover"
),
NOTIFICATIONS(
route = Route.Notification,
selectedIcon = Icons.Filled.Notifications,
unselectedIcon = Icons.Outlined.Notifications,
label = "Notifications"
)
}
```
### Observing Current Route
```kotlin
@Composable
fun MainScreen() {
val navController = rememberNavController()
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackStackEntry?.toRoute<Route>()
Scaffold(
topBar = {
if (shouldShowTopBar(currentRoute)) {
AppTopBar(currentRoute)
}
},
bottomBar = {
if (shouldShowBottomBar(currentRoute)) {
AppBottomBar(currentRoute, nav)
}
}
) { paddingValues ->
AppNavigation(
navController = navController,
modifier = Modifier.padding(paddingValues)
)
}
}
fun shouldShowBottomBar(route: Route?): Boolean {
return when (route) {
is Route.Home,
is Route.Messages,
is Route.Video,
is Route.Discover,
is Route.Notification -> true
else -> false
}
}
```
## Navigation Drawer
### Material3 ModalDrawerSheet
```kotlin
@Composable
fun AppDrawer(
drawerState: DrawerState,
nav: Nav,
accountViewModel: AccountViewModel
) {
val scope = rememberCoroutineScope()
ModalDrawerSheet {
// User profile header
DrawerHeader(accountViewModel.account)
HorizontalDivider()
// Menu items
NavigationDrawerItem(
label = { Text("Home") },
selected = false,
onClick = { nav.nav(Route.Home) },
icon = { Icon(Icons.Default.Home, "Home") }
)
NavigationDrawerItem(
label = { Text("Profile") },
selected = false,
onClick = { nav.nav(Route.Profile(accountViewModel.account.pubkey)) },
icon = { Icon(Icons.Default.Person, "Profile") }
)
NavigationDrawerItem(
label = { Text("Settings") },
selected = false,
onClick = { nav.nav(Route.Settings) },
icon = { Icon(Icons.Default.Settings, "Settings") }
)
HorizontalDivider()
NavigationDrawerItem(
label = { Text("Logout") },
selected = false,
onClick = {
scope.launch {
accountViewModel.logout()
drawerState.close()
}
},
icon = { Icon(Icons.Default.Logout, "Logout") }
)
}
}
```
### Main Scaffold with Drawer
```kotlin
@Composable
fun MainScreen() {
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val nav = remember { Nav(navController, drawerState, scope) }
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
AppDrawer(drawerState, nav, accountViewModel)
}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Amethyst") },
navigationIcon = {
IconButton(
onClick = { scope.launch { drawerState.open() } }
) {
Icon(Icons.Default.Menu, "Menu")
}
}
)
},
bottomBar = { AppBottomBar(currentRoute, nav) }
) { paddingValues ->
AppNavigation(
navController = navController,
modifier = Modifier.padding(paddingValues)
)
}
}
}
```
## Deep Link Handling
### Intent Processing
```kotlin
@Composable
fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel
) {
val activity = LocalContext.current as? Activity
// Handle incoming intents
LaunchedEffect(activity?.intent) {
activity?.intent?.let { intent ->
handleIntent(intent, navController)
}
}
NavHost(navController = navController) {
// Routes...
}
}
fun handleIntent(intent: Intent, navController: NavHostController) {
when (intent.action) {
Intent.ACTION_SEND -> {
// Share text/image
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
val sharedUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
navController.navigate(
Route.NewPost(
message = sharedText,
attachment = sharedUri?.toString()
)
)
}
Intent.ACTION_VIEW -> {
// Deep link
intent.data?.let { uri ->
when (uri.scheme) {
"nostr" -> handleNostrUri(uri, navController)
"https", "http" -> handleWebUri(uri, navController)
}
}
}
}
}
fun handleNostrUri(uri: Uri, navController: NavHostController) {
val path = uri.pathSegments.firstOrNull() ?: return
when {
path.startsWith("npub") -> {
navController.navigate(Route.Profile(path))
}
path.startsWith("note") -> {
navController.navigate(Route.Note(path))
}
path.startsWith("nevent") -> {
// Decode and navigate to event
val eventId = decodeNevent(path)
navController.navigate(Route.Note(eventId))
}
}
}
fun handleWebUri(uri: Uri, navController: NavHostController) {
// Handle web-based deep links
// https://njump.me/npub1...
// https://primal.net/profile/npub1...
when (uri.host) {
"njump.me" -> {
val id = uri.pathSegments.lastOrNull()
if (id?.startsWith("npub") == true) {
navController.navigate(Route.Profile(id))
}
}
"primal.net" -> {
// Parse primal.net URLs
}
}
}
```
### AndroidManifest Intent Filters
```xml
<!-- MainActivity -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostr" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="njump.me" />
<data android:scheme="https" android:host="primal.net" />
<data android:scheme="https" android:host="iris.to" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data android:mimeType="image/*" />
</intent-filter>
```
## Nested Navigation
### Tab Navigation Inside Screen
```kotlin
@Composable
fun ProfileScreen(
pubkey: String,
nav: Nav
) {
val nestedNavController = rememberNavController()
Column {
ProfileHeader(pubkey)
// Tab row
TabRow(selectedTabIndex = currentTab) {
Tab(selected = currentTab == 0, onClick = { /* Notes */ })
Tab(selected = currentTab == 1, onClick = { /* Replies */ })
Tab(selected = currentTab == 2, onClick = { /* Likes */ })
}
// Nested NavHost for tabs
NavHost(
navController = nestedNavController,
startDestination = ProfileTab.Notes
) {
composable<ProfileTab.Notes> {
NotesTabContent(pubkey)
}
composable<ProfileTab.Replies> {
RepliesTabContent(pubkey)
}
composable<ProfileTab.Likes> {
LikesTabContent(pubkey)
}
}
}
}
@Serializable
sealed class ProfileTab {
@Serializable object Notes : ProfileTab()
@Serializable object Replies : ProfileTab()
@Serializable object Likes : ProfileTab()
}
```
## Testing Navigation
### Navigation Test Example
```kotlin
@Test
fun testNavigationToProfile() {
val navController = TestNavHostController(
ApplicationProvider.getApplicationContext()
)
composeTestRule.setContent {
navController.navigatorProvider.addNavigator(
ComposeNavigator()
)
AppNavigation(navController, accountViewModel)
}
// Navigate to profile
composeTestRule.onNodeWithText("Profile").performClick()
// Verify navigation
val currentRoute = navController.currentBackStackEntry?.toRoute<Route>()
assertTrue(currentRoute is Route.Profile)
}
```
## File Locations
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Nav.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt`

View File

@@ -0,0 +1,659 @@
# Android Runtime Permissions
Complete permission handling patterns for Amethyst using Accompanist Permissions library and Android best practices.
## Permission Categories in Amethyst
### Network Permissions (Normal - Auto-granted)
```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
```
### Media Permissions (Dangerous - Runtime request)
```xml
<!-- Camera -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Audio -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Storage (version-specific) -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
```
### Notification Permissions (Android 13+)
```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```
### Location Permissions
```xml
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
```
### NFC Permissions
```xml
<uses-permission android:name="android.permission.NFC" />
```
### Foreground Service Permissions
```xml
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
```
## Accompanist Permissions Library
### Setup
```gradle
dependencies {
implementation("com.google.accompanist:accompanist-permissions:0.36.0")
}
```
### Single Permission Pattern
```kotlin
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.shouldShowRationale
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraFeature() {
val cameraPermissionState = rememberPermissionState(
Manifest.permission.CAMERA
)
when {
// Permission granted - show feature
cameraPermissionState.status.isGranted -> {
CameraPreview()
}
// Should show rationale - explain why permission is needed
cameraPermissionState.status.shouldShowRationale -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Camera permission is needed to scan QR codes for login",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { cameraPermissionState.launchPermissionRequest() }
) {
Text("Grant Permission")
}
}
}
// First time - request permission
else -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Button(
onClick = { cameraPermissionState.launchPermissionRequest() }
) {
Icon(Icons.Default.CameraAlt, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Enable Camera")
}
}
}
}
}
```
### Multiple Permissions Pattern
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MediaUploadFeature() {
val permissionsState = rememberMultiplePermissionsState(
permissions = buildList {
add(Manifest.permission.CAMERA)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
)
when {
// All permissions granted
permissionsState.allPermissionsGranted -> {
MediaUploadUI()
}
// Some permissions need rationale
permissionsState.shouldShowRationale -> {
RationaleDialog(
title = "Permissions Required",
message = "Camera and storage access are needed to upload photos",
onConfirm = {
permissionsState.launchMultiplePermissionRequest()
},
onDismiss = { /* Handle dismissal */ }
)
}
// Request all permissions
else -> {
PermissionRequestScreen(
permissions = permissionsState.permissions,
onRequestPermissions = {
permissionsState.launchMultiplePermissionRequest()
}
)
}
}
}
@Composable
fun RationaleDialog(
title: String,
message: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(message) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Continue")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
```
## Lifecycle-Aware Permission Requests
### Amethyst Pattern: POST_NOTIFICATIONS
**File:** `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt`
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NotificationRegistration(accountViewModel: AccountViewModel) {
val context = LocalContext.current
// Only request on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(
Manifest.permission.POST_NOTIFICATIONS
)
// Register for push notifications when permission is granted
if (notificationPermissionState.status.isGranted) {
LifecycleResumeEffect(
key1 = accountViewModel,
key2 = notificationPermissionState.status.isGranted
) {
val scope = rememberCoroutineScope()
scope.launch(Dispatchers.IO) {
PushNotificationUtils.checkAndInit(
context = context,
accountViewModel = accountViewModel
)
}
onPauseOrDispose {
// Cleanup when composable pauses or disposes
}
}
} else {
// Show prompt to enable notifications
NotificationPermissionPrompt(
onEnableClick = {
notificationPermissionState.launchPermissionRequest()
}
)
}
}
}
@Composable
fun NotificationPermissionPrompt(onEnableClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Enable Notifications",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Get notified when someone mentions you or replies to your posts",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onEnableClick,
modifier = Modifier.fillMaxWidth()
) {
Text("Enable Notifications")
}
}
}
}
```
## Permission Best Practices
### 1. Request Contextually
**Bad:**
```kotlin
// Requesting permission on app launch
@Composable
fun AppContent() {
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
// DON'T DO THIS - user doesn't know why
permissionState.launchPermissionRequest()
}
}
```
**Good:**
```kotlin
// Request when user explicitly wants to use camera
@Composable
fun QRScannerButton() {
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
Button(
onClick = {
if (permissionState.status.isGranted) {
// Open scanner
} else {
// Request permission
permissionState.launchPermissionRequest()
}
}
) {
Text("Scan QR Code")
}
}
```
### 2. Show Rationale
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun LocationFeature() {
val locationPermissionState = rememberPermissionState(
Manifest.permission.ACCESS_COARSE_LOCATION
)
// Always show rationale first for sensitive permissions
if (!locationPermissionState.status.isGranted) {
LocationRationaleCard(
onEnableClick = {
locationPermissionState.launchPermissionRequest()
}
)
} else {
LocationMap()
}
}
@Composable
fun LocationRationaleCard(onEnableClick: () -> Unit) {
Card {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Why location access?",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Location is used for geohashing your posts. " +
"This helps other users discover local content. " +
"Your exact location is never shared.",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
Row {
OutlinedButton(onClick = { /* Skip */ }) {
Text("Skip")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = onEnableClick) {
Text("Enable")
}
}
}
}
}
```
### 3. Handle Permanent Denial
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraFeatureWithSettings() {
val context = LocalContext.current
val cameraPermissionState = rememberPermissionState(
Manifest.permission.CAMERA
)
when {
cameraPermissionState.status.isGranted -> {
CameraPreview()
}
cameraPermissionState.status.shouldShowRationale -> {
// User denied once, show rationale
RationaleDialog(
onConfirm = { cameraPermissionState.launchPermissionRequest() }
)
}
else -> {
// Might be permanently denied - offer settings
PermanentlyDeniedDialog(
onOpenSettings = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
context.startActivity(intent)
}
)
}
}
}
@Composable
fun PermanentlyDeniedDialog(onOpenSettings: () -> Unit) {
AlertDialog(
onDismissRequest = { },
title = { Text("Permission Denied") },
text = {
Text(
"Camera permission is required for QR scanning. " +
"Please enable it in Settings."
)
},
confirmButton = {
TextButton(onClick = onOpenSettings) {
Text("Open Settings")
}
},
dismissButton = {
TextButton(onClick = { /* Cancel */ }) {
Text("Cancel")
}
}
)
}
```
### 4. Version-Specific Permissions
```kotlin
@Composable
fun StoragePermissionRequest() {
val permissions = remember {
buildList {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
add(Manifest.permission.READ_MEDIA_IMAGES)
add(Manifest.permission.READ_MEDIA_VIDEO)
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
// Android 10-12: No permission needed for scoped storage
}
else -> {
// Android 9 and below
add(Manifest.permission.READ_EXTERNAL_STORAGE)
add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
}
}
}
if (permissions.isNotEmpty()) {
val permissionsState = rememberMultiplePermissionsState(permissions)
if (!permissionsState.allPermissionsGranted) {
StoragePermissionUI(
onRequest = { permissionsState.launchMultiplePermissionRequest() }
)
} else {
MediaPickerUI()
}
} else {
// No permission needed
MediaPickerUI()
}
}
```
## Permission Groups
### Camera + Storage (Media Upload)
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MediaCaptureFeature() {
val mediaPermissions = buildList {
add(Manifest.permission.CAMERA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.READ_MEDIA_IMAGES)
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
val permissionsState = rememberMultiplePermissionsState(mediaPermissions)
when {
permissionsState.allPermissionsGranted -> {
MediaCaptureUI()
}
else -> {
MediaPermissionScreen(
permissions = permissionsState.permissions,
onRequest = { permissionsState.launchMultiplePermissionRequest() }
)
}
}
}
```
### Audio + Storage (Voice Recording)
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun VoiceRecordingFeature() {
val audioPermissions = buildList {
add(Manifest.permission.RECORD_AUDIO)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.READ_MEDIA_AUDIO)
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
val permissionsState = rememberMultiplePermissionsState(audioPermissions)
if (permissionsState.allPermissionsGranted) {
AudioRecorderUI()
} else {
AudioPermissionScreen(
onRequest = { permissionsState.launchMultiplePermissionRequest() }
)
}
}
```
## Testing Permissions
### Grant Permission in Tests
```kotlin
@get:Rule
val permissionRule = GrantPermissionRule.grant(
Manifest.permission.CAMERA,
Manifest.permission.READ_EXTERNAL_STORAGE
)
@Test
fun testCameraFeatureWithPermission() {
composeTestRule.setContent {
CameraFeature()
}
// Permission is already granted by rule
composeTestRule.onNodeWithText("Take Photo").assertExists()
}
```
### Test Permission Request Flow
```kotlin
@Test
fun testPermissionRequestFlow() {
composeTestRule.setContent {
CameraFeature()
}
// Initially shows permission request button
composeTestRule.onNodeWithText("Enable Camera").assertExists()
// Click to request
composeTestRule.onNodeWithText("Enable Camera").performClick()
// System permission dialog appears (can't test dialog itself)
// Would need UiAutomator to interact with system dialog
}
```
## Permission State Checking
### Check Permission Before Action
```kotlin
fun checkAndRequestCameraPermission(
context: Context,
permissionState: PermissionState,
onGranted: () -> Unit
) {
when {
permissionState.status.isGranted -> {
onGranted()
}
permissionState.status.shouldShowRationale -> {
// Show rationale dialog
}
else -> {
permissionState.launchPermissionRequest()
}
}
}
```
### Manual Permission Check (Non-Compose)
```kotlin
fun hasCameraPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
}
fun requestCameraPermission(activity: ComponentActivity) {
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.CAMERA),
REQUEST_CAMERA_PERMISSION
)
}
// In Activity
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_CAMERA_PERMISSION -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Permission granted
} else {
// Permission denied
}
}
}
}
companion object {
private const val REQUEST_CAMERA_PERMISSION = 100
}
```
## File Locations
- `amethyst/src/main/AndroidManifest.xml` - Permission declarations
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt` - Notification permission pattern
- `amethyst/build.gradle` - Accompanist dependency
## Resources
- [Accompanist Permissions Documentation](https://google.github.io/accompanist/permissions/)
- [Android Permissions Guide](https://developer.android.com/guide/topics/permissions/overview)
- [Request Runtime Permissions](https://developer.android.com/training/permissions/requesting)

View File

@@ -0,0 +1,468 @@
# Proguard Rules for Amethyst
Proguard configuration for optimizing and obfuscating Android APK while preserving necessary code.
## What is Proguard/R8?
**R8** is Android's default code shrinker and obfuscator (replaced Proguard in AGP 3.4.0+). It:
- **Shrinks** code by removing unused classes/methods
- **Obfuscates** code by renaming classes/methods to short names
- **Optimizes** code by inlining methods and removing dead code
## Amethyst Proguard Configuration
**File:** `amethyst/proguard-rules.pro`
### Keep Kotlin Metadata
```proguard
# Kotlin metadata is required for reflection
-keep class kotlin.Metadata { *; }
-keep class kotlin.** { *; }
-dontwarn kotlin.**
# Kotlin serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses class com.vitorpamplona.**$$serializer { *; }
-keepclassmembers class com.vitorpamplona.** {
*** Companion;
}
-keepclasseswithmembers class com.vitorpamplona.** {
kotlinx.serialization.KSerializer serializer(...);
}
```
### Keep Nostr Event Classes
```proguard
# Nostr events are serialized/deserialized
-keep class com.vitorpamplona.quartz.events.** { *; }
-keep class com.vitorpamplona.quartz.encoders.** { *; }
# Keep event builders
-keep class com.vitorpamplona.quartz.builders.** { *; }
# Keep tag classes
-keep class com.vitorpamplona.quartz.nip01Core.tags.** { *; }
```
### Keep Data Classes
```proguard
# Data classes used in ViewModels and serialization
-keep @kotlinx.serialization.Serializable class * { *; }
# Keep all data classes
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
```
### Keep Compose Classes
```proguard
# Jetpack Compose
-keep class androidx.compose.** { *; }
-dontwarn androidx.compose.**
# Compose runtime
-keep class androidx.compose.runtime.** { *; }
# Compose UI
-keep class androidx.compose.ui.** { *; }
# Material3
-keep class androidx.compose.material3.** { *; }
# Navigation Compose - Keep serializable routes
-keep class * implements java.io.Serializable { *; }
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
private static final java.io.ObjectStreamField[] serialPersistentFields;
!static !transient <fields>;
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
java.lang.Object writeReplace();
java.lang.Object readResolve();
}
```
### Keep OkHttp/Retrofit
```proguard
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
# OkHttp WebSockets (for Nostr relays)
-keep class okhttp3.internal.ws.** { *; }
# Retrofit (if used)
-keepattributes Signature
-keepattributes Exceptions
-keep class retrofit2.** { *; }
```
### Keep Jackson (JSON)
```proguard
# Jackson JSON library
-keep class com.fasterxml.jackson.** { *; }
-keep class org.codehaus.** { *; }
-keepclassmembers class * {
@com.fasterxml.jackson.annotation.* <methods>;
}
# Jackson polymorphic types
-keepattributes RuntimeVisibleAnnotations
-keep @com.fasterxml.jackson.annotation.JsonTypeInfo class *
```
### Keep Secp256k1 (Crypto)
```proguard
# Secp256k1 native library
-keep class fr.acinq.secp256k1.** { *; }
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
```
### Keep Tor
```proguard
# Tor library
-keep class com.msopentech.thali.toronionproxy.** { *; }
-dontwarn com.msopentech.thali.toronionproxy.**
```
### Keep ExoPlayer (Media)
```proguard
# ExoPlayer (Media3)
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**
-keep class com.google.android.exoplayer2.** { *; }
-dontwarn com.google.android.exoplayer2.**
```
### Keep Coil (Image Loading)
```proguard
# Coil image loading
-keep class coil.** { *; }
-keep class coil3.** { *; }
-dontwarn coil.**
-dontwarn coil3.**
```
### Keep ViewModels
```proguard
# ViewModel classes
-keep class * extends androidx.lifecycle.ViewModel {
<init>();
}
# ViewModel factories
-keep class * extends androidx.lifecycle.ViewModelProvider$Factory {
<init>(...);
}
# Keep ViewModel constructors for reflection
-keepclassmembers class * extends androidx.lifecycle.ViewModel {
<init>(...);
}
```
### Keep Parcelable
```proguard
# Parcelable
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
-keepclassmembers class * implements android.os.Parcelable {
public <fields>;
private <fields>;
}
```
### Keep Enums
```proguard
# Enums
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
```
### Remove Logging (Production)
```proguard
# Remove debug logging in release builds
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
public static *** i(...);
}
# Keep error/warning logs
-assumenosideeffects class android.util.Log {
public static *** e(...) return false;
public static *** w(...) return false;
}
```
### Keep Crashlytics/Firebase
```proguard
# Firebase Crashlytics
-keepattributes SourceFile,LineNumberTable
-keep public class * extends java.lang.Exception
# Firebase
-keep class com.google.firebase.** { *; }
-dontwarn com.google.firebase.**
```
## Build Configuration
### Enable R8 in build.gradle
```gradle
android {
buildTypes {
release {
minifyEnabled = true
shrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
minifyEnabled = false
}
}
}
```
### Multiple Proguard Files
```gradle
android {
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
"proguard-quartz.pro", // Library-specific rules
"proguard-compose.pro" // Compose-specific rules
)
}
}
}
```
## Debugging Proguard Issues
### Generate Mapping File
R8 generates `mapping.txt` in `app/build/outputs/mapping/release/`:
```
# Original class name -> Obfuscated name
com.vitorpamplona.amethyst.ui.MainActivity -> a.b.c:
void onCreate(Bundle) -> a
```
### Deobfuscate Stack Traces
```bash
# Using retrace (part of Android SDK)
retrace.sh mapping.txt stacktrace.txt
```
### Enable Proguard Output
```gradle
android {
buildTypes {
release {
proguardFiles(...)
// Generate reports
postprocessing {
proguardFiles = [...]
obfuscate = true
optimizeCode = true
removeUnusedCode = true
}
}
}
}
```
**Output files:**
- `build/outputs/mapping/release/configuration.txt` - All Proguard rules applied
- `build/outputs/mapping/release/mapping.txt` - Obfuscation mappings
- `build/outputs/mapping/release/seeds.txt` - Classes kept by `-keep` rules
- `build/outputs/mapping/release/usage.txt` - Code removed by R8
### Test Release Build
```bash
./gradlew assembleRelease
# Install and test
adb install app/build/outputs/apk/release/app-release.apk
```
## Common Issues
### Issue: NoSuchMethodException at Runtime
**Cause:** Proguard removed or renamed a method used via reflection.
**Solution:**
```proguard
-keep class com.example.YourClass {
public <methods>;
}
```
### Issue: Serialization Fails
**Cause:** Data class fields were renamed.
**Solution:**
```proguard
-keep @kotlinx.serialization.Serializable class * { *; }
-keepclassmembers class * {
@kotlinx.serialization.SerialName <fields>;
}
```
### Issue: Compose Navigation Crashes
**Cause:** @Serializable route classes were obfuscated.
**Solution:**
```proguard
# Keep all route classes
-keep @kotlinx.serialization.Serializable class com.vitorpamplona.amethyst.ui.navigation.routes.** { *; }
```
### Issue: Native Library Crashes
**Cause:** Native method signatures were changed.
**Solution:**
```proguard
-keepclasseswithmembernames class * {
native <methods>;
}
```
## Optimization Tips
### 1. Keep Only What's Necessary
Don't use broad wildcards:
```proguard
# Bad - keeps everything
-keep class com.vitorpamplona.** { *; }
# Good - keeps only specific packages
-keep class com.vitorpamplona.quartz.events.** { *; }
```
### 2. Test Thoroughly
- Test all app features after enabling Proguard
- Test deep links and navigation
- Test serialization/deserialization
- Test external library integrations
### 3. Use AGP's Proguard Analysis
```gradle
android {
buildTypes {
release {
// Generate R8 configuration
android.debug.obsoleteApi = true
}
}
}
```
### 4. Analyze APK Size
```bash
# Build release APK
./gradlew assembleRelease
# Analyze APK with Android Studio
# Build > Analyze APK > Select app-release.apk
```
See `scripts/analyze-apk-size.sh` for automated analysis.
## Product Flavor Specific Rules
### Play Flavor (Firebase)
```proguard
# proguard-play.pro
-keep class com.google.firebase.** { *; }
-keep class com.google.android.gms.** { *; }
```
### F-Droid Flavor (No Google Services)
```proguard
# proguard-fdroid.pro
# UnifiedPush
-keep class org.unifiedpush.** { *; }
```
**Configure in build.gradle:**
```gradle
android {
flavorDimensions = ["channel"]
productFlavors {
create("play") {
dimension = "channel"
proguardFiles("proguard-play.pro")
}
create("fdroid") {
dimension = "channel"
proguardFiles("proguard-fdroid.pro")
}
}
}
```
## File Locations
- `amethyst/proguard-rules.pro` - Main Proguard rules
- `amethyst/build/outputs/mapping/release/` - Proguard output files
- `amethyst/build.gradle` - Proguard configuration
## Resources
- [Android R8 Documentation](https://developer.android.com/build/shrink-code)
- [Proguard Manual](https://www.guardsquare.com/manual/configuration)
- [Kotlinx Serialization Proguard](https://github.com/Kotlin/kotlinx.serialization#android)

View File

@@ -0,0 +1,230 @@
#!/bin/bash
#
# APK Size Analysis Script for Amethyst
#
# Usage:
# ./analyze-apk-size.sh [apk-path]
#
# If no APK path provided, uses latest release build
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Default APK path
DEFAULT_APK="amethyst/build/outputs/apk/release/amethyst-release.apk"
APK_PATH="${1:-$DEFAULT_APK}"
# Check if APK exists
if [ ! -f "$APK_PATH" ]; then
echo -e "${RED}Error: APK not found at $APK_PATH${NC}"
echo "Build the APK first: ./gradlew :amethyst:assembleRelease"
exit 1
fi
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Amethyst APK Size Analysis${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo
# APK basic info
echo -e "${GREEN}APK Path:${NC} $APK_PATH"
APK_SIZE=$(du -h "$APK_PATH" | cut -f1)
APK_SIZE_BYTES=$(stat -f%z "$APK_PATH" 2>/dev/null || stat -c%s "$APK_PATH" 2>/dev/null)
echo -e "${GREEN}APK Size:${NC} $APK_SIZE ($APK_SIZE_BYTES bytes)"
echo
# Extract APK to temp directory
TEMP_DIR=$(mktemp -d)
echo -e "${YELLOW}Extracting APK to $TEMP_DIR...${NC}"
unzip -q "$APK_PATH" -d "$TEMP_DIR"
# Analyze APK contents
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Top-Level Contents${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
du -sh "$TEMP_DIR"/* | sort -hr
# Analyze DEX files
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} DEX Files (Code)${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
DEX_TOTAL=0
for dex in "$TEMP_DIR"/*.dex; do
if [ -f "$dex" ]; then
DEX_NAME=$(basename "$dex")
DEX_SIZE=$(stat -f%z "$dex" 2>/dev/null || stat -c%s "$dex" 2>/dev/null)
DEX_SIZE_MB=$(echo "scale=2; $DEX_SIZE / 1024 / 1024" | bc)
echo -e " $DEX_NAME: ${GREEN}${DEX_SIZE_MB} MB${NC}"
DEX_TOTAL=$((DEX_TOTAL + DEX_SIZE))
fi
done
DEX_TOTAL_MB=$(echo "scale=2; $DEX_TOTAL / 1024 / 1024" | bc)
echo -e "${YELLOW}Total DEX: $DEX_TOTAL_MB MB${NC}"
# Analyze resources
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Resources${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
if [ -d "$TEMP_DIR/res" ]; then
RES_SIZE=$(du -sh "$TEMP_DIR/res" | cut -f1)
echo -e " res/: ${GREEN}$RES_SIZE${NC}"
# Top resource folders
echo " Top resource folders:"
du -sh "$TEMP_DIR/res"/* | sort -hr | head -10 | sed 's/^/ /'
fi
# Analyze assets
echo
if [ -d "$TEMP_DIR/assets" ]; then
ASSETS_SIZE=$(du -sh "$TEMP_DIR/assets" | cut -f1)
echo -e " assets/: ${GREEN}$ASSETS_SIZE${NC}"
# Top asset files
echo " Top asset files:"
find "$TEMP_DIR/assets" -type f -exec du -h {} \; | sort -hr | head -10 | sed 's/^/ /'
fi
# Analyze native libraries
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Native Libraries${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
if [ -d "$TEMP_DIR/lib" ]; then
LIB_SIZE=$(du -sh "$TEMP_DIR/lib" | cut -f1)
echo -e " lib/: ${GREEN}$LIB_SIZE${NC}"
# By architecture
for arch in "$TEMP_DIR/lib"/*; do
if [ -d "$arch" ]; then
ARCH_NAME=$(basename "$arch")
ARCH_SIZE=$(du -sh "$arch" | cut -f1)
echo -e " $ARCH_NAME: ${GREEN}$ARCH_SIZE${NC}"
# Top libraries in architecture
find "$arch" -type f -name "*.so" -exec du -h {} \; | sort -hr | head -5 | sed 's/^/ /'
fi
done
else
echo " No native libraries"
fi
# Analyze Kotlin metadata
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Kotlin Metadata${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
if [ -d "$TEMP_DIR/kotlin" ]; then
KOTLIN_SIZE=$(du -sh "$TEMP_DIR/kotlin" | cut -f1)
echo -e " kotlin/: ${GREEN}$KOTLIN_SIZE${NC}"
fi
# Method count (if dexdump available)
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Method Count${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
# Try to find dexdump
DEXDUMP=$(which dexdump 2>/dev/null || find "$ANDROID_HOME/build-tools" -name dexdump 2>/dev/null | head -1 || echo "")
if [ -n "$DEXDUMP" ] && [ -x "$DEXDUMP" ]; then
TOTAL_METHODS=0
for dex in "$TEMP_DIR"/*.dex; do
if [ -f "$dex" ]; then
DEX_NAME=$(basename "$dex")
METHOD_COUNT=$("$DEXDUMP" -l xml "$dex" | grep -c "<method " || echo "0")
echo -e " $DEX_NAME: ${GREEN}$METHOD_COUNT methods${NC}"
TOTAL_METHODS=$((TOTAL_METHODS + METHOD_COUNT))
fi
done
echo -e "${YELLOW}Total methods: $TOTAL_METHODS${NC}"
# Check multidex threshold
if [ $TOTAL_METHODS -gt 65536 ]; then
echo -e "${RED} ⚠ Exceeded 64K method limit (multidex required)${NC}"
else
REMAINING=$((65536 - TOTAL_METHODS))
echo -e "${GREEN} ✓ Below 64K limit ($REMAINING methods remaining)${NC}"
fi
else
echo -e "${YELLOW} dexdump not found - cannot analyze method count${NC}"
echo " Set ANDROID_HOME or install Android SDK build-tools"
fi
# Size breakdown percentages
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Size Breakdown${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
# Calculate percentages
CODE_SIZE=$(du -s "$TEMP_DIR"/*.dex 2>/dev/null | awk '{sum+=$1} END {print sum}' || echo "0")
RES_SIZE_BYTES=$(du -s "$TEMP_DIR/res" 2>/dev/null | awk '{print $1}' || echo "0")
ASSETS_SIZE_BYTES=$(du -s "$TEMP_DIR/assets" 2>/dev/null | awk '{print $1}' || echo "0")
LIB_SIZE_BYTES=$(du -s "$TEMP_DIR/lib" 2>/dev/null | awk '{print $1}' || echo "0")
# Convert to KB for bc
APK_SIZE_KB=$((APK_SIZE_BYTES / 1024))
CODE_PCT=$(echo "scale=1; $CODE_SIZE * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
RES_PCT=$(echo "scale=1; $RES_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
ASSETS_PCT=$(echo "scale=1; $ASSETS_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
LIB_PCT=$(echo "scale=1; $LIB_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
echo -e " Code (DEX): ${CODE_PCT}%"
echo -e " Resources: ${RES_PCT}%"
echo -e " Assets: ${ASSETS_PCT}%"
echo -e " Native libs: ${LIB_PCT}%"
# Recommendations
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Optimization Recommendations${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
# Check if resources are large
if [ $(echo "$RES_PCT > 30" | bc 2>/dev/null || echo "0") -eq 1 ]; then
echo -e "${YELLOW} • Resources are ${RES_PCT}% of APK - consider:${NC}"
echo " - Enable resource shrinking (shrinkResources = true)"
echo " - Use WebP for images instead of PNG/JPG"
echo " - Remove unused resources"
fi
# Check if native libs are large
if [ $(echo "$LIB_PCT > 40" | bc 2>/dev/null || echo "0") -eq 1 ]; then
echo -e "${YELLOW} • Native libraries are ${LIB_PCT}% of APK - consider:${NC}"
echo " - Use App Bundle to serve ABI-specific APKs"
echo " - Remove unused ABIs"
fi
# Check if code is large
if [ $(echo "$CODE_PCT > 40" | bc 2>/dev/null || echo "0") -eq 1 ]; then
echo -e "${YELLOW} • Code is ${CODE_PCT}% of APK - consider:${NC}"
echo " - Enable Proguard/R8 (minifyEnabled = true)"
echo " - Review dependencies for bloat"
echo " - Enable code shrinking"
fi
# General recommendations
echo -e "${GREEN} General optimizations:${NC}"
echo " - Use Android App Bundle (.aab) instead of APK"
echo " - Enable R8 optimization (minifyEnabled = true)"
echo " - Enable resource shrinking (shrinkResources = true)"
echo " - Analyze with APK Analyzer in Android Studio"
# Cleanup
echo
echo -e "${YELLOW}Cleaning up temporary files...${NC}"
rm -rf "$TEMP_DIR"
echo
echo -e "${GREEN}✓ Analysis complete${NC}"

View File

@@ -0,0 +1,339 @@
# Compose Desktop Skill
## Desktop Application Entry Point
```kotlin
// desktopApp/src/jvmMain/kotlin/Main.kt
package com.vitorpamplona.amethyst.desktop
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
fun main() = application {
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
// System tray
Tray(
icon = painterResource("icon.png"),
tooltip = "Amethyst",
menu = {
Item("Show", onClick = { windowState.isMinimized = false })
Separator()
Item("Exit", onClick = ::exitApplication)
}
)
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Amethyst",
icon = painterResource("icon.png")
) {
MenuBar {
Menu("File") {
Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) { }
Separator()
Item("Settings", shortcut = KeyShortcut(Key.Comma, ctrl = true)) { }
Separator()
Item("Quit", shortcut = KeyShortcut(Key.Q, ctrl = true), onClick = ::exitApplication)
}
Menu("Edit") {
Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true)) { }
Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true)) { }
}
Menu("View") {
Item("Feed") { }
Item("Messages") { }
Item("Notifications") { }
}
Menu("Help") {
Item("About Amethyst") { }
}
}
App()
}
}
```
## Desktop-Specific Components
### File Dialog
```kotlin
@Composable
fun rememberFileDialog(): FileDialogState {
return remember { FileDialogState() }
}
class FileDialogState {
var isOpen by mutableStateOf(false)
var result by mutableStateOf<File?>(null)
fun open() { isOpen = true }
@Composable
fun Dialog(
title: String = "Select File",
allowedExtensions: List<String> = emptyList()
) {
if (isOpen) {
DisposableEffect(Unit) {
val dialog = java.awt.FileDialog(null as java.awt.Frame?, title)
if (allowedExtensions.isNotEmpty()) {
dialog.setFilenameFilter { _, name ->
allowedExtensions.any { name.endsWith(it) }
}
}
dialog.isVisible = true
result = dialog.file?.let { File(dialog.directory, it) }
isOpen = false
onDispose { }
}
}
}
}
```
### Scroll Behavior
```kotlin
@Composable
fun DesktopScrollableColumn(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
val scrollState = rememberScrollState()
Box(modifier) {
Column(
modifier = Modifier
.verticalScroll(scrollState)
.fillMaxSize()
) {
content()
}
VerticalScrollbar(
modifier = Modifier.align(Alignment.CenterEnd),
adapter = rememberScrollbarAdapter(scrollState)
)
}
}
```
### Keyboard Navigation
```kotlin
@Composable
fun KeyboardNavigableList(
items: List<Note>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
onActivate: (Note) -> Unit
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
LazyColumn(
modifier = Modifier
.focusRequester(focusRequester)
.focusable()
.onKeyEvent { event ->
when {
event.key == Key.DirectionDown && event.type == KeyEventType.KeyDown -> {
onSelect((selectedIndex + 1).coerceAtMost(items.lastIndex))
true
}
event.key == Key.DirectionUp && event.type == KeyEventType.KeyDown -> {
onSelect((selectedIndex - 1).coerceAtLeast(0))
true
}
event.key == Key.Enter && event.type == KeyEventType.KeyDown -> {
items.getOrNull(selectedIndex)?.let { onActivate(it) }
true
}
else -> false
}
}
) {
itemsIndexed(items) { index, note ->
NoteCard(
note = note,
isSelected = index == selectedIndex,
modifier = Modifier.clickable { onSelect(index) }
)
}
}
}
```
### Multi-Window Support
```kotlin
@Composable
fun ApplicationScope.NoteDetailWindow(
note: Note,
onClose: () -> Unit
) {
Window(
onCloseRequest = onClose,
title = "Note by ${note.author.name}",
state = rememberWindowState(width = 600.dp, height = 400.dp)
) {
NoteDetailScreen(note)
}
}
// Usage in main application
var openNotes by remember { mutableStateOf<List<Note>>(emptyList()) }
openNotes.forEach { note ->
key(note.id) {
NoteDetailWindow(
note = note,
onClose = { openNotes = openNotes - note }
)
}
}
```
### Tooltips
```kotlin
@Composable
fun TooltipButton(
tooltip: String,
onClick: () -> Unit,
content: @Composable () -> Unit
) {
TooltipArea(
tooltip = {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.inverseSurface
) {
Text(
text = tooltip,
modifier = Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.inverseOnSurface
)
}
}
) {
IconButton(onClick = onClick) {
content()
}
}
}
```
## Desktop Layout Pattern
```kotlin
@Composable
fun DesktopAppLayout(
currentScreen: Screen,
onNavigate: (Screen) -> Unit,
content: @Composable () -> Unit
) {
Row(Modifier.fillMaxSize()) {
// Sidebar navigation
NavigationRail(
modifier = Modifier.width(72.dp),
containerColor = MaterialTheme.colorScheme.surfaceVariant
) {
Spacer(Modifier.height(16.dp))
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == Screen.Feed,
onClick = { onNavigate(Screen.Feed) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Email, "Messages") },
label = { Text("DMs") },
selected = currentScreen == Screen.Messages,
onClick = { onNavigate(Screen.Messages) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Notifications, "Notifications") },
label = { Text("Alerts") },
selected = currentScreen == Screen.Notifications,
onClick = { onNavigate(Screen.Notifications) }
)
Spacer(Modifier.weight(1f))
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, "Settings") },
label = { Text("Settings") },
selected = currentScreen == Screen.Settings,
onClick = { onNavigate(Screen.Settings) }
)
}
// Divider
VerticalDivider()
// Main content
Box(Modifier.weight(1f).fillMaxHeight()) {
content()
}
}
}
```
## Build Configuration
```kotlin
// desktopApp/build.gradle.kts
plugins {
kotlin("jvm")
id("org.jetbrains.compose")
}
dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(project(":quartz"))
}
compose.desktop {
application {
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
nativeDistributions {
targetFormats(
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb
)
packageName = "Amethyst"
packageVersion = "1.0.0"
macOS {
bundleID = "com.vitorpamplona.amethyst.desktop"
iconFile.set(project.file("icons/icon.icns"))
}
windows {
iconFile.set(project.file("icons/icon.ico"))
menuGroup = "Amethyst"
}
linux {
iconFile.set(project.file("icons/icon.png"))
}
}
}
}
```

View File

@@ -0,0 +1,577 @@
---
name: compose-expert
description: Advanced Compose Multiplatform UI patterns for shared composables. Use when working with visual UI components, state management patterns (remember, derivedStateOf, produceState), recomposition optimization (@Stable/@Immutable visual usage), Material3 theming, custom ImageVector icons, or determining whether to share UI in commonMain vs keep platform-specific. Delegates navigation to android-expert/desktop-expert. Complements kotlin-expert (handles Kotlin language aspects of state/annotations).
---
# Compose Multiplatform Expert
Visual UI patterns for sharing composables across Android and Desktop.
## When to Use This Skill
- Creating or refactoring shared UI components
- Deciding whether to share UI in `commonMain` or keep platform-specific
- Building custom ImageVector icons (robohash pattern)
- State management: remember, derivedStateOf, produceState
- Recomposition optimization: visual usage of @Stable/@Immutable
- Material3 theming and styling
- Performance: lazy lists, image loading
**Delegate to other skills:**
- Navigation structure → `android-expert`, `desktop-expert`
- Kotlin state patterns (StateFlow, sealed classes) → `kotlin-expert`
- Build configuration → `gradle-expert`
## Philosophy: Share by Default
**Default to `commons/commonMain`** unless platform experts indicate otherwise.
### Always Share
- **UI components**: Buttons, cards, lists, dialogs, inputs
- **State visualization**: Loading, empty, error states
- **Custom icons**: ImageVector assets (robohash, custom paths)
- **Theme utilities**: Color calculations, style helpers
- **Material3 components**: Any UI using Material primitives
### Keep Platform-Specific
- **Navigation structure**: Bottom nav (Android) vs Sidebar (Desktop)
- **Screen layouts**: Platform-specific scaffolding
- **System integrations**: File pickers, notifications, share sheets
- **Platform UX**: Gestures, keyboard shortcuts, window management
### Decision Framework
1. **Uses only Material3 primitives?** → Share in `commonMain`
2. **Requires platform system APIs?** → Platform-specific
3. **Pure visual component without navigation?** → Share in `commonMain`
4. **Needs platform UX patterns?** → Ask `android-expert` or `desktop-expert`
If uncertain, **default to sharing** - easier to split later than merge.
## Shared Composable Anatomy
### Structure
```kotlin
@Composable
fun SharedComponent(
// State parameters (read-only)
data: DataClass,
isLoading: Boolean,
// Event parameters (write-only)
onAction: () -> Unit,
// Visual parameters
modifier: Modifier = Modifier,
// Optional customization
colors: ComponentColors = ComponentDefaults.colors()
) {
// Implementation
}
```
**Pattern**: State down, events up
- Parameters above modifier = required state/events
- `modifier` parameter = layout control
- Parameters below modifier = optional customization
### Example: AddButton
```kotlin
@Composable
fun AddButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
text: String = "Add",
enabled: Boolean = true
) {
OutlinedButton(
modifier = modifier,
enabled = enabled,
onClick = onClick,
shape = ActionButtonShape,
contentPadding = ActionButtonPadding
) {
Text(text = text, textAlign = TextAlign.Center)
}
}
// Shared constants for consistency
val ActionButtonShape = RoundedCornerShape(20.dp)
val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
```
**Why this works on all platforms:**
- Material3 primitives (OutlinedButton, Text)
- No platform APIs
- Configurable through parameters
- Consistent styling via shared constants
## State Management Patterns
### remember - Cache Across Recompositions
```kotlin
@Composable
fun ExpandableCard() {
var isExpanded by remember { mutableStateOf(false) }
Column {
IconButton(onClick = { isExpanded = !isExpanded }) {
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Collapse" else "Expand"
)
}
if (isExpanded) {
Text("Expanded content...")
}
}
}
```
**Visual pattern**: Toggle button → state changes → UI expands/collapses
**Use for**: Simple UI state (toggles, counters, text input)
### derivedStateOf - Optimize Frequent Changes
```kotlin
@Composable
fun ScrollToTopButton(listState: LazyListState) {
// Only recomposes when showButton changes, not every scroll pixel
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
if (showButton) {
FloatingActionButton(onClick = { /* scroll to top */ }) {
Icon(Icons.Default.ArrowUpward, null)
}
}
}
```
**Visual pattern**: Scroll position (0, 1, 2...) → boolean (show/hide) → Button visibility
**Use for**: Input changes frequently, derived result changes rarely
**Performance**: Prevents recomposition on every scroll event
### produceState - Async to Compose State
```kotlin
@Composable
fun LoadUserProfile(userId: String): State<User?> {
return produceState<User?>(initialValue = null, userId) {
value = repository.fetchUser(userId)
}
}
@Composable
fun ProfileScreen(userId: String) {
val user by LoadUserProfile(userId)
when (user) {
null -> LoadingState("Loading profile...")
else -> ProfileCard(user!!)
}
}
```
**Visual pattern**: Async operation → state updates → UI reflects changes
**Use for**: Convert Flow, LiveData, callbacks into Compose state
**Lifecycle**: Coroutine cancelled when composable leaves composition
For Kotlin-specific state patterns (StateFlow, sealed classes), see `kotlin-expert`.
## State Hoisting
Move state up to make composables reusable:
```kotlin
// ❌ Stateful - hard to test, can't control externally
@Composable
fun BadSearchBar() {
var query by remember { mutableStateOf("") }
TextField(value = query, onValueChange = { query = it })
}
// ✅ Stateless - reusable, testable
@Composable
fun GoodSearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier
)
}
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") }
Column {
GoodSearchBar(query = query, onQueryChange = { query = it })
SearchResults(query = query)
}
}
```
**Principle**: State up, events down
- State: `query: String` (read-only parameter)
- Events: `onQueryChange: (String) -> Unit` (callback parameter)
## Recomposition Optimization
### Visual Usage of @Immutable
Use @Immutable on data classes passed to composables:
```kotlin
@Immutable
data class UserProfile(val name: String, val avatar: String)
@Composable
fun ProfileCard(profile: UserProfile) {
// Only recomposes when profile instance changes
Row {
RobohashImage(robot = profile.avatar)
Text(profile.name, style = MaterialTheme.typography.titleMedium)
}
}
```
**Visual effect**: Prevents recomposition when parent recomposes with same data
**Pattern**: Mark parameter data classes as @Immutable
**Note**: For Kotlin language details on @Immutable, see `kotlin-expert`
### Stable Parameters
```kotlin
// ✅ Stable - won't trigger recomposition unless colors instance changes
@Composable
fun ThemedCard(
content: String,
colors: CardColors = CardDefaults.colors(),
modifier: Modifier = Modifier
) {
Card(colors = colors, modifier = modifier) {
Text(content)
}
}
```
For @Stable annotation details, see `kotlin-expert`.
## Material3 Theming
All shared composables use Material3 for consistency:
```kotlin
@Composable
fun ThemedComponent() {
val bg = MaterialTheme.colorScheme.background
val fg = MaterialTheme.colorScheme.onBackground
val primary = MaterialTheme.colorScheme.primary
Column(
modifier = Modifier.background(bg)
) {
Text(
"Title",
style = MaterialTheme.typography.headlineMedium,
color = fg
)
Button(
onClick = { /* ... */ },
colors = ButtonDefaults.buttonColors(containerColor = primary)
) {
Text("Action")
}
}
}
```
**Principles:**
- Colors: `MaterialTheme.colorScheme.*`
- Typography: `MaterialTheme.typography.*`
- Shapes: `MaterialTheme.shapes.*`
### Theme Detection
```kotlin
@Composable
private fun isLightTheme(): Boolean {
val background = MaterialTheme.colorScheme.background
return (background.red + background.green + background.blue) / 3 > 0.5f
}
@Composable
fun ThemedIcon() {
val isDark = !isLightTheme()
val tint = if (isDark) Color.White else Color.Black
Icon(Icons.Default.Face, null, tint = tint)
}
```
## Custom Icons: ImageVector Pattern
Amethyst uses ImageVector for multiplatform icons.
### roboBuilder DSL
```kotlin
fun roboBuilder(block: Builder.() -> Unit): ImageVector {
return ImageVector.Builder(
name = "Robohash",
defaultWidth = 300.dp,
defaultHeight = 300.dp,
viewportWidth = 300f,
viewportHeight = 300f
).apply(block).build()
}
```
### Building Icons
```kotlin
fun customIcon(fgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
builder.addPath(pathData3, fill = Black, fillAlpha = 0.2f)
}
private val pathData1 = PathData {
moveTo(144.5f, 87.5f)
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
lineToRelative(16.0f, 16.0f)
close()
}
@Composable
fun CustomIcon() {
Image(
painter = rememberVectorPainter(
roboBuilder {
customIcon(SolidColor(Color.Blue), this)
}
),
contentDescription = "Custom icon"
)
}
```
**Why ImageVector?**
- Pure Kotlin, no XML
- Works on Android, Desktop, iOS
- GPU-accelerated
- Type-safe
### Caching Pattern
```kotlin
object CustomIcons {
private val cache = mutableMapOf<String, ImageVector>()
fun get(key: String): ImageVector {
return cache.getOrPut(key) {
buildIcon(key)
}
}
}
@Composable
fun CachedIcon(key: String) {
Image(imageVector = CustomIcons.get(key), contentDescription = null)
}
```
For detailed icon patterns, see `references/icon-assets.md`.
## Common Visual Patterns
### State Visualization
```kotlin
@Composable
fun DataScreen(uiState: UiState) {
when (uiState) {
is UiState.Loading -> LoadingState("Loading...")
is UiState.Empty -> EmptyState(
title = "No data",
onRefresh = { /* refresh */ }
)
is UiState.Error -> ErrorState(
message = uiState.message,
onRetry = { /* retry */ }
)
is UiState.Success -> ContentList(uiState.items)
}
}
```
**Components** (all in `commons/commonMain`):
- `LoadingState` - Progress indicator + message
- `EmptyState` - Empty message + optional refresh button
- `ErrorState` - Error message + optional retry button
### Relay Status (Amethyst Pattern)
```kotlin
@Composable
fun RelayStatusIndicator(connectedCount: Int) {
val statusColor = when {
connectedCount == 0 -> RelayStatusColors.Disconnected
connectedCount < 3 -> RelayStatusColors.Connecting
else -> RelayStatusColors.Connected
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
tint = statusColor,
modifier = Modifier.size(16.dp)
)
Text(
"$connectedCount relay${if (connectedCount != 1) "s" else ""}",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
```
**Visual mapping**:
- 0 relays → Red + X icon
- 1-2 relays → Yellow + Check icon
- 3+ relays → Green + Check icon
### Placeholder Pattern
```kotlin
@Composable
fun PlaceholderScreen(
title: String,
description: String,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Text(title, style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(16.dp))
Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
// Specific implementations
@Composable
fun SearchPlaceholder() = PlaceholderScreen(
title = "Search",
description = "Search for users, notes, and hashtags."
)
```
**Pattern**: Generic composable + specific wrappers with preset text
## Performance
### Avoid Unnecessary Recomposition
```kotlin
// ❌ Bad - recomposes on every scroll
@Composable
fun BadButton(scrollState: ScrollState) {
if (scrollState.value > 100) {
Button(onClick = {}) { Text("Top") }
}
}
// ✅ Good - only recomposes when visibility changes
@Composable
fun GoodButton(scrollState: ScrollState) {
val show by remember { derivedStateOf { scrollState.value > 100 } }
if (show) {
Button(onClick = {}) { Text("Top") }
}
}
```
### Lazy Lists
```kotlin
@Composable
fun FeedList(items: List<Item>) {
LazyColumn {
items(items, key = { it.id }) { item ->
FeedItem(item)
}
}
}
```
**Key principle**: Use `key` parameter for stable item identity
## Bundled Resources
- **references/shared-composables-catalog.md** - Complete catalog of shared UI components
- **references/state-patterns.md** - State management patterns with visual examples
- **references/icon-assets.md** - Custom ImageVector icon patterns
- **scripts/find-composables.sh** - Find all @Composable functions in codebase
## Quick Reference
| Task | Pattern | Location |
|------|---------|----------|
| Reusable UI | State hoisting | commons/commonMain |
| Simple state | remember { mutableStateOf() } | Composable scope |
| Derived state | derivedStateOf { } | remember block |
| Async → state | produceState { } | Composable function |
| Custom icons | roboBuilder + PathData | commons/icons |
| Loading/Error | LoadingState, ErrorState | commons/ui/components |
| Theme colors | MaterialTheme.colorScheme | Any @Composable |
| Navigation | Delegate to platform expert | amethyst/, desktopApp/ |
## Common Workflows
### Creating a Shared Component
1. Start in `commons/src/commonMain/kotlin/.../ui/components/`
2. Use Material3 primitives only
3. Hoist state (parameters for data, callbacks for events)
4. Add modifier parameter
5. Use MaterialTheme for colors/typography
6. Test on both Android and Desktop
### Converting Existing Component
1. Read current implementation in `amethyst/` or `desktopApp/`
2. Identify pure visual logic (no platform APIs)
3. Create in `commons/commonMain` with hoisted state
4. Replace platform implementations with shared component
5. Keep platform-specific wrappers if needed
### Custom Icon
1. Export SVG from design tool
2. Convert to PathData using Android Studio
3. Create icon function with roboBuilder
4. Add caching if generated dynamically
5. Wrap in @Composable for easy use
### Navigation (Delegate)
For navigation patterns:
- Android bottom nav → `android-expert`
- Desktop sidebar → `desktop-expert`
- Multi-window → `desktop-expert`
## Related Skills
- **kotlin-expert** - Kotlin language aspects (@Immutable details, StateFlow, sealed classes)
- **android-expert** - Android navigation, platform APIs
- **desktop-expert** - Desktop navigation, window management, OS specifics
- **kotlin-coroutines** - Async patterns, Flow integration

View File

@@ -0,0 +1,365 @@
# Custom Icon Assets and ImageVector Patterns
Guide to creating and using custom ImageVector icons in Compose Multiplatform.
## Why ImageVector?
ImageVector is the native Compose format for vector graphics:
- **Pure Kotlin**: No XML, no asset files
- **Multiplatform**: Works on Android, Desktop, iOS without conversion
- **Performant**: Lightweight, composable, GPU-accelerated
- **Type-safe**: Compile-time checking, no resource IDs
## Amethyst Pattern: Robohash
Amethyst generates deterministic avatars using ImageVector builders.
### Architecture
```
commons/robohash/
├── RobohashAssembler.kt # Main assembly logic
├── CachedRobohash.kt # Caching layer
└── parts/
├── Face0C3po.kt # Face variants (0-9)
├── Eyes2Single.kt # Eye variants (0-9)
├── Mouth3Grid.kt # Mouth variants (0-9)
├── Body2Thinnest.kt # Body variants (0-9)
└── Accessory7Antenna.kt # Accessory variants (0-9)
```
**Pattern**: 10 variants per feature × 5 features = 100,000+ unique combinations
### roboBuilder DSL
Custom ImageVector builder with sensible defaults:
```kotlin
fun roboBuilder(block: Builder.() -> Unit): ImageVector {
return ImageVector.Builder(
name = "Robohash",
defaultWidth = 300.dp,
defaultHeight = 300.dp,
viewportWidth = 300f,
viewportHeight = 300f
).apply(block).build()
}
```
**Usage**:
```kotlin
@Composable
fun CustomIcon() {
Image(
painter = rememberVectorPainter(
roboBuilder {
// Add paths here
}
),
contentDescription = "Custom icon"
)
}
```
### Path Building Pattern
```kotlin
fun face0C3po(fgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
builder.addPath(pathData5, fill = Black, fillAlpha = 0.2f)
builder.addPath(pathData6, stroke = Black, strokeLineWidth = 1.0f)
builder.addPath(pathData7, fill = Black, stroke = Black, fillAlpha = 0.2f, strokeLineWidth = 0.75f)
}
private val pathData1 = PathData {
moveTo(144.5f, 87.5f)
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
curveToRelative(0.0f, 0.0f, 0.0f, 27.0f, 5.0f, 42.0f)
reflectiveCurveToRelative(10.0f, 38.0f, 10.0f, 38.0f)
lineToRelative(16.0f, 16.0f)
// ...
close()
}
```
**Key elements**:
- `pathData` variables for path commands
- `addPath()` for each layer
- Parameterized colors (`fgColor`)
- Constant colors (`Black`)
- Alpha for shadows/highlights
### PathData DSL
Compose's PathData builder provides SVG-like commands:
| Command | Description | Example |
|---------|-------------|---------|
| `moveTo(x, y)` | Move pen without drawing | `moveTo(100f, 100f)` |
| `lineTo(x, y)` | Draw line to point | `lineTo(200f, 150f)` |
| `curveToRelative(...)` | Relative cubic Bézier | `curveToRelative(10f, 20f, 30f, 40f, 50f, 60f)` |
| `reflectiveCurveToRelative(...)` | Smooth curve | `reflectiveCurveToRelative(-51f, 3f, -53f, 55f)` |
| `horizontalLineTo(x)` | Horizontal line | `horizontalLineTo(250f)` |
| `verticalLineTo(y)` | Vertical line | `verticalLineTo(300f)` |
| `close()` | Close path | `close()` |
**Relative vs Absolute**:
- `moveTo` / `lineTo` - Absolute coordinates
- `moveToRelative` / `lineToRelative` - Relative to current position
## Creating Custom Icons
### Method 1: From SVG (Recommended)
1. **Export SVG** from design tool (Figma, Illustrator)
2. **Convert to ImageVector** using Android Studio's Vector Asset tool
3. **Extract path data** and adapt to roboBuilder pattern
```kotlin
// SVG path: M 10 10 L 20 20 ...
// Becomes:
private val myIconPath = PathData {
moveTo(10f, 10f)
lineTo(20f, 20f)
// ...
}
```
### Method 2: Programmatic
Build paths programmatically for simple shapes:
```kotlin
fun simpleIcon(): ImageVector = roboBuilder {
addPath(
pathData = PathData {
moveTo(50f, 50f)
lineTo(150f, 50f)
lineTo(150f, 150f)
lineTo(50f, 150f)
close()
},
fill = SolidColor(Color.Blue),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f
)
}
```
### Method 3: Material Icons Extensions
Extend Material Icons when you need platform-consistent icons:
```kotlin
// For standard icons, use Material Icons
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
Icon(Icons.Default.Check, contentDescription = "Success")
Icon(Icons.Default.Close, contentDescription = "Error")
```
## CachedRobohash Pattern
Performance optimization for generated icons:
```kotlin
object CachedRobohash {
private val cache = mutableMapOf<Pair<String, Boolean>, ImageVector>()
fun get(seed: String, isLight: Boolean): ImageVector {
return cache.getOrPut(seed to isLight) {
RobohashAssembler.assemble(seed, isLight)
}
}
}
```
**Pattern**:
- Key: `(seed, theme)` pair
- Value: Assembled ImageVector
- Lifecycle: Application lifetime (never cleared)
**Usage**:
```kotlin
@Composable
fun RobohashImage(robot: String) {
Image(
imageVector = CachedRobohash.get(robot, isLightTheme()),
contentDescription = "Avatar for $robot"
)
}
```
## Color Management
### Dynamic Colors
Pass colors as parameters for theme adaptation:
```kotlin
fun themedIcon(fgColor: SolidColor, bgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = bgColor)
builder.addPath(pathData2, fill = fgColor)
}
@Composable
fun ThemedIcon() {
val fg = MaterialTheme.colorScheme.primary
val bg = MaterialTheme.colorScheme.surface
Image(
painter = rememberVectorPainter(
roboBuilder {
themedIcon(SolidColor(fg), SolidColor(bg), this)
}
),
contentDescription = null
)
}
```
### Static Colors
Define constants for colors that don't change:
```kotlin
val Black = SolidColor(Color.Black)
val White = SolidColor(Color.White)
val Transparent = SolidColor(Color.Transparent)
```
## Advanced Techniques
### Layering
Build complex icons with multiple layers:
```kotlin
fun complexIcon(builder: Builder) {
// Layer 1: Background
builder.addPath(bgPath, fill = SolidColor(Color.White))
// Layer 2: Shadow
builder.addPath(shadowPath, fill = SolidColor(Color.Black), fillAlpha = 0.2f)
// Layer 3: Main shape
builder.addPath(mainPath, fill = SolidColor(Color.Blue))
// Layer 4: Highlight
builder.addPath(highlightPath, fill = SolidColor(Color.White), fillAlpha = 0.3f)
// Layer 5: Stroke
builder.addPath(outlinePath, stroke = SolidColor(Color.Black), strokeLineWidth = 1f)
}
```
**Render order**: Bottom to top (first addPath = bottom layer)
### Alpha for Visual Effects
```kotlin
// Shadow
builder.addPath(shadowPath, fill = Black, fillAlpha = 0.4f)
// Highlight
builder.addPath(highlightPath, fill = White, fillAlpha = 0.2f)
// Glass effect
builder.addPath(glassPath, fill = White, fillAlpha = 0.1f)
```
### Stroke Styles
```kotlin
// Outline only
builder.addPath(path, stroke = Black, strokeLineWidth = 1.5f)
// Fill + outline
builder.addPath(path, fill = fgColor, stroke = Black, strokeLineWidth = 1f)
// Dashed (not supported directly, use multiple segments)
```
## Composable Icon Pattern
Wrap ImageVector in a Composable for reusability:
```kotlin
@Composable
fun MyCustomIcon(
modifier: Modifier = Modifier,
tint: Color = Color.Unspecified
) {
Image(
painter = rememberVectorPainter(myIconVector()),
contentDescription = "My custom icon",
modifier = modifier,
colorFilter = if (tint != Color.Unspecified) {
ColorFilter.tint(tint)
} else null
)
}
```
**Usage**:
```kotlin
MyCustomIcon(
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
```
## Best Practices
### DO
✅ Cache generated ImageVectors for performance
✅ Use PathData DSL for readability
✅ Parameterize colors for theme support
✅ Use Material Icons for standard icons
✅ Keep viewport size consistent (e.g., 300×300)
✅ Layer paths from back to front
✅ Use alpha for shadows and highlights
### DON'T
❌ Generate ImageVectors in @Composable without caching
❌ Hardcode theme-specific colors
❌ Create custom icons for standard Material icons
❌ Use extreme viewport sizes (stay 24-1000dp)
❌ Mix absolute and relative coordinates unnecessarily
❌ Forget to close() paths
## Icon Organization
### Structure
```
commons/icons/
├── CustomIcons.kt # Icon collection object
├── icons/
│ ├── Zap.kt # Lightning bolt
│ ├── Relay.kt # Relay indicator
│ └── Bitcoin.kt # Bitcoin symbol
└── builders/
└── IconBuilder.kt # Shared builder utilities
```
### Collection Object
```kotlin
object CustomIcons {
val Zap: ImageVector by lazy { ZapIcon.create() }
val Relay: ImageVector by lazy { RelayIcon.create() }
val Bitcoin: ImageVector by lazy { BitcoinIcon.create() }
}
// Usage
Icon(CustomIcons.Zap, contentDescription = "Zap")
```
## Resources
- [Compose ImageVector API](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/vector/ImageVector)
- [SVG Path Commands](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths)
- [Material Icons](https://fonts.google.com/icons)
- Robohash implementation: `commons/robohash/` in AmethystMultiplatform

View File

@@ -0,0 +1,281 @@
# Shared Composables Catalog
This catalog documents shared UI components in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/`.
## Directory Structure
```
commons/src/commonMain/kotlin/.../commons/ui/
├── components/ # Reusable UI components
├── screens/ # Screen-level composables
├── theme/ # Theming and styling
└── feed/ # Feed-specific components
```
## Components (`ui/components/`)
### State Visualization
**LoadingState** - Centered loading indicator with message
```kotlin
@Composable
fun LoadingState(message: String, modifier: Modifier = Modifier)
```
- Use for: Async operations, data fetching
- Pattern: fillMaxSize, centered Column, CircularProgressIndicator
- Works on: Android, Desktop
**EmptyState** - Centered empty state with optional refresh
```kotlin
@Composable
fun EmptyState(
title: String,
modifier: Modifier = Modifier,
description: String? = null,
onRefresh: (() -> Unit)? = null,
refreshLabel: String = "Refresh"
)
```
- Use for: Empty lists, no data scenarios
- Pattern: Centered Column, optional OutlinedButton
- Works on: Android, Desktop
**ErrorState** - Centered error message with retry
```kotlin
@Composable
fun ErrorState(
message: String,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
retryLabel: String = "Try Again"
)
```
- Use for: Error handling, failed operations
- Pattern: error color, optional Button
- Works on: Android, Desktop
### Feed-Specific States
**FeedEmptyState** - Pre-configured empty state for feeds
```kotlin
@Composable
fun FeedEmptyState(
modifier: Modifier = Modifier,
title: String = "Feed is empty",
onRefresh: (() -> Unit)? = null
)
```
**FeedErrorState** - Pre-configured error state for feeds
```kotlin
@Composable
fun FeedErrorState(
errorMessage: String,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null
)
```
### Action Buttons
**Shared Constants**:
```kotlin
val ActionButtonShape = RoundedCornerShape(20.dp)
val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
```
**AddButton** - Consistent "Add" action button
```kotlin
@Composable
fun AddButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
text: String = "Add",
enabled: Boolean = true
)
```
- Pattern: OutlinedButton with consistent shape/padding
- Works on: Android, Desktop
**RemoveButton** - Consistent "Remove" action button
```kotlin
@Composable
fun RemoveButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
text: String = "Remove",
enabled: Boolean = true
)
```
### Custom Images
**RobohashImage** - Deterministic avatar generation
```kotlin
@Composable
fun RobohashImage(
robot: String, // Seed (e.g., pubkey)
modifier: Modifier = Modifier,
contentDescription: String? = null,
loadRobohash: Boolean = true
)
// Overload with more options
@Composable
fun RobohashImage(
robot: String,
modifier: Modifier = Modifier,
contentDescription: String? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
colorFilter: ColorFilter? = null,
loadRobohash: Boolean = true
)
```
- Use for: User avatars, deterministic graphics
- Pattern: Uses CachedRobohash.get(), isLightTheme() detection
- Fallback: Icons.Default.Face
- Works on: Android, Desktop (pure ImageVector)
**Theme Detection Helper**:
```kotlin
@Composable
private fun isLightTheme(): Boolean {
val background = MaterialTheme.colorScheme.background
return (background.red + background.green + background.blue) / 3 > 0.5f
}
```
## Feed Components (`ui/feed/`)
### FeedHeader
**FeedHeader** - Screen header with title and relay status
```kotlin
@Composable
fun FeedHeader(
title: String,
connectedRelayCount: Int,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
)
```
- Pattern: Row with SpaceBetween, title + RelayStatusIndicator
- Works on: Android, Desktop
**RelayStatusIndicator** - Compact relay connection indicator
```kotlin
@Composable
fun RelayStatusIndicator(
connectedCount: Int,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
)
```
- Pattern: Status icon + count text + refresh button
- Colors: RelayStatusColors.{Disconnected, Connecting, Connected}
- Visual cues: Check icon (connected), Close icon (disconnected)
## Screens (`ui/screens/`)
### Placeholder Pattern
**PlaceholderScreen** - Generic placeholder
```kotlin
@Composable
fun PlaceholderScreen(
title: String,
description: String,
modifier: Modifier = Modifier
)
```
- Pattern: Column with title (headlineMedium) + description
- Use for: Unimplemented screens, coming soon features
**Specific Placeholders**:
- `SearchPlaceholder()` - Search screen
- `MessagesPlaceholder()` - DMs screen
- `NotificationsPlaceholder()` - Notifications screen
Pattern: Specific implementations wrap PlaceholderScreen with preset text.
## Custom Icons (`robohash/parts/`)
### ImageVector Builder Pattern
Amethyst uses a custom DSL for building ImageVector assets:
```kotlin
@Composable
fun Face0C3po() {
Image(
painter = rememberVectorPainter(
roboBuilder {
face0C3po(SolidColor(Color.Blue), this)
}
),
contentDescription = ""
)
}
fun face0C3po(fgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
// ...
}
private val pathData1 = PathData {
moveTo(144.5f, 87.5f)
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
// ... path commands
}
```
**roboBuilder** - Custom ImageVector.Builder DSL
- Located in: `commons/robohash/`
- Pattern: Builder-based, composable paths
- Parts: Face, Eyes, Mouth, Body, Accessory (0-9 variants each)
- Colors: Dynamic (fgColor parameter) + Black constants
### CachedRobohash
```kotlin
CachedRobohash.get(seed: String, isLight: Boolean): ImageVector
```
- Deterministic: Same seed → same avatar
- Theme-aware: Different colors for light/dark
- Cached: Performance optimization
- Pure ImageVector: Works on all platforms
## Sharing Guidelines
### Always Share
- State visualization (Loading, Empty, Error)
- Action buttons with consistent styling
- Generic placeholders
- Custom ImageVector icons
- Material3 themed components
- Theme utilities (isLightTheme)
### Platform-Specific (Delegate to Experts)
- Navigation structure (android-expert, desktop-expert)
- Screen layouts and scaffolds
- Platform system integrations
- Gesture handling specifics
### Decision Framework
1. **Can it use Material3 primitives?** → Share
2. **Does it need platform system APIs?** → Platform-specific
3. **Is it a visual component without navigation?** → Share
4. **Does it require platform UX patterns?** → Ask platform expert
## Material3 Usage
All shared composables use Material3:
- `MaterialTheme.colorScheme.*` for colors
- `MaterialTheme.typography.*` for text styles
- `OutlinedButton`, `Button`, `IconButton` for actions
- `CircularProgressIndicator` for loading
- `Icon`, `Image` for visuals
This ensures consistent theming across Android and Desktop.

View File

@@ -0,0 +1,334 @@
# Compose State Management Patterns
Visual guide to state management in Compose Multiplatform. For Kotlin-specific patterns (StateFlow, sealed classes), see `kotlin-expert` skill.
## Core State Functions
### remember
Cache values across recompositions:
```kotlin
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}
```
**When to use**: Simple UI state (toggles, counters, text input)
**Visual pattern**: Button press → state changes → UI updates
### derivedStateOf
Compute state from other state, recompose only when result changes:
```kotlin
@Composable
fun ScrollToTopButton(listState: LazyListState) {
// Only recomposes when showButton value changes (not every scroll pixel)
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
if (showButton) {
FloatingActionButton(onClick = { /* scroll to top */ }) {
Icon(Icons.Default.ArrowUpward, null)
}
}
}
```
**When to use**: Input state changes frequently, but derived result changes rarely
**Visual pattern**: Scroll position (0, 1, 2...) → boolean (show/hide) → FAB visibility
**Performance**: Prevents recomposition on every scroll event
### produceState
Convert non-Compose state into Compose state:
```kotlin
@Composable
fun LoadUserProfile(userId: String): State<User?> {
return produceState<User?>(initialValue = null, userId) {
value = repository.fetchUser(userId)
}
}
@Composable
fun ProfileScreen(userId: String) {
val user by LoadUserProfile(userId)
when (user) {
null -> LoadingState("Loading profile...")
else -> ProfileCard(user!!)
}
}
```
**When to use**: Convert Flow, LiveData, callbacks into Compose state
**Visual pattern**: Async operation → state updates → UI reflects changes
**Lifecycle**: Coroutine cancelled when composable leaves composition
## State Hoisting Pattern
Move state up to make composables reusable and testable:
### Before (Stateful)
```kotlin
@Composable
fun SearchBar() {
var query by remember { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search...") }
)
}
```
❌ Hard to test, can't control state externally
### After (Stateless)
```kotlin
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search...") },
modifier = modifier
)
}
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") }
Column {
SearchBar(query = query, onQueryChange = { query = it })
SearchResults(query = query)
}
}
```
✅ Reusable, testable, state controlled by parent
**Hoisting principle**: State goes up, events go down
- State: `query: String` (read-only)
- Events: `onQueryChange: (String) -> Unit` (write-only)
## Amethyst State Patterns
### Theme-Aware State
```kotlin
@Composable
private fun isLightTheme(): Boolean {
val background = MaterialTheme.colorScheme.background
return (background.red + background.green + background.blue) / 3 > 0.5f
}
@Composable
fun ThemedContent() {
val isDark = !isLightTheme()
// Adjust visuals based on theme
val iconTint = if (isDark) Color.White else Color.Black
}
```
**Pattern**: Derive state from MaterialTheme
**Visual**: Component adapts to light/dark theme automatically
### Relay Status State
```kotlin
@Composable
fun RelayStatusIndicator(
connectedCount: Int,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
) {
val statusColor = when {
connectedCount == 0 -> RelayStatusColors.Disconnected
connectedCount < 3 -> RelayStatusColors.Connecting
else -> RelayStatusColors.Connected
}
Icon(
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
tint = statusColor
)
}
```
**Pattern**: Visual state derived from domain state
**Visual mapping**:
- 0 relays → Red + X icon
- 1-2 relays → Yellow + Check icon
- 3+ relays → Green + Check icon
### Loading/Empty/Error States
```kotlin
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is UiState.Loading -> LoadingState("Loading feed...")
is UiState.Empty -> FeedEmptyState(onRefresh = { viewModel.refresh() })
is UiState.Error -> FeedErrorState(
errorMessage = uiState.message,
onRetry = { viewModel.retry() }
)
is UiState.Success -> LazyColumn {
items(uiState.items) { FeedItem(it) }
}
}
}
```
**Pattern**: Sealed class → visual state component
**Components**:
- `LoadingState` - Progress indicator
- `EmptyState` - Empty message + refresh
- `ErrorState` - Error message + retry
- Success - Actual content
## Common Patterns
### Toggle State
```kotlin
var isExpanded by remember { mutableStateOf(false) }
IconButton(onClick = { isExpanded = !isExpanded }) {
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Collapse" else "Expand"
)
}
if (isExpanded) {
Text("Expanded content...")
}
```
### List State with Actions
```kotlin
var items by remember { mutableStateOf(listOf("Item 1", "Item 2")) }
Column {
AddButton(onClick = {
items = items + "Item ${items.size + 1}"
})
items.forEachIndexed { index, item ->
Row {
Text(item)
RemoveButton(onClick = {
items = items.filterIndexed { i, _ -> i != index }
})
}
}
}
```
### TextField State
```kotlin
var text by remember { mutableStateOf("") }
TextField(
value = text,
onValueChange = { text = it },
label = { Text("Enter text") }
)
```
## Performance Patterns
### Avoid Unnecessary Recomposition
```kotlin
// ❌ Bad: Recomposes on every scroll position change
@Composable
fun BadScrollButton(scrollState: ScrollState) {
if (scrollState.value > 100) { // scrollState.value changes constantly
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
}
}
// ✅ Good: Only recomposes when visibility changes
@Composable
fun GoodScrollButton(scrollState: ScrollState) {
val showButton by remember {
derivedStateOf { scrollState.value > 100 }
}
if (showButton) {
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
}
}
```
### Stable Parameters
Use `@Immutable` data classes (see `kotlin-expert`) to prevent recomposition:
```kotlin
@Immutable
data class UserProfile(val name: String, val avatar: String)
@Composable
fun ProfileCard(profile: UserProfile) {
// Only recomposes when profile instance changes
Row {
RobohashImage(robot = profile.avatar)
Text(profile.name)
}
}
```
## Integration with Kotlin State
For ViewModel state, Flow, StateFlow → See `kotlin-expert` skill
Common integration pattern:
```kotlin
// ViewModel (Kotlin state)
class FeedViewModel {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
}
// Composable (Compose state)
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val uiState by viewModel.uiState.collectAsState()
// Use uiState to render UI
}
```
## Quick Reference
| Function | Use Case | Recomposes When |
|----------|----------|----------------|
| `remember { mutableStateOf() }` | Local UI state | State value changes |
| `derivedStateOf { }` | Computed state | Derived result changes |
| `produceState { }` | Async/Flow → State | Async operation updates value |
| `collectAsState()` | Flow → State | Flow emits new value |
| State hoisting | Reusable components | Parent passes new state |
## Sources
State management patterns based on:
- [State and Jetpack Compose - Android Developers](https://developer.android.com/develop/ui/compose/state)
- [When should I use derivedStateOf?](https://medium.com/androiddevelopers/jetpack-compose-when-should-i-use-derivedstateof-63ce7954c11b)
- [Advanced State and Side Effects](https://developer.android.com/codelabs/jetpack-compose-advanced-state-side-effects)
- AmethystMultiplatform codebase patterns (2025)

View File

@@ -0,0 +1,34 @@
#!/bin/bash
# Find all @Composable functions in the codebase
set -e
# Default to current directory if no path provided
SEARCH_PATH="${1:-.}"
echo "Searching for @Composable functions in: $SEARCH_PATH"
echo "================================================"
echo ""
# Find all @Composable functions with file paths and line numbers
grep -r -n "@Composable" "$SEARCH_PATH" \
--include="*.kt" \
--exclude-dir=build \
--exclude-dir=.gradle \
| while IFS=: read -r file line content; do
# Extract function name if possible
if [[ $content =~ fun[[:space:]]+([a-zA-Z0-9_]+) ]]; then
func_name="${BASH_REMATCH[1]}"
echo "$file:$line - $func_name"
else
echo "$file:$line"
fi
done
echo ""
echo "Total @Composable functions found:"
grep -r "@Composable" "$SEARCH_PATH" \
--include="*.kt" \
--exclude-dir=build \
--exclude-dir=.gradle \
| wc -l

View File

@@ -0,0 +1,748 @@
# Desktop Expert
Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles.
## When to Use This Skill
**Auto-invoke when:**
- Working with `desktopApp/` module files
- Using Desktop-only APIs: `Window`, `Tray`, `MenuBar`, `Dialog`
- Implementing keyboard shortcuts, menu systems
- Desktop navigation (NavigationRail, multi-window)
- File system operations (file pickers, drag-drop)
- OS-specific behavior (macOS, Windows, Linux)
- Desktop UX patterns (keyboard-first, tooltips)
**Delegate to:**
- **kotlin-multiplatform**: Shared code questions, `jvmMain` source set structure
- **gradle-expert**: All `build.gradle.kts` issues, dependency conflicts
- **compose-expert**: General Compose patterns, `@Composable` best practices, Material3
## Scope
**In scope:**
- Desktop-only Compose APIs
- Window management, positioning, state
- MenuBar + keyboard shortcuts (OS-specific)
- System Tray integration
- Desktop navigation patterns (NavigationRail)
- File dialogs, Desktop.getDesktop()
- OS conventions (macOS vs Windows vs Linux)
- Desktop UX principles
**Out of scope:**
- Build configuration → **gradle-expert**
- Shared composables → **compose-expert**
- KMP structure → **kotlin-multiplatform**
---
## 1. Desktop Entry Point
### application {} DSL
Desktop apps start with the `application {}` block:
```kotlin
// desktopApp/src/jvmMain/kotlin/Main.kt
fun main() = application {
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Amethyst"
) {
MenuBar { /* ... */ }
App()
}
}
```
**Key points:**
- `application {}` is the root composable (JVM-only)
- `Window()` creates the main window
- `rememberWindowState()` manages size/position
- `onCloseRequest` handles window close
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:87-138`
---
## 2. Window Management
### WindowState
```kotlin
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
Window(
state = windowState,
title = "My App",
resizable = true,
onCloseRequest = ::exitApplication
) {
// Content
}
```
### Multiple Windows
```kotlin
fun main() = application {
var showSettings by remember { mutableStateOf(false) }
Window(onCloseRequest = ::exitApplication, title = "Main") {
Button(onClick = { showSettings = true }) {
Text("Open Settings")
}
}
if (showSettings) {
Window(
onCloseRequest = { showSettings = false },
title = "Settings"
) {
// Settings UI
}
}
}
```
**Pattern:** Use state to control window visibility conditionally.
---
## 3. MenuBar System
### Basic MenuBar
```kotlin
Window(onCloseRequest = ::exitApplication, title = "App") {
MenuBar {
Menu("File") {
Item("New Note", onClick = { /* ... */ })
Separator()
Item("Quit", onClick = ::exitApplication)
}
Menu("Edit") {
Item("Copy", onClick = { /* ... */ })
Item("Paste", onClick = { /* ... */ })
}
}
App()
}
```
### Keyboard Shortcuts (OS-Aware)
**Current issue:** Main.kt hardcodes `ctrl = true` (Main.kt:105, 111, 117, 122, 123).
**OS-specific shortcuts:**
```kotlin
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyShortcut
// Detect OS
val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
MenuBar {
Menu("File") {
Item(
"New Note",
shortcut = if (isMacOS) {
KeyShortcut(Key.N, meta = true) // Cmd+N on macOS
} else {
KeyShortcut(Key.N, ctrl = true) // Ctrl+N on Win/Linux
},
onClick = { /* ... */ }
)
Item(
"Settings",
shortcut = if (isMacOS) {
KeyShortcut(Key.Comma, meta = true) // Cmd+, on macOS
} else {
KeyShortcut(Key.Comma, ctrl = true) // Ctrl+, on Win/Linux
},
onClick = { /* ... */ }
)
Separator()
Item(
"Quit",
shortcut = if (isMacOS) {
KeyShortcut(Key.Q, meta = true) // Cmd+Q on macOS
} else {
KeyShortcut(Key.Q, ctrl = true) // Ctrl+Q on Win/Linux
},
onClick = ::exitApplication
)
}
}
```
**Standard shortcuts:**
| Action | macOS | Windows/Linux |
|--------|-------|---------------|
| New | Cmd+N | Ctrl+N |
| Open | Cmd+O | Ctrl+O |
| Save | Cmd+S | Ctrl+S |
| Quit | Cmd+Q | Ctrl+Q (Alt+F4) |
| Settings | Cmd+, | Ctrl+, |
| Copy | Cmd+C | Ctrl+C |
| Paste | Cmd+V | Ctrl+V |
| Undo | Cmd+Z | Ctrl+Z |
**See:** `references/keyboard-shortcuts.md` for full list.
---
## 4. System Tray
### Basic Tray
```kotlin
application {
var isVisible by remember { mutableStateOf(true) }
Tray(
icon = painterResource("icon.png"),
onAction = { isVisible = true },
menu = {
Item("Show", onClick = { isVisible = true })
Separator()
Item("Quit", onClick = ::exitApplication)
}
)
if (isVisible) {
Window(
onCloseRequest = { isVisible = false }, // Minimize to tray
title = "App"
) {
// Content
}
}
}
```
**Pattern:** Hide window to tray instead of closing.
**Current status:** Not implemented in Main.kt. Planned feature.
---
## 5. Desktop Navigation Patterns
### NavigationRail (Current Pattern)
Desktop uses **NavigationRail** (vertical sidebar) instead of Android's bottom navigation.
```kotlin
Row(Modifier.fillMaxSize()) {
// Sidebar
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant
) {
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == AppScreen.Feed,
onClick = { currentScreen = AppScreen.Feed }
)
// More items...
}
VerticalDivider()
// Main content area
Box(Modifier.weight(1f).fillMaxHeight()) {
when (currentScreen) {
AppScreen.Feed -> FeedScreen()
// Other screens...
}
}
}
```
**See:** Main.kt:191-264
**Why NavigationRail?**
- Desktop has horizontal space (1200+ dp width)
- Vertical sidebar is standard desktop pattern
- Always visible (no tabs hidden)
- Icon + label both visible
**Android comparison:**
- Android: `BottomNavigationBar` (horizontal, bottom)
- Desktop: `NavigationRail` (vertical, left)
### Multi-Pane Layouts
Desktop can leverage wide screens:
```kotlin
Row {
// Left: Navigation
NavigationRail { /* ... */ }
// Center: Main content
Box(Modifier.weight(0.6f)) {
FeedScreen()
}
// Right: Details pane (desktop only)
if (selectedNote != null) {
VerticalDivider()
Box(Modifier.weight(0.4f)) {
NoteDetailPane(selectedNote)
}
}
}
```
**See:** `references/desktop-navigation.md`
---
## 6. File System Integration
### File Dialogs
```kotlin
// File picker (load)
val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD)
fileDialog.isVisible = true
val filePath = fileDialog.file?.let { "${fileDialog.directory}$it" }
// File picker (save)
val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE)
saveDialog.isVisible = true
val savePath = saveDialog.file?.let { "${saveDialog.directory}$it" }
```
**Note:** Compose Desktop doesn't have native file picker composable yet. Use AWT `FileDialog`.
### Open External URLs
```kotlin
// jvmMain actual implementation
actual fun openExternalUrl(url: String) {
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().browse(URI(url))
}
}
```
**Pattern:** Define `expect` in `commonMain`, implement `actual` in `jvmMain`.
### Drag & Drop (Future)
```kotlin
// Compose Desktop drag-drop (experimental)
Box(
modifier = Modifier
.onExternalDrag(
onDragStart = { /* ... */ },
onDrag = { /* ... */ },
onDragExit = { /* ... */ },
onDrop = { state ->
val dragData = state.dragData
// Handle dropped files
}
)
) {
Text("Drop files here")
}
```
---
## 7. OS-Specific Conventions
### Platform Detection
```kotlin
val osName = System.getProperty("os.name").lowercase()
val isMacOS = osName.contains("mac")
val isWindows = osName.contains("win")
val isLinux = osName.contains("nux") || osName.contains("nix")
```
### Menu Bar Placement
| OS | Behavior |
|----|----------|
| **macOS** | System-wide menu bar at top of screen |
| **Windows** | In-window menu bar |
| **Linux** | Varies by desktop environment |
Compose Desktop `MenuBar` adapts automatically.
### Keyboard Modifier Keys
| Modifier | macOS | Windows/Linux |
|----------|-------|---------------|
| Primary | `meta = true` (Cmd) | `ctrl = true` |
| Secondary | `ctrl = true` | `alt = true` |
| Shift | `shift = true` | `shift = true` |
**Best practice:** Detect OS and use appropriate modifier.
### System Tray Behavior
| OS | Tray Location |
|----|---------------|
| **macOS** | Top-right menu bar |
| **Windows** | Bottom-right taskbar |
| **Linux** | Top panel (varies) |
---
## 8. Desktop UX Principles
### Keyboard-First Design
**Every action should have:**
1. Mouse/touch interaction
2. Keyboard shortcut (if frequent)
3. Tooltip showing shortcut
```kotlin
IconButton(
onClick = { /* refresh */ },
modifier = Modifier.tooltipArea(
tooltip = {
Text("Refresh (${if (isMacOS) "Cmd" else "Ctrl"}+R)")
}
)
) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
### Tooltip Best Practices
- Show keyboard shortcut in tooltip
- Use native modifier name (Cmd vs Ctrl)
- Brief description + shortcut
### Context Menus
Right-click should show context menu:
```kotlin
// Future: Compose Desktop context menu API
Box(
modifier = Modifier.contextMenuArea(
items = {
listOf(
ContextMenuItem("Copy") { /* ... */ },
ContextMenuItem("Paste") { /* ... */ }
)
}
)
) {
// Content
}
```
**Current:** Use popup or custom implementation.
### Window State Persistence
Save/restore window size/position:
```kotlin
// Save on close
windowState.size // DpSize
windowState.position // WindowPosition
// Restore on launch
val savedWidth = preferences.getInt("window.width", 1200)
val savedHeight = preferences.getInt("window.height", 800)
val windowState = rememberWindowState(
width = savedWidth.dp,
height = savedHeight.dp
)
```
---
## 9. Desktop Module Structure
```
desktopApp/
├── build.gradle.kts # Desktop-only build config
└── src/
└── jvmMain/
├── kotlin/
│ └── com/vitorpamplona/amethyst/desktop/
│ ├── Main.kt # Entry point, Window, MenuBar
│ ├── network/
│ │ ├── DesktopHttpClient.kt
│ │ └── DesktopRelayConnectionManager.kt
│ └── ui/
│ ├── FeedScreen.kt # Desktop screen layouts
│ └── LoginScreen.kt
└── resources/
├── icon.icns # macOS icon
├── icon.ico # Windows icon
└── icon.png # Linux icon
```
**Key files:**
- `Main.kt:87-138` - `application {}`, `Window`, `MenuBar`
- `Main.kt:183-264` - NavigationRail pattern
- `build.gradle.kts:45-73` - Desktop packaging config
---
## 10. Packaging & Distribution
### Build Configuration
```kotlin
// desktopApp/build.gradle.kts
compose.desktop {
application {
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "Amethyst"
packageVersion = "1.0.0"
macOS {
bundleID = "com.vitorpamplona.amethyst.desktop"
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
}
windows {
iconFile.set(project.file("src/jvmMain/resources/icon.ico"))
menuGroup = "Amethyst"
}
linux {
iconFile.set(project.file("src/jvmMain/resources/icon.png"))
}
}
}
}
```
**See:** desktopApp/build.gradle.kts:45-73
### Gradle Tasks
```bash
# Run desktop app
./gradlew :desktopApp:run
# Package for distribution
./gradlew :desktopApp:packageDmg # macOS
./gradlew :desktopApp:packageMsi # Windows
./gradlew :desktopApp:packageDeb # Linux
```
**Delegate packaging issues to gradle-expert.**
---
## Common Patterns
### Pattern: OS-Aware Shortcuts Helper
```kotlin
// commons/src/jvmMain/kotlin/shortcuts/ShortcutUtils.kt
object DesktopShortcuts {
private val isMacOS = System.getProperty("os.name")
.lowercase().contains("mac")
fun primary(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true)
} else {
KeyShortcut(key, ctrl = true)
}
fun primaryShift(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, shift = true)
} else {
KeyShortcut(key, ctrl = true, shift = true)
}
val modifierName = if (isMacOS) "Cmd" else "Ctrl"
}
// Usage in MenuBar
Item(
"New Note",
shortcut = DesktopShortcuts.primary(Key.N),
onClick = { /* ... */ }
)
```
### Pattern: Shared Composables, Platform Layouts
```kotlin
// commons/commonMain - Shared NoteCard
@Composable
fun NoteCard(note: NoteDisplayData) {
// Business logic, UI component (shared)
}
// desktopApp/jvmMain - Desktop layout
@Composable
fun FeedScreen() {
Column {
FeedHeader(/* ... */) // Shared from commons
LazyColumn {
items(notes) { note ->
NoteCard(note) // Shared composable
}
}
}
}
// amethyst/androidMain - Android layout
@Composable
fun FeedScreen() {
Scaffold(
bottomBar = { BottomNavigationBar() } // Android-specific
) {
LazyColumn {
items(notes) { note ->
NoteCard(note) // Same shared composable
}
}
}
}
```
**Philosophy:** Share UI components (cards, buttons), keep navigation/layout platform-specific.
---
## Resources
### Official Documentation
- [Desktop-only API | Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-components.html)
- [Top-level windows management](https://kotlinlang.org/docs/multiplatform/compose-desktop-top-level-windows-management.html)
- [Tray/MenuBar Tutorial](https://github.com/JetBrains/compose-multiplatform/blob/master/tutorials/Tray_Notifications_MenuBar_new/README.md)
### Bundled References
- `references/desktop-compose-apis.md` - Complete Desktop API catalog
- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns
- `references/keyboard-shortcuts.md` - Standard shortcuts by OS
- `references/os-detection.md` - Platform detection patterns
### Codebase Examples
- Main.kt:87-138 - Window, MenuBar entry point
- Main.kt:183-264 - NavigationRail pattern
- FeedScreen.kt:49-136 - Desktop screen layout
- LoginScreen.kt:44-97 - Centered desktop login
---
## Questions to Ask
When working on desktop features:
1. **Should this be shared or desktop-only?**
- Business logic → Share in `commonMain`
- Navigation/layout → Keep in `desktopApp/jvmMain`
2. **Does this need OS-specific behavior?**
- Keyboard shortcuts → Yes (Cmd vs Ctrl)
- File paths → Yes (separators)
- Icons → Yes (per-OS formats)
3. **Is there a desktop UX convention?**
- Check MenuBar standards
- Consider keyboard-first design
- Tooltips for all actions
4. **Does this need gradle-expert?**
- Any `build.gradle.kts` changes → Delegate
- Packaging/distribution issues → Delegate
---
## Anti-Patterns
**Hardcoding Ctrl everywhere**
```kotlin
// Main.kt:105 - Current issue
shortcut = KeyShortcut(Key.N, ctrl = true) // Wrong on macOS
```
**OS-aware shortcuts**
```kotlin
shortcut = DesktopShortcuts.primary(Key.N)
```
---
**Using Android navigation on Desktop**
```kotlin
Scaffold(bottomBar = { BottomNavigationBar() }) // Wrong for desktop
```
**NavigationRail for desktop**
```kotlin
Row {
NavigationRail { /* ... */ }
MainContent()
}
```
---
**No keyboard shortcuts**
```kotlin
IconButton(onClick = { refresh() }) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
**Shortcuts + tooltips**
```kotlin
IconButton(
onClick = { refresh() },
modifier = Modifier.tooltipArea("Refresh (Cmd+R)")
) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
---
## Next Steps
When implementing desktop features:
1. **Read** `references/desktop-compose-apis.md` for API catalog
2. **Check** `references/keyboard-shortcuts.md` for standard shortcuts
3. **Reference** Main.kt:87-264 for current patterns
4. **Test** on all 3 platforms (macOS, Windows, Linux) if possible
5. **Delegate** build issues to gradle-expert
6. **Share** UI components via compose-expert, not desktop-expert
---
**Version:** 1.0.0
**Last Updated:** 2025-12-30
**Codebase Reference:** AmethystMultiplatform commit 258c4e011

View File

@@ -0,0 +1,597 @@
# Desktop Compose APIs Catalog
Complete reference for Compose Multiplatform Desktop-only APIs.
## Window Management
### application
Root entry point for desktop apps.
```kotlin
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
Text("Hello Desktop")
}
}
```
### Window
Creates a window.
```kotlin
Window(
onCloseRequest: () -> Unit,
state: WindowState = rememberWindowState(),
visible: Boolean = true,
title: String = "Untitled",
icon: Painter? = null,
undecorated: Boolean = false,
transparent: Boolean = false,
resizable: Boolean = true,
enabled: Boolean = true,
focusable: Boolean = true,
alwaysOnTop: Boolean = false,
onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false },
onKeyEvent: ((KeyEvent) -> Boolean) = { false },
content: @Composable FrameWindowScope.() -> Unit
)
```
**Example:**
```kotlin
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "My App",
resizable = true
) {
// Content
}
```
### rememberWindowState
Manages window size and position.
```kotlin
@Composable
fun rememberWindowState(
placement: WindowPlacement = WindowPlacement.Floating,
isMinimized: Boolean = false,
position: WindowPosition = WindowPosition.PlatformDefault,
width: Dp = Dp.Unspecified,
height: Dp = Dp.Unspecified
): WindowState
```
**WindowPlacement:**
- `Floating` - Normal window
- `Maximized` - Fullscreen
- `Fullscreen` - Fullscreen without decorations
**WindowPosition:**
- `PlatformDefault` - OS decides
- `Aligned(alignment)` - Center, TopStart, etc.
- `Absolute(x, y)` - Fixed position in pixels
### DialogWindow
Modal dialog.
```kotlin
DialogWindow(
onCloseRequest: () -> Unit,
state: DialogState = rememberDialogState(),
visible: Boolean = true,
title: String = "Dialog",
icon: Painter? = null,
undecorated: Boolean = false,
transparent: Boolean = false,
resizable: Boolean = true,
enabled: Boolean = true,
focusable: Boolean = true,
content: @Composable DialogWindowScope.() -> Unit
)
```
**Example:**
```kotlin
var showDialog by remember { mutableStateOf(false) }
if (showDialog) {
DialogWindow(
onCloseRequest = { showDialog = false },
title = "Confirm"
) {
Column(Modifier.padding(16.dp)) {
Text("Are you sure?")
Row {
Button(onClick = { showDialog = false }) {
Text("Cancel")
}
Button(onClick = { /* confirm */ }) {
Text("OK")
}
}
}
}
}
```
---
## MenuBar
### MenuBar
Native menu bar for windows.
```kotlin
@Composable
fun FrameWindowScope.MenuBar(
content: @Composable MenuBarScope.() -> Unit
)
```
**Example:**
```kotlin
Window(onCloseRequest = ::exitApplication) {
MenuBar {
Menu("File") {
Item("New", onClick = { /* ... */ })
Item("Open", onClick = { /* ... */ })
Separator()
Item("Quit", onClick = ::exitApplication)
}
Menu("Edit") {
Item("Copy", onClick = { /* ... */ })
Item("Paste", onClick = { /* ... */ })
}
}
}
```
### Menu
Top-level menu.
```kotlin
@Composable
fun MenuBarScope.Menu(
text: String,
mnemonic: Char? = null,
enabled: Boolean = true,
content: @Composable MenuScope.() -> Unit
)
```
### Item
Menu item.
```kotlin
@Composable
fun MenuScope.Item(
text: String,
onClick: () -> Unit,
shortcut: KeyShortcut? = null,
mnemonic: Char? = null,
enabled: Boolean = true,
icon: Painter? = null
)
```
**With keyboard shortcut:**
```kotlin
Item(
text = "Save",
onClick = { save() },
shortcut = KeyShortcut(Key.S, ctrl = true),
icon = painterResource("save.png")
)
```
### Separator
Menu separator line.
```kotlin
@Composable
fun MenuScope.Separator()
```
### CheckboxItem
Toggleable menu item.
```kotlin
@Composable
fun MenuScope.CheckboxItem(
text: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
shortcut: KeyShortcut? = null,
mnemonic: Char? = null,
enabled: Boolean = true
)
```
**Example:**
```kotlin
var darkMode by remember { mutableStateOf(false) }
Menu("View") {
CheckboxItem(
text = "Dark Mode",
checked = darkMode,
onCheckedChange = { darkMode = it },
shortcut = KeyShortcut(Key.D, ctrl = true)
)
}
```
### RadioButtonItem
Radio button menu item.
```kotlin
@Composable
fun MenuScope.RadioButtonItem(
text: String,
selected: Boolean,
onClick: () -> Unit,
shortcut: KeyShortcut? = null,
mnemonic: Char? = null,
enabled: Boolean = true
)
```
---
## System Tray
### Tray
System tray icon with menu.
```kotlin
@Composable
fun ApplicationScope.Tray(
icon: Painter,
state: TrayState = rememberTrayState(),
tooltip: String? = null,
onAction: () -> Unit = {},
menu: @Composable MenuScope.() -> Unit = {}
)
```
**Example:**
```kotlin
application {
var isVisible by remember { mutableStateOf(true) }
Tray(
icon = painterResource("tray-icon.png"),
tooltip = "My App",
onAction = { isVisible = true },
menu = {
Item("Show Window", onClick = { isVisible = true })
Separator()
Item("Quit", onClick = ::exitApplication)
}
)
if (isVisible) {
Window(
onCloseRequest = { isVisible = false },
title = "App"
) {
// Content
}
}
}
```
### rememberTrayState
Manages tray state.
```kotlin
@Composable
fun rememberTrayState(): TrayState
```
---
## Notifications
### Notification (via Tray)
Show desktop notifications through tray.
```kotlin
val trayState = rememberTrayState()
Tray(
icon = painterResource("icon.png"),
state = trayState
)
// Send notification
LaunchedEffect(Unit) {
trayState.sendNotification(
Notification(
title = "Message",
message = "You have a new message",
type = Notification.Type.Info
)
)
}
```
**Notification types:**
- `Info` - Information
- `Warning` - Warning
- `Error` - Error
---
## Keyboard
### KeyShortcut
Keyboard shortcut definition.
```kotlin
data class KeyShortcut(
val key: Key,
val ctrl: Boolean = false,
val meta: Boolean = false,
val alt: Boolean = false,
val shift: Boolean = false
)
```
**Examples:**
```kotlin
// Ctrl+S (Windows/Linux)
KeyShortcut(Key.S, ctrl = true)
// Cmd+S (macOS)
KeyShortcut(Key.S, meta = true)
// Ctrl+Shift+N
KeyShortcut(Key.N, ctrl = true, shift = true)
// Alt+F4
KeyShortcut(Key.F4, alt = true)
```
### onPreviewKeyEvent / onKeyEvent
Window-level keyboard handlers.
```kotlin
Window(
onCloseRequest = ::exitApplication,
onPreviewKeyEvent = { event ->
if (event.key == Key.Escape && event.type == KeyEventType.KeyDown) {
// Handle Escape
true // Consume event
} else {
false // Propagate
}
}
) {
// Content
}
```
---
## Mouse
### PointerMoveFilter (Deprecated, use Modifier.pointerInput)
```kotlin
Box(
modifier = Modifier
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
// Handle mouse events
}
}
}
)
```
### Mouse cursor
```kotlin
Box(
modifier = Modifier.pointerHoverIcon(
icon = PointerIcon(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR))
)
) {
Text("Hover me")
}
```
**Cursor types:**
- `DEFAULT_CURSOR`
- `HAND_CURSOR`
- `TEXT_CURSOR`
- `CROSSHAIR_CURSOR`
- `WAIT_CURSOR`
- `MOVE_CURSOR`
- `E_RESIZE_CURSOR`, `W_RESIZE_CURSOR`, etc.
---
## Drag & Drop (Experimental)
### onExternalDrag
Handle drag-and-drop from external sources.
```kotlin
Box(
modifier = Modifier
.size(200.dp)
.background(Color.LightGray)
.onExternalDrag(
onDragStart = { externalDragValue ->
println("Drag started")
},
onDrag = { externalDragValue ->
println("Dragging: ${externalDragValue.dragData}")
},
onDragExit = {
println("Drag exited")
},
onDrop = { externalDragValue ->
val dragData = externalDragValue.dragData
when (dragData) {
is DragData.FilesList -> {
println("Files dropped: ${dragData.readFiles()}")
}
is DragData.Text -> {
println("Text dropped: ${dragData.readText()}")
}
else -> {}
}
}
)
) {
Text("Drop files here", Modifier.align(Alignment.Center))
}
```
---
## Resources
### painterResource
Load images from resources.
```kotlin
val icon = painterResource("icon.png")
Icon(
painter = icon,
contentDescription = "App icon"
)
```
**Resource location:** `src/jvmMain/resources/`
---
## Platform Integration
### Desktop.getDesktop() (AWT)
Access system desktop features (not Compose API, but commonly used).
```kotlin
import java.awt.Desktop
import java.net.URI
// Open URL in browser
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().browse(URI("https://example.com"))
}
// Open file with default app
Desktop.getDesktop().open(File("/path/to/file.pdf"))
// Open email client
Desktop.getDesktop().mail(URI("mailto:user@example.com"))
```
### FileDialog (AWT)
File picker dialogs.
```kotlin
import java.awt.FileDialog
import java.awt.Frame
// Open file
val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD)
fileDialog.isVisible = true
val selectedFile = fileDialog.file
val directory = fileDialog.directory
// Save file
val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE)
saveDialog.file = "document.txt"
saveDialog.isVisible = true
```
---
## SwingPanel (Interop)
Embed Swing components in Compose.
```kotlin
import androidx.compose.ui.awt.SwingPanel
import javax.swing.JButton
SwingPanel(
factory = {
JButton("Swing Button").apply {
addActionListener {
println("Swing button clicked")
}
}
},
modifier = Modifier.size(200.dp, 50.dp)
)
```
---
## ComposePanel (Reverse Interop)
Embed Compose in Swing.
```kotlin
import androidx.compose.ui.awt.ComposePanel
import javax.swing.JFrame
val frame = JFrame("Swing Frame")
val composePanel = ComposePanel()
composePanel.setContent {
Text("Compose in Swing")
}
frame.contentPane.add(composePanel)
frame.setSize(400, 300)
frame.isVisible = true
```
---
## Version Requirements
- **Kotlin:** 2.0+
- **Compose Multiplatform:** 1.7.0+
- **JVM Target:** 11+ (recommend 21)
**See also:**
- [Official Desktop API docs](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-components.html)
- [Compose Multiplatform repo](https://github.com/JetBrains/compose-multiplatform)

View File

@@ -0,0 +1,464 @@
# Desktop Navigation Patterns
Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform.
## Core Difference
| Platform | Pattern | Location | Rationale |
|----------|---------|----------|-----------|
| **Android** | Bottom Navigation Bar | Horizontal, bottom | Thumb reach on mobile |
| **Desktop** | Navigation Rail | Vertical, left sidebar | Horizontal screen space |
---
## Desktop: NavigationRail
### Current Implementation
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:191-264`
```kotlin
@Composable
fun MainContent(
currentScreen: AppScreen,
onScreenChange: (AppScreen) -> Unit,
// ...
) {
Row(Modifier.fillMaxSize()) {
// LEFT: Vertical Sidebar (NavigationRail)
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant
) {
Spacer(Modifier.height(16.dp))
// Top navigation items
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == AppScreen.Feed,
onClick = { onScreenChange(AppScreen.Feed) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Search, "Search") },
label = { Text("Search") },
selected = currentScreen == AppScreen.Search,
onClick = { onScreenChange(AppScreen.Search) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Email, "Messages") },
label = { Text("DMs") },
selected = currentScreen == AppScreen.Messages,
onClick = { onScreenChange(AppScreen.Messages) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Notifications, "Notifications") },
label = { Text("Alerts") },
selected = currentScreen == AppScreen.Notifications,
onClick = { onScreenChange(AppScreen.Notifications) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Person, "Profile") },
label = { Text("Profile") },
selected = currentScreen == AppScreen.Profile,
onClick = { onScreenChange(AppScreen.Profile) }
)
// Push Settings to bottom
Spacer(Modifier.weight(1f))
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, "Settings") },
label = { Text("Settings") },
selected = currentScreen == AppScreen.Settings,
onClick = { onScreenChange(AppScreen.Settings) }
)
Spacer(Modifier.height(16.dp))
}
VerticalDivider()
// RIGHT: Main Content Area
Box(modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp)) {
when (currentScreen) {
AppScreen.Feed -> FeedScreen(relayManager)
AppScreen.Search -> SearchPlaceholder()
AppScreen.Messages -> MessagesPlaceholder()
AppScreen.Notifications -> NotificationsPlaceholder()
AppScreen.Profile -> ProfileScreen(account, accountManager)
AppScreen.Settings -> RelaySettingsScreen(relayManager)
}
}
}
}
```
### Layout Structure
```
┌────────────────────────────────────────┐
│ [Menu Bar: File, Edit, View, Help] │ ← MenuBar (OS-native)
├──────┬─────────────────────────────────┤
│ │ │
│ [🏠] │ │
│ Feed │ │
│ │ │
│ [🔍] │ Main Content Area │
│Search│ (Feed, Messages, etc.) │
│ │ │
│ [✉️] │ │
│ DMs │ │
│ │ │
│ [🔔] │ │
│Alerts│ │
│ │ │
│ [👤] │ │
│Profile │
│ │ │
│ ─ │ │
│ [⚙️] │ │
│Settings │
│ │ │
└──────┴─────────────────────────────────┘
80dp Remaining width (weight=1f)
```
### Key Features
1. **Always visible:** All nav items visible at once
2. **Icon + Label:** Both shown (not just icons)
3. **Vertical list:** Natural reading order
4. **Settings at bottom:** Separated by divider + Spacer.weight(1f)
5. **80dp width:** Standard NavigationRail width
---
## Android: BottomNavigationBar (Future)
### Expected Implementation
**Location:** `amethyst/src/androidMain/kotlin/...` (not yet implemented)
```kotlin
@Composable
fun MainScreen(
currentScreen: AppScreen,
onScreenChange: (AppScreen) -> Unit
) {
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == AppScreen.Feed,
onClick = { onScreenChange(AppScreen.Feed) }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Search, "Search") },
label = { Text("Search") },
selected = currentScreen == AppScreen.Search,
onClick = { onScreenChange(AppScreen.Search) }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Email, "Messages") },
label = { Text("Messages") },
selected = currentScreen == AppScreen.Messages,
onClick = { onScreenChange(AppScreen.Messages) }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Person, "Profile") },
label = { Text("Profile") },
selected = currentScreen == AppScreen.Profile,
onClick = { onScreenChange(AppScreen.Profile) }
)
}
}
) { paddingValues ->
Box(Modifier.padding(paddingValues)) {
when (currentScreen) {
AppScreen.Feed -> FeedScreen()
AppScreen.Search -> SearchScreen()
AppScreen.Messages -> MessagesScreen()
AppScreen.Profile -> ProfileScreen()
// Settings accessed via Profile or overflow menu
}
}
}
}
```
### Layout Structure
```
┌─────────────────────────────────────┐
│ │
│ │
│ Main Content Area │
│ (Feed, Messages, etc.) │
│ │
│ │
│ │
├─────────────────────────────────────┤
│ [🏠] [🔍] [✉️] [👤] │ ← NavigationBar
│ Feed Search DMs Profile │
└─────────────────────────────────────┘
```
### Key Differences from Desktop
1. **Bottom placement:** Thumb reach
2. **Horizontal layout:** Limited vertical space
3. **Fewer items:** 3-5 primary destinations
4. **Label optional:** Can hide on small screens
5. **Settings hidden:** In profile or overflow
---
## Shared Navigation State
Both platforms use the same `AppScreen` enum from `commons`.
**File:** `commons/src/commonMain/kotlin/.../navigation/AppScreen.kt` (expected)
```kotlin
// Shared navigation destinations
enum class AppScreen {
Feed,
Search,
Messages,
Notifications,
Profile,
Settings
}
```
**State management (shared):**
```kotlin
// commons/src/jvmAndroid/kotlin/.../navigation/NavigationViewModel.kt
class NavigationViewModel : ViewModel() {
private val _currentScreen = MutableStateFlow(AppScreen.Feed)
val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
fun navigateTo(screen: AppScreen) {
_currentScreen.value = screen
}
}
```
---
## Multi-Pane Desktop Layout (Advanced)
Desktop can utilize horizontal space for multi-pane layouts.
### Two-Pane Layout
```kotlin
Row(Modifier.fillMaxSize()) {
// Left: NavigationRail (fixed 80dp)
NavigationRail { /* ... */ }
VerticalDivider()
// Center: Main content (60% width)
Box(Modifier.weight(0.6f)) {
FeedScreen()
}
// Right: Detail pane (40% width, conditional)
if (selectedNote != null) {
VerticalDivider()
Box(Modifier.weight(0.4f)) {
NoteDetailPane(selectedNote)
}
}
}
```
### Layout:
```
┌──────┬───────────────────┬─────────────┐
│ │ │ │
│ Nav │ Feed List │ Detail │
│ Rail │ (60%) │ Pane │
│ │ │ (40%) │
│ │ │ │
└──────┴───────────────────┴─────────────┘
80dp weight(0.6f) weight(0.4f)
```
**Use cases:**
- Email: List + message detail
- Notes: List + editor
- Settings: Categories + options
---
## Keyboard Navigation
Desktop should support keyboard navigation.
### Tab Navigation
```kotlin
NavigationRail(
modifier = Modifier.focusable()
) {
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == AppScreen.Feed,
onClick = { onScreenChange(AppScreen.Feed) },
modifier = Modifier.focusable()
)
// More items...
}
```
### Keyboard Shortcuts
```kotlin
Window(
onPreviewKeyEvent = { event ->
when {
event.key == Key.One && event.isCtrlPressed ->
onScreenChange(AppScreen.Feed).also { true }
event.key == Key.Two && event.isCtrlPressed ->
onScreenChange(AppScreen.Search).also { true }
event.key == Key.Three && event.isCtrlPressed ->
onScreenChange(AppScreen.Messages).also { true }
else -> false
}
}
) {
// Content
}
```
**Standard:**
- Ctrl+1: First nav item (Feed)
- Ctrl+2: Second nav item (Search)
- Ctrl+3: Third nav item (Messages)
- Ctrl+Comma: Settings
---
## Navigation Transitions
### Desktop (Instant)
No fancy animations. Instant switch.
```kotlin
Box {
when (currentScreen) {
AppScreen.Feed -> FeedScreen()
AppScreen.Search -> SearchScreen()
}
}
```
### Android (Animated, Future)
Can use Navigation Compose for transitions.
```kotlin
NavHost(navController, startDestination = "feed") {
composable("feed") { FeedScreen() }
composable("search") { SearchScreen() }
}
```
---
## Best Practices
### Desktop NavigationRail
**DO:**
- Keep width 72-80dp
- Show both icon and label
- Use Spacer.weight(1f) for bottom items
- Separate sections with HorizontalDivider
- Limit to 5-7 primary items
**DON'T:**
- Use bottom navigation on desktop
- Hide labels (plenty of space)
- Make it collapsible (not standard)
- Use hamburger menu (not desktop pattern)
### Android NavigationBar
**DO:**
- Limit to 3-5 items
- Use bottom placement
- Consider label visibility on small screens
- Use standard icons
**DON'T:**
- Put more than 5 items
- Use top placement (deprecated)
- Put critical actions only in nav bar
---
## Migration Strategy
When adding Android support:
1. **Extract shared state:** Move `AppScreen` to `commons/commonMain`
2. **Platform layouts:** Keep `NavigationRail` in `desktopApp/jvmMain`, `NavigationBar` in `amethyst/androidMain`
3. **Shared screens:** Composables in `commons/commonMain` (FeedScreen content)
4. **Platform chrome:** Navigation containers in platform modules
**Example:**
```kotlin
// commons/commonMain - Shared screen content
@Composable
fun FeedContent(notes: List<Note>) {
LazyColumn {
items(notes) { note ->
NoteCard(note)
}
}
}
// desktopApp/jvmMain - Desktop wrapper
@Composable
fun FeedScreen() {
Column {
FeedHeader() // Desktop-specific header
FeedContent(notes) // Shared content
}
}
// amethyst/androidMain - Android wrapper
@Composable
fun FeedScreen() {
Scaffold(
topBar = { TopAppBar { Text("Feed") } }
) {
FeedContent(notes) // Same shared content
}
}
```
---
## References
- **Current Desktop:** Main.kt:191-264
- **Material3 NavigationRail:** [Material Design Docs](https://m3.material.io/components/navigation-rail)
- **Material3 NavigationBar:** [Material Design Docs](https://m3.material.io/components/navigation-bar)

View File

@@ -0,0 +1,400 @@
# Keyboard Shortcuts Reference
Standard keyboard shortcuts for desktop applications across macOS, Windows, and Linux.
## Primary Modifier Keys
| Platform | Primary | Secondary | Tertiary |
|----------|---------|-----------|----------|
| **macOS** | Cmd (⌘) / `meta` | Option (⌥) / `alt` | Ctrl (⌃) / `ctrl` |
| **Windows** | Ctrl / `ctrl` | Alt / `alt` | Win / `meta` |
| **Linux** | Ctrl / `ctrl` | Alt / `alt` | Super / `meta` |
**In Compose Desktop:**
```kotlin
// macOS
KeyShortcut(Key.N, meta = true) // Cmd+N
// Windows/Linux
KeyShortcut(Key.N, ctrl = true) // Ctrl+N
```
---
## File Operations
| Action | macOS | Windows | Linux | Notes |
|--------|-------|---------|-------|-------|
| **New** | Cmd+N | Ctrl+N | Ctrl+N | Create new |
| **Open** | Cmd+O | Ctrl+O | Ctrl+O | Open file |
| **Save** | Cmd+S | Ctrl+S | Ctrl+S | Save current |
| **Save As** | Cmd+Shift+S | Ctrl+Shift+S | Ctrl+Shift+S | Save with new name |
| **Close** | Cmd+W | Ctrl+W | Ctrl+W | Close window/tab |
| **Quit** | Cmd+Q | Ctrl+Q | Ctrl+Q | Exit app |
| **Print** | Cmd+P | Ctrl+P | Ctrl+P | Print |
**Compose Implementation:**
```kotlin
val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
MenuBar {
Menu("File") {
Item(
"New Note",
shortcut = if (isMacOS) {
KeyShortcut(Key.N, meta = true)
} else {
KeyShortcut(Key.N, ctrl = true)
},
onClick = { createNewNote() }
)
Item(
"Save",
shortcut = if (isMacOS) {
KeyShortcut(Key.S, meta = true)
} else {
KeyShortcut(Key.S, ctrl = true)
},
onClick = { save() }
)
Separator()
Item(
"Quit",
shortcut = if (isMacOS) {
KeyShortcut(Key.Q, meta = true)
} else {
KeyShortcut(Key.Q, ctrl = true)
},
onClick = ::exitApplication
)
}
}
```
---
## Edit Operations
| Action | macOS | Windows | Linux | Notes |
|--------|-------|---------|-------|-------|
| **Undo** | Cmd+Z | Ctrl+Z | Ctrl+Z | Universal |
| **Redo** | Cmd+Shift+Z | Ctrl+Y | Ctrl+Y | Windows/Linux use Y |
| **Cut** | Cmd+X | Ctrl+X | Ctrl+X | Universal |
| **Copy** | Cmd+C | Ctrl+C | Ctrl+C | Universal |
| **Paste** | Cmd+V | Ctrl+V | Ctrl+V | Universal |
| **Select All** | Cmd+A | Ctrl+A | Ctrl+A | Universal |
| **Find** | Cmd+F | Ctrl+F | Ctrl+F | Search |
| **Find Next** | Cmd+G | F3 | F3 | Next result |
| **Replace** | Cmd+Option+F | Ctrl+H | Ctrl+H | Find & replace |
**Note:** Undo/Redo typically handled by text fields automatically.
---
## Navigation
| Action | macOS | Windows | Linux | Notes |
|--------|-------|---------|-------|-------|
| **Tab 1** | Cmd+1 | Ctrl+1 | Ctrl+1 | First tab/view |
| **Tab 2** | Cmd+2 | Ctrl+2 | Ctrl+2 | Second tab/view |
| **Tab 3** | Cmd+3 | Ctrl+3 | Ctrl+3 | Third tab/view |
| **Next Tab** | Cmd+Option+→ | Ctrl+Tab | Ctrl+Tab | Cycle forward |
| **Prev Tab** | Cmd+Option+← | Ctrl+Shift+Tab | Ctrl+Shift+Tab | Cycle back |
| **Go Back** | Cmd+[ | Alt+← | Alt+← | Browser-style |
| **Go Forward** | Cmd+] | Alt+→ | Alt+→ | Browser-style |
**Compose Implementation:**
```kotlin
Window(
onPreviewKeyEvent = { event ->
if (event.type == KeyEventType.KeyDown) {
when {
event.key == Key.One && event.isPrimaryPressed() -> {
navigateTo(AppScreen.Feed)
true
}
event.key == Key.Two && event.isPrimaryPressed() -> {
navigateTo(AppScreen.Search)
true
}
event.key == Key.Three && event.isPrimaryPressed() -> {
navigateTo(AppScreen.Messages)
true
}
else -> false
}
} else false
}
) {
// Content
}
// Helper extension
fun KeyEvent.isPrimaryPressed() = if (isMacOS) isMetaPressed else isCtrlPressed
```
---
## Window Management
| Action | macOS | Windows | Linux | Notes |
|--------|-------|---------|-------|-------|
| **New Window** | Cmd+N | Ctrl+N | Ctrl+N | New instance |
| **Close Window** | Cmd+W | Alt+F4 | Alt+F4 | Close current |
| **Minimize** | Cmd+M | Win+Down | Super+Down | Minimize to dock/taskbar |
| **Maximize** | Cmd+Ctrl+F | Win+Up | Super+Up | Fullscreen/maximize |
| **Hide App** | Cmd+H | - | - | macOS only |
| **Switch Window** | Cmd+` | Alt+Tab | Alt+Tab | Between app windows |
**Note:** Window management often handled by OS, not app shortcuts.
---
## App-Specific (Amethyst)
### Nostr Actions
| Action | macOS | Windows | Linux | Description |
|--------|-------|---------|-------|-------------|
| **New Note** | Cmd+N | Ctrl+N | Ctrl+N | Compose new post |
| **Refresh Feed** | Cmd+R | Ctrl+R | Ctrl+R | Reload timeline |
| **Search** | Cmd+K | Ctrl+K | Ctrl+K | Quick search |
| **DMs** | Cmd+Shift+M | Ctrl+Shift+M | Ctrl+Shift+M | Open messages |
| **Settings** | Cmd+, | Ctrl+, | Ctrl+, | Open preferences |
| **Notifications** | Cmd+Shift+N | Ctrl+Shift+N | Ctrl+Shift+N | View alerts |
**Implementation:**
```kotlin
MenuBar {
Menu("File") {
Item(
"New Note",
shortcut = DesktopShortcuts.primary(Key.N),
onClick = { showComposeDialog() }
)
Item(
"Settings",
shortcut = DesktopShortcuts.primary(Key.Comma),
onClick = { navigateTo(AppScreen.Settings) }
)
}
Menu("View") {
Item(
"Refresh Feed",
shortcut = DesktopShortcuts.primary(Key.R),
onClick = { refreshFeed() }
)
Item(
"Search",
shortcut = DesktopShortcuts.primary(Key.K),
onClick = { focusSearch() }
)
}
}
```
---
## Accessibility
| Action | macOS | Windows | Linux | Description |
|--------|-------|---------|-------|-------------|
| **Zoom In** | Cmd++ | Ctrl++ | Ctrl++ | Increase size |
| **Zoom Out** | Cmd+- | Ctrl+- | Ctrl+- | Decrease size |
| **Reset Zoom** | Cmd+0 | Ctrl+0 | Ctrl+0 | Default size |
| **Help** | Cmd+? | F1 | F1 | Show help |
---
## Best Practices
### 1. OS-Aware Helper
Create a utility for OS detection:
```kotlin
// commons/src/jvmMain/kotlin/utils/PlatformShortcuts.kt
object DesktopShortcuts {
private val isMacOS = System.getProperty("os.name")
.lowercase()
.contains("mac")
fun primary(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true)
} else {
KeyShortcut(key, ctrl = true)
}
fun primaryShift(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, shift = true)
} else {
KeyShortcut(key, ctrl = true, shift = true)
}
fun primaryAlt(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, alt = true)
} else {
KeyShortcut(key, ctrl = true, alt = true)
}
val modifierName = if (isMacOS) "Cmd" else "Ctrl"
val secondaryName = if (isMacOS) "Option" else "Alt"
}
```
**Usage:**
```kotlin
Item(
"Save",
shortcut = DesktopShortcuts.primary(Key.S),
onClick = { save() }
)
```
### 2. Show Shortcuts in Tooltips
```kotlin
IconButton(
onClick = { refresh() },
modifier = Modifier.tooltipArea {
Text("Refresh (${DesktopShortcuts.modifierName}+R)")
}
) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
### 3. Shortcuts Menu
Provide a "Keyboard Shortcuts" help menu:
```kotlin
Menu("Help") {
Item("Keyboard Shortcuts", onClick = { showShortcutsDialog() })
}
// Dialog content
@Composable
fun ShortcutsDialog() {
Dialog(onDismissRequest = { /* close */ }) {
Surface {
Column(Modifier.padding(16.dp)) {
Text("Keyboard Shortcuts", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(16.dp))
ShortcutRow("New Note", "${DesktopShortcuts.modifierName}+N")
ShortcutRow("Save", "${DesktopShortcuts.modifierName}+S")
ShortcutRow("Search", "${DesktopShortcuts.modifierName}+K")
ShortcutRow("Settings", "${DesktopShortcuts.modifierName}+,")
// ...
}
}
}
}
@Composable
fun ShortcutRow(action: String, shortcut: String) {
Row(
Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(action, style = MaterialTheme.typography.bodyMedium)
Text(
shortcut,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
```
### 4. Avoid Conflicts
**Check for OS-level shortcuts:**
| macOS Reserved | Description |
|----------------|-------------|
| Cmd+Tab | Switch apps |
| Cmd+Space | Spotlight |
| Cmd+H | Hide window |
| Cmd+M | Minimize |
| Cmd+Q | Quit |
| Cmd+W | Close window |
**Windows Reserved:**
| Windows Reserved | Description |
|-----------------|-------------|
| Win+D | Show desktop |
| Win+E | File Explorer |
| Win+L | Lock screen |
| Alt+Tab | Switch apps |
| Alt+F4 | Close window |
**Don't override these unless critical.**
---
## Testing Shortcuts
```kotlin
// Test OS detection
@Test
fun testOsDetection() {
val osName = System.getProperty("os.name")
println("OS: $osName")
val isMacOS = osName.lowercase().contains("mac")
println("Is macOS: $isMacOS")
val shortcut = if (isMacOS) {
KeyShortcut(Key.N, meta = true)
} else {
KeyShortcut(Key.N, ctrl = true)
}
println("Primary modifier for New: $shortcut")
}
```
---
## Current Issues in Amethyst
**Main.kt:105-123** hardcodes `ctrl = true`:
```kotlin
// ❌ WRONG: Hardcoded Ctrl (doesn't work on macOS)
Item(
"New Note",
shortcut = KeyShortcut(Key.N, ctrl = true), // Should be Cmd on macOS
onClick = { /* ... */ }
)
```
**Fix:**
```kotlin
// ✅ CORRECT: OS-aware
Item(
"New Note",
shortcut = DesktopShortcuts.primary(Key.N),
onClick = { /* ... */ }
)
```
---
## References
- [macOS Keyboard Shortcuts](https://support.apple.com/en-us/102650)
- [Windows Keyboard Shortcuts](https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec)
- [GNOME Keyboard Shortcuts](https://help.gnome.org/users/gnome-help/stable/shell-keyboard-shortcuts.html)
- [Material Design: Keyboard Shortcuts](https://m3.material.io/foundations/interaction/keyboard)
- [Compose Desktop: Keyboard Events](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-keyboard.html)

View File

@@ -0,0 +1,579 @@
# OS Detection & Platform-Specific Code
Patterns for detecting operating system and implementing platform-specific behavior in Compose Desktop.
## OS Detection
### Basic Detection
```kotlin
val osName = System.getProperty("os.name").lowercase()
val isMacOS = osName.contains("mac")
val isWindows = osName.contains("win")
val isLinux = osName.contains("nux") || osName.contains("nix")
```
### System Properties
```kotlin
// OS name
System.getProperty("os.name")
// Examples: "Mac OS X", "Windows 10", "Linux"
// OS version
System.getProperty("os.version")
// Examples: "14.2.1", "10.0", "6.5.0-14-generic"
// OS architecture
System.getProperty("os.arch")
// Examples: "aarch64", "x86_64", "amd64"
// User home directory
System.getProperty("user.home")
// Examples: "/Users/username", "C:\Users\username", "/home/username"
// File separator
System.getProperty("file.separator")
// Examples: "/" (Unix), "\" (Windows)
// Path separator
System.getProperty("path.separator")
// Examples: ":" (Unix), ";" (Windows)
```
---
## PlatformDetector Utility
Create a centralized utility for platform detection.
**File:** `commons/src/jvmMain/kotlin/utils/PlatformDetector.kt`
```kotlin
package com.vitorpamplona.amethyst.commons.utils
object PlatformDetector {
private val osName = System.getProperty("os.name").lowercase()
val isMacOS: Boolean = osName.contains("mac")
val isWindows: Boolean = osName.contains("win")
val isLinux: Boolean = osName.contains("nux") || osName.contains("nix")
val platform: Platform = when {
isMacOS -> Platform.MacOS
isWindows -> Platform.Windows
isLinux -> Platform.Linux
else -> Platform.Unknown
}
enum class Platform {
MacOS,
Windows,
Linux,
Unknown
}
// File paths
val fileSeparator: String = System.getProperty("file.separator")
val pathSeparator: String = System.getProperty("path.separator")
// User directories
val userHome: String = System.getProperty("user.home")
val appDataDir: String = when (platform) {
Platform.MacOS -> "$userHome/Library/Application Support"
Platform.Windows -> System.getenv("APPDATA") ?: "$userHome\\AppData\\Roaming"
Platform.Linux -> System.getenv("XDG_CONFIG_HOME") ?: "$userHome/.config"
Platform.Unknown -> userHome
}
// Modifier key names
val primaryModifierName: String = if (isMacOS) "Cmd" else "Ctrl"
val secondaryModifierName: String = if (isMacOS) "Option" else "Alt"
fun platformSpecific(
macOS: () -> Unit = {},
windows: () -> Unit = {},
linux: () -> Unit = {},
fallback: () -> Unit = {}
) {
when (platform) {
Platform.MacOS -> macOS()
Platform.Windows -> windows()
Platform.Linux -> linux()
Platform.Unknown -> fallback()
}
}
}
```
**Usage:**
```kotlin
// Simple check
if (PlatformDetector.isMacOS) {
// macOS-specific code
}
// Pattern matching
when (PlatformDetector.platform) {
Platform.MacOS -> setupMacDock()
Platform.Windows -> setupWindowsTray()
Platform.Linux -> setupLinuxTray()
Platform.Unknown -> showWarning()
}
// Platform-specific execution
PlatformDetector.platformSpecific(
macOS = { setupMacMenuBar() },
windows = { setupWindowsMenu() },
linux = { setupLinuxMenu() }
)
// File paths
val configPath = "${PlatformDetector.appDataDir}${PlatformDetector.fileSeparator}amethyst"
```
---
## Platform-Specific UI
### Keyboard Shortcuts Helper
```kotlin
package com.vitorpamplona.amethyst.commons.utils
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyShortcut
object DesktopShortcuts {
private val isMacOS = PlatformDetector.isMacOS
fun primary(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true)
} else {
KeyShortcut(key, ctrl = true)
}
fun primaryShift(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, shift = true)
} else {
KeyShortcut(key, ctrl = true, shift = true)
}
fun primaryAlt(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, alt = true)
} else {
KeyShortcut(key, ctrl = true, alt = true)
}
val modifierName = PlatformDetector.primaryModifierName
val secondaryName = PlatformDetector.secondaryModifierName
fun formatShortcut(key: String, withPrimary: Boolean = true): String {
return if (withPrimary) "$modifierName+$key" else key
}
}
```
### File Paths Helper
```kotlin
package com.vitorpamplona.amethyst.commons.utils
import java.io.File
object FilePaths {
private val separator = PlatformDetector.fileSeparator
fun join(vararg parts: String): String {
return parts.joinToString(separator)
}
fun appConfig(appName: String): String {
return join(PlatformDetector.appDataDir, appName)
}
fun appCache(appName: String): String {
return when (PlatformDetector.platform) {
PlatformDetector.Platform.MacOS ->
join(PlatformDetector.userHome, "Library", "Caches", appName)
PlatformDetector.Platform.Windows ->
join(System.getenv("LOCALAPPDATA") ?: "${PlatformDetector.userHome}\\AppData\\Local", appName)
PlatformDetector.Platform.Linux ->
join(System.getenv("XDG_CACHE_HOME") ?: "${PlatformDetector.userHome}/.cache", appName)
else -> join(PlatformDetector.userHome, ".cache", appName)
}
}
fun ensureDirectory(path: String): File {
return File(path).apply {
if (!exists()) {
mkdirs()
}
}
}
}
// Usage
val configDir = FilePaths.ensureDirectory(FilePaths.appConfig("amethyst"))
val cacheDir = FilePaths.ensureDirectory(FilePaths.appCache("amethyst"))
```
---
## Platform-Specific Features
### Open External URL
```kotlin
// commons/src/commonMain/kotlin/utils/ExternalUrl.kt
expect fun openExternalUrl(url: String)
// commons/src/jvmMain/kotlin/utils/ExternalUrl.jvm.kt
import java.awt.Desktop
import java.net.URI
actual fun openExternalUrl(url: String) {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(URI(url))
}
}
```
### File Picker
```kotlin
// Platform-specific file picker
fun showFilePicker(
title: String = "Select file",
mode: FilePickerMode = FilePickerMode.Load
): String? {
val fileDialog = java.awt.FileDialog(
java.awt.Frame(),
title,
when (mode) {
FilePickerMode.Load -> java.awt.FileDialog.LOAD
FilePickerMode.Save -> java.awt.FileDialog.SAVE
}
)
// macOS-specific: Enable file selection features
if (PlatformDetector.isMacOS) {
System.setProperty("apple.awt.fileDialogForDirectories", "false")
}
fileDialog.isVisible = true
return fileDialog.file?.let { "${fileDialog.directory}$it" }
}
enum class FilePickerMode {
Load,
Save
}
```
### Directory Picker (macOS)
```kotlin
fun showDirectoryPicker(title: String = "Select directory"): String? {
if (PlatformDetector.isMacOS) {
// macOS-specific directory picker
System.setProperty("apple.awt.fileDialogForDirectories", "true")
}
val fileDialog = java.awt.FileDialog(java.awt.Frame(), title, java.awt.FileDialog.LOAD)
fileDialog.isVisible = true
if (PlatformDetector.isMacOS) {
System.setProperty("apple.awt.fileDialogForDirectories", "false")
}
return fileDialog.directory
}
```
---
## Window Decorations
### macOS-Specific
```kotlin
// Unified title bar (macOS Big Sur+)
if (PlatformDetector.isMacOS) {
Window(
undecorated = false,
transparent = true,
// ...
) {
// Custom title bar
}
}
```
### Windows-Specific
```kotlin
// Custom window chrome (Windows)
if (PlatformDetector.isWindows) {
Window(
undecorated = true,
// Custom decorations
) {
Column {
// Custom title bar with min/max/close buttons
WindowTitleBar()
// Content
}
}
}
```
---
## System Tray Icons
Different icon formats per OS:
```kotlin
fun getTrayIcon(): Painter {
return when (PlatformDetector.platform) {
Platform.MacOS -> painterResource("tray-icon-mac.png") // Template icon
Platform.Windows -> painterResource("tray-icon-win.ico")
Platform.Linux -> painterResource("tray-icon-linux.png")
else -> painterResource("tray-icon.png")
}
}
// macOS: Template icons (black/transparent)
// Windows: ICO format, 16x16
// Linux: PNG, typically 24x24
```
---
## Native Notifications
```kotlin
// Platform-specific notification implementation
fun sendNotification(title: String, message: String) {
PlatformDetector.platformSpecific(
macOS = {
// macOS: Use NSUserNotification (via tray)
trayState.sendNotification(
Notification(title, message, Notification.Type.Info)
)
},
windows = {
// Windows: Use Windows toast notifications
trayState.sendNotification(
Notification(title, message, Notification.Type.Info)
)
},
linux = {
// Linux: Use libnotify (via tray)
trayState.sendNotification(
Notification(title, message, Notification.Type.Info)
)
}
)
}
```
---
## Architecture Detection
```kotlin
object ArchDetector {
private val arch = System.getProperty("os.arch").lowercase()
val isArm: Boolean = arch.contains("aarch") || arch.contains("arm")
val isX64: Boolean = arch.contains("x86_64") || arch.contains("amd64")
val isX86: Boolean = arch.contains("x86") && !isX64
val architecture: Architecture = when {
isArm -> Architecture.ARM
isX64 -> Architecture.X64
isX86 -> Architecture.X86
else -> Architecture.Unknown
}
enum class Architecture {
ARM,
X64,
X86,
Unknown
}
}
// Usage: Load correct native library
fun loadNativeLib() {
val libName = when {
PlatformDetector.isMacOS && ArchDetector.isArm -> "libsecp256k1-macos-arm64"
PlatformDetector.isMacOS && ArchDetector.isX64 -> "libsecp256k1-macos-x64"
PlatformDetector.isWindows && ArchDetector.isX64 -> "libsecp256k1-win-x64"
PlatformDetector.isLinux && ArchDetector.isX64 -> "libsecp256k1-linux-x64"
else -> throw UnsupportedOperationException("Unsupported platform")
}
System.loadLibrary(libName)
}
```
---
## Testing Platform Detection
```kotlin
@Test
fun testPlatformDetection() {
println("OS: ${System.getProperty("os.name")}")
println("Version: ${System.getProperty("os.version")}")
println("Arch: ${System.getProperty("os.arch")}")
println()
println("Is macOS: ${PlatformDetector.isMacOS}")
println("Is Windows: ${PlatformDetector.isWindows}")
println("Is Linux: ${PlatformDetector.isLinux}")
println("Platform: ${PlatformDetector.platform}")
println()
println("User home: ${PlatformDetector.userHome}")
println("App data: ${PlatformDetector.appDataDir}")
println("File separator: ${PlatformDetector.fileSeparator}")
}
// Example output (macOS):
// OS: Mac OS X
// Version: 14.2.1
// Arch: aarch64
//
// Is macOS: true
// Is Windows: false
// Is Linux: false
// Platform: MacOS
//
// User home: /Users/username
// App data: /Users/username/Library/Application Support
// File separator: /
```
---
## Best Practices
### 1. Centralize Detection
**DO:** Use PlatformDetector singleton
```kotlin
if (PlatformDetector.isMacOS) { /* ... */ }
```
**DON'T:** Repeat detection everywhere
```kotlin
if (System.getProperty("os.name").lowercase().contains("mac")) { /* ... */ }
```
### 2. Use expect/actual for Platform APIs
```kotlin
// commonMain
expect fun openFile(path: String)
// jvmMain (Desktop)
actual fun openFile(path: String) {
Desktop.getDesktop().open(File(path))
}
// androidMain
actual fun openFile(path: String) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(path)))
}
```
### 3. Graceful Degradation
```kotlin
fun openBrowser(url: String) {
try {
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().browse(URI(url))
} else {
// Fallback: Copy to clipboard
Toolkit.getDefaultToolkit().systemClipboard.setContents(
StringSelection(url),
null
)
showMessage("URL copied to clipboard: $url")
}
} catch (e: Exception) {
showError("Failed to open browser: ${e.message}")
}
}
```
### 4. Test on All Platforms
Always test platform-specific code on:
- macOS (Intel + Apple Silicon if possible)
- Windows (10/11)
- Linux (Ubuntu/Fedora)
---
## Common Patterns
### Pattern: Config File Location
```kotlin
fun getConfigFile(filename: String): File {
val configDir = when (PlatformDetector.platform) {
Platform.MacOS ->
File("${PlatformDetector.userHome}/Library/Application Support/Amethyst")
Platform.Windows ->
File("${System.getenv("APPDATA")}\\Amethyst")
Platform.Linux ->
File("${PlatformDetector.userHome}/.config/amethyst")
else ->
File("${PlatformDetector.userHome}/.amethyst")
}
if (!configDir.exists()) {
configDir.mkdirs()
}
return File(configDir, filename)
}
// Usage
val settingsFile = getConfigFile("settings.json")
```
### Pattern: Platform-Specific Resources
```kotlin
fun getPlatformIcon(name: String): Painter {
val extension = when (PlatformDetector.platform) {
Platform.MacOS -> "icns"
Platform.Windows -> "ico"
else -> "png"
}
return painterResource("$name.$extension")
}
// Resources:
// src/jvmMain/resources/app-icon.icns (macOS)
// src/jvmMain/resources/app-icon.ico (Windows)
// src/jvmMain/resources/app-icon.png (Linux)
```
---
## References
- [System Properties (Java)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/System.html#getProperties())
- [Desktop API (Java)](https://docs.oracle.com/en/java/javase/21/docs/api/java.desktop/java/awt/Desktop.html)
- [File System Standards (XDG)](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html)

View File

@@ -0,0 +1,77 @@
---
name: find-missing-translations
description: Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language
---
# Find Missing Translations
## 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.
## When to Use
- Need to find untranslated strings for a specific locale
- Preparing a batch of strings for a translator
- Checking translation coverage after adding new features
## Technique
### 1. Identify files
```
Default: amethyst/src/main/res/values/strings.xml
Target: amethyst/src/main/res/values-<locale>/strings.xml
```
Default locale: `cs-rCZ` if none specified. User may override (e.g., `pt-rBR`, `ja`).
### 2. Extract and diff keys
Use a single bash pipeline to extract translatable keys from both files and diff them:
```bash
# Extract translatable keys from default (exclude translatable="false")
comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-<LOCALE>/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
```
This gives the list of missing key names.
### 3. Get English values for missing keys
For each missing key, extract its English value:
```bash
# For each missing key, extract the full line from default strings.xml
while IFS= read -r key; do
grep "name=\"$key\"" amethyst/src/main/res/values/strings.xml
done < <(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-<LOCALE>/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
```
### 4. Present results
Output the missing entries as raw XML resource lines (copy-paste ready for the locale file):
```xml
<string name="attestation_valid">Valid</string>
<string name="attestation_valid_from">Valid from %1$s</string>
<string name="feed_group_lists">Lists</string>
```
Also check `<string-array>` and `<plurals>` tags using the same approach if the project uses them.
## Common Mistakes
- **Forgetting `translatable="false"`** — these should never appear in locale files
- **Not checking string-arrays/plurals** — only checking `<string>` misses other resource types
- **Modifying files** — this is a read-only research task unless the user asks to add entries

View File

@@ -0,0 +1,548 @@
---
name: gradle-expert
description: Build optimization, dependency resolution, and multi-module KMP troubleshooting for AmethystMultiplatform. Use when working with: (1) Gradle build files (build.gradle.kts, settings.gradle), (2) Version catalog (libs.versions.toml), (3) Build errors and dependency conflicts, (4) Module dependencies and source sets, (5) Desktop packaging (DMG/MSI/DEB), (6) Build performance optimization, (7) Proguard/R8 configuration, (8) Common KMP + Android Gradle issues (Compose conflicts, secp256k1 JNI variants, source set problems).
---
# Gradle Expert
Build system expertise for AmethystMultiplatform's 4-module KMP architecture. Focus: practical troubleshooting, dependency resolution, and project-specific optimizations.
## Build Architecture Mental Model
Think of this project as **4 layers**:
```
┌─────────────┬─────────────┐
│ :amethyst │ :desktopApp │ ← Platform apps (navigation, layouts)
│ (Android) │ (JVM) │
└──────┬──────┴──────┬──────┘
│ │
└──────┬──────┘
┌─────────────┐
│ :commons │ ← Shared UI (KMP with jvmAndroid)
│ (KMP UI) │
└──────┬──────┘
┌─────────────┐
│ :quartz │ ← Core library (KMP: Android/JVM/iOS)
│(KMP Library)│
└─────────────┘
```
**Key insight:** Dependencies flow DOWN. Lower modules never depend on upper modules. This enables code sharing without circular dependencies.
**The jvmAndroid pattern:** Unique to this project. A custom source set between commonMain and {androidMain, jvmMain} for JVM-specific code shared by Android and Desktop. Not standard KMP, but critical for this architecture.
## Version Catalog Philosophy
All dependencies centralized in `gradle/libs.versions.toml`. Think "single source of truth."
**Pattern:**
```toml
[versions]
kotlin = "2.3.0"
[libraries]
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
```
**Usage:**
```kotlin
dependencies {
implementation(libs.okhttp) // Type-safe, IDE-autocompleted
}
```
**Critical alignments:**
- **Kotlin ecosystem:** All Kotlin plugins MUST share same version
- **Compose ecosystem:** Compose Multiplatform version → Kotlin version (check compatibility matrix)
- **secp256k1 variants:** All three variants (common, jni-android, jni-jvm) MUST share same version
See [references/version-catalog-guide.md](references/version-catalog-guide.md) for comprehensive patterns.
## Common Build Tasks
### Quick Reference
```bash
# Full builds
./gradlew build # All modules
./gradlew clean build # Clean build
# Desktop
./gradlew :desktopApp:run # Run desktop app
./gradlew :desktopApp:packageDmg # macOS package
# Module-specific
./gradlew :quartz:build # KMP library only
./gradlew :commons:build # Shared UI only
# Analysis
./gradlew dependencies # Dependency tree
./gradlew build --scan # Online diagnostics
```
See [references/build-commands.md](references/build-commands.md) for comprehensive command reference.
## Module Structure & Dependencies
### Dependency Flow
**Desktop build chain:**
```
:desktopApp → :commons (jvmMain) → :quartz (jvmMain → jvmAndroid → commonMain)
```
**Android build chain:**
```
:amethyst → :commons (androidMain) → :quartz (androidMain → jvmAndroid → commonMain)
```
**Key source set pattern (quartz & commons):**
```
commonMain # Truly cross-platform code
├─ jvmAndroid # JVM-specific, shared by Android + Desktop
│ ├─ androidMain
│ └─ jvmMain
└─ iosMain # iOS-specific (quartz only)
```
**Dependency config types:**
- Use `api` when types appear in module's public API or expect/actual declarations
- Use `implementation` for internal implementation details
- Example: quartz exposes secp256k1 (`api`), but hides okhttp (`implementation`)
See [references/dependency-graph.md](references/dependency-graph.md) for module visualization and transitive dependency flow.
## Critical Dependency Patterns
### 1. secp256k1 (Crypto Library)
**The problem:** KMP library with platform-specific JNI bindings. Wrong variant = runtime crash.
**Pattern:**
```kotlin
// commonMain - API only
api(libs.secp256k1.kmp.common)
// androidMain - Android JNI
api(libs.secp256k1.kmp.jni.android)
// jvmMain - Desktop JVM JNI
implementation(libs.secp256k1.kmp.jni.jvm)
```
**Why api in androidMain?** Types leak to consumers (:amethyst).
**Common error:** Desktop using jni-android variant → `UnsatisfiedLinkError: no secp256k1jni in java.library.path`
**Fix:** Check source set dependencies. jvmMain must use jni-jvm, never jni-android.
### 2. JNA (for LibSodium Encryption)
**The problem:** Android needs AAR packaging, JVM needs JAR. Same library, different artifact types.
**Pattern:**
```kotlin
// androidMain
implementation("com.goterl:lazysodium-android:5.2.0@aar") // @aar explicit
implementation("net.java.dev.jna:jna:5.18.1@aar")
// jvmMain
implementation(libs.lazysodium.java) // JAR implicit
implementation(libs.jna)
```
**Critical:** Never put JNA in jvmAndroid or commonMain. Platform-specific packaging only.
### 3. Compose Versions
**The problem:** Two Compose ecosystems (Multiplatform + AndroidX) must align, or duplicate classes.
**Current project config:**
```toml
composeMultiplatform = "1.9.3" # Plugin + runtime
composeBom = "2025.12.01" # AndroidX Compose BOM
kotlin = "2.3.0"
```
**Rule:** Compose Multiplatform version must be compatible with Kotlin version. Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
**In KMP modules (quartz, commons):**
```kotlin
// ✅ Use Compose Multiplatform
implementation(compose.ui)
implementation(compose.material3)
// ❌ DON'T use AndroidX BOM in KMP modules
// implementation(libs.androidx.compose.bom)
```
**In Android-only modules (amethyst):**
```kotlin
// Can use AndroidX BOM
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
```
## Desktop Packaging Basics
**TargetFormat options:**
```kotlin
// In desktopApp/build.gradle.kts
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "Amethyst"
packageVersion = "1.0.0"
macOS {
bundleID = "com.vitorpamplona.amethyst.desktop"
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
}
}
```
**Package tasks:**
```bash
./gradlew :desktopApp:packageDmg # macOS
./gradlew :desktopApp:packageMsi # Windows
./gradlew :desktopApp:packageDeb # Linux
```
**Output locations:**
- macOS: `desktopApp/build/compose/binaries/main/dmg/`
- Windows: `desktopApp/build/compose/binaries/main/msi/`
- Linux: `desktopApp/build/compose/binaries/main/deb/`
**Icon requirements:**
- macOS: `.icns` (multi-resolution: 512, 256, 128, 32)
- Windows: `.ico` (256, 128, 64, 32, 16)
- Linux: `.png` (512x512)
**Common issues:**
- Main class not found → Verify `mainClass = "...MainKt"` (Kotlin adds `Kt` suffix)
- Native libs missing → Ensure secp256k1-kmp-jni-jvm in dependencies
- Icon not found → Check file exists at path, use absolute path if needed
## Build Performance Optimization
**Add to `gradle.properties`:**
```properties
# Daemon (faster subsequent builds)
org.gradle.daemon=true
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
# Parallel execution (multi-module speedup)
org.gradle.parallel=true
org.gradle.workers.max=8
# Caching (incremental builds)
org.gradle.caching=true
org.gradle.configuration-cache=true
# Kotlin daemon
kotlin.incremental=true
kotlin.daemon.jvmargs=-Xmx2g
```
**Impact:** Typically 30-50% faster builds after first run.
**Measure impact:**
```bash
./gradlew clean build --profile
# Report: build/reports/profile/profile-<timestamp>.html
```
**When to clean build:**
- After changing version catalog
- After adding/removing source sets
- When seeing unexplained errors
**When NOT to clean:**
- Regular development iteration
- Small code changes
- Incremental compilation works fine
Use script: `scripts/analyze-build-time.sh` for automated profiling.
## Troubleshooting: Practical Patterns
### Pattern 1: Version Conflict
**Symptom:** `Duplicate class` or `NoSuchMethodError`
**Diagnosis:**
```bash
./gradlew dependencyInsight --dependency <library-name>
```
**Fix options:**
1. Align versions in libs.versions.toml (preferred)
2. Force resolution:
```kotlin
configurations.all {
resolutionStrategy {
force(libs.okhttp.get().toString())
}
}
```
### Pattern 2: Source Set Issues
**Symptom:** `Unresolved reference` to JVM library in shared code
**Diagnosis:** Check source set hierarchy. JVM-only libs (jackson, okhttp) can't be in commonMain.
**Fix:** Move to jvmAndroid or platform-specific source set.
```kotlin
// ❌ Wrong
commonMain {
dependencies {
implementation(libs.jackson.module.kotlin) // JVM-only!
}
}
// ✅ Correct
val jvmAndroid = create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
api(libs.jackson.module.kotlin) // JVM code, shared by Android + Desktop
}
}
```
### Pattern 3: Proguard Stripping Native Libs
**Symptom:** `NoClassDefFoundError` for secp256k1, JNA, or LibSodium in release builds
**Fix:** Update proguard rules in `quartz/proguard-rules.pro`:
```proguard
# Native libraries
-keep class fr.acinq.secp256k1.** { *; }
-keep class com.goterl.lazysodium.** { *; }
-keep class com.sun.jna.** { *; }
# Jackson (reflection-based)
-keep class com.vitorpamplona.quartz.** { *; }
-keepattributes *Annotation*
-keepattributes Signature
```
### Pattern 4: Compose Compiler Mismatch
**Symptom:** `IllegalStateException: Version mismatch: runtime 1.10.0 but compiler 1.9.0`
**Fix:** Update Compose Multiplatform version in libs.versions.toml to match Kotlin version compatibility.
Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
### Pattern 5: Wrong JVM Target
**Symptom:** `Unsupported class file major version 65`
**Fix:** Ensure Java 21 everywhere:
```bash
# Check current Java
java -version # Should show 21
# Set JAVA_HOME
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
# Stop Gradle daemon to pick up new Java
./gradlew --stop
```
Verify all build files use JVM 21:
```kotlin
kotlin {
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
}
android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
```
## Comprehensive Error Guide
For detailed troubleshooting of specific errors, see [references/common-errors.md](references/common-errors.md). Covers:
- Compose version conflicts
- secp256k1 JNI errors
- Source set dependency issues
- Proguard/R8 problems
- Desktop packaging errors
- Kotlin compilation errors
- Dependency resolution failures
- JVM/JDK version issues
Each error includes: symptom, cause, solution, verification steps.
## Quick Diagnostic Commands
```bash
# Check dependencies for specific module
./gradlew :quartz:dependencies
# Find specific library in dependency tree
./gradlew dependencyInsight --dependency okhttp
# Build with detailed logging
./gradlew build --info
# Generate interactive build scan (best diagnostics)
./gradlew build --scan
# Profile build performance
./gradlew clean build --profile
# Stop all Gradle daemons (fresh start)
./gradlew --stop
# Check Gradle version
./gradlew --version
```
## Scripts & References
### Diagnostic Scripts
- `scripts/analyze-build-time.sh` - Profile build performance, generate optimization report
- `scripts/fix-dependency-conflicts.sh` - Diagnose common dependency conflicts, suggest fixes
### Reference Docs
- `references/build-commands.md` - Comprehensive command reference for all tasks
- `references/dependency-graph.md` - Module dependencies, source set hierarchy, transitive deps
- `references/version-catalog-guide.md` - Version catalog patterns, usage, best practices
- `references/common-errors.md` - Troubleshooting guide for frequent build issues
## Workflow Examples
### Example 1: Adding New Dependency
**Task:** Add kotlinx.datetime to quartz
**Steps:**
1. **Update version catalog** (gradle/libs.versions.toml):
```toml
[versions]
kotlinxDatetime = "0.6.0"
[libraries]
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
```
2. **Add to build file** (quartz/build.gradle.kts):
```kotlin
sourceSets {
commonMain {
dependencies {
implementation(libs.kotlinx.datetime) // KMP library, goes in commonMain
}
}
}
```
3. **Sync & verify:**
```bash
./gradlew :quartz:dependencies | grep datetime
```
### Example 2: Fixing secp256k1 Error on Desktop
**Error:** `UnsatisfiedLinkError: no secp256k1jni in java.library.path` when running desktop app
**Diagnosis:**
```bash
./gradlew :desktopApp:dependencies --configuration runtimeClasspath | grep secp256k1
# Shows: secp256k1-kmp-jni-android ← WRONG!
```
**Fix:**
```kotlin
// In quartz/build.gradle.kts
jvmMain {
dependencies {
// Change from:
// implementation(libs.secp256k1.kmp.jni.android) ❌
// To:
implementation(libs.secp256k1.kmp.jni.jvm) // ✅
}
}
```
**Verify:**
```bash
./gradlew :desktopApp:dependencies --configuration runtimeClasspath | grep secp256k1
# Now shows: secp256k1-kmp-jni-jvm ✅
./gradlew :desktopApp:run # Should work
```
### Example 3: Optimizing Build Time
**Current:** Clean build takes 5 minutes
**Steps:**
1. **Baseline measurement:**
```bash
./gradlew clean build --profile
# Check: build/reports/profile/profile-*.html
```
2. **Add optimizations** to gradle.properties:
```properties
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx4g
kotlin.incremental=true
```
3. **Re-measure:**
```bash
./gradlew clean build --profile
```
**Expected improvement:** 30-50% faster on subsequent builds (incremental builds much faster).
## Delegation Patterns
**When to delegate to other skills:**
- **Source set architecture** (jvmAndroid pattern, expect/actual) → Use `kotlin-multiplatform` skill
- **Compose UI issues** (composables, state management) → Use `compose-expert` skill (when available)
- **Kotlin language issues** (Flow, sealed classes, DSLs) → Use `kotlin-expert` skill
- **Desktop-specific features** (Window management, MenuBar, tray) → Use `desktop-expert` skill
**This skill handles:** Build system, dependencies, versioning, module structure, packaging, performance.
## Core Principles for This Build System
1. **Centralize versions:** Never hardcode versions in build.gradle.kts. Always use libs.versions.toml.
2. **Respect source set hierarchy:** Dependencies flow downward. jvmAndroid depends on commonMain, never the reverse.
3. **Platform-specific variants matter:** secp256k1, JNA must use correct variant per platform. Check when errors occur.
4. **Clean builds are expensive:** Use incremental compilation. Only clean when truly needed (source set changes, version updates).
5. **Compose alignment is critical:** Compose Multiplatform version must match Kotlin version. Check compatibility matrix.
6. **Proguard for native libs:** All JNI libraries need explicit `-keep` rules in release builds.
7. **Java 21 everywhere:** All modules, all targets, consistent JVM version.

View File

@@ -0,0 +1,214 @@
# Build Commands Reference
## Table of Contents
- [Core Build Tasks](#core-build-tasks)
- [Module-Specific Builds](#module-specific-builds)
- [Desktop Tasks](#desktop-tasks)
- [Android Tasks](#android-tasks)
- [Testing](#testing)
- [Analysis & Diagnostics](#analysis--diagnostics)
- [Performance Optimization](#performance-optimization)
## Core Build Tasks
### Full Project Build
```bash
./gradlew build # Build all modules
./gradlew clean build # Clean build
./gradlew assemble # Build without tests
```
### Incremental Builds
```bash
./gradlew :quartz:build # Build only quartz module
./gradlew :commons:build # Build only commons module
./gradlew :desktopApp:build # Build only desktop app
```
## Module-Specific Builds
### Quartz (KMP Library)
```bash
./gradlew :quartz:build # All targets
./gradlew :quartz:compileKotlinJvm # JVM target only
./gradlew :quartz:compileDebugKotlinAndroid # Android target only
./gradlew :quartz:linkDebugFrameworkIosArm64 # iOS framework
./gradlew :quartz:publishToMavenLocal # Publish locally
```
### Commons (Shared UI)
```bash
./gradlew :commons:build # All targets
./gradlew :commons:compileKotlinJvm # Desktop target
./gradlew :commons:compileDebugKotlinAndroid # Android target
```
## Desktop Tasks
### Run Desktop App
```bash
./gradlew :desktopApp:run # Run desktop app
./gradlew :desktopApp:runDistributable # Run packaged version
```
### Package Desktop App
```bash
./gradlew :desktopApp:createDistributable # Create runnable package
./gradlew :desktopApp:packageDmg # macOS DMG
./gradlew :desktopApp:packageMsi # Windows MSI
./gradlew :desktopApp:packageDeb # Linux DEB
```
### Distribution Location
- macOS: `desktopApp/build/compose/binaries/main/dmg/`
- Windows: `desktopApp/build/compose/binaries/main/msi/`
- Linux: `desktopApp/build/compose/binaries/main/deb/`
## Android Tasks
### Compile & Assemble
```bash
./gradlew :amethyst:assembleDebug # Debug APK
./gradlew :amethyst:assembleRelease # Release APK
./gradlew :amethyst:bundleRelease # Release AAB
```
### Install & Run
```bash
./gradlew :amethyst:installDebug # Install debug on device
adb shell am start -n com.vitorpamplona.amethyst/.MainActivity
```
### Proguard/R8
```bash
./gradlew :quartz:minifyReleaseWithR8 # Test R8 minification
```
## Testing
### Unit Tests
```bash
./gradlew test # All unit tests
./gradlew :quartz:jvmTest # JVM unit tests
./gradlew :quartz:testDebugUnitTest # Android unit tests
./gradlew :commons:test # Commons tests
```
### Android Instrumented Tests
```bash
./gradlew :quartz:connectedAndroidTest # Requires device/emulator
```
### Test Reports
```bash
# Reports location: <module>/build/reports/tests/
open quartz/build/reports/tests/jvmTest/index.html
```
## Analysis & Diagnostics
### Dependency Analysis
```bash
./gradlew dependencies # All dependencies
./gradlew :quartz:dependencies # Quartz dependencies
./gradlew dependencyInsight --dependency okhttp # Specific dependency
```
### Build Scan
```bash
./gradlew build --scan # Upload to scans.gradle.com
```
### Performance Profiling
```bash
./gradlew build --profile # Generate profile report
# Report: build/reports/profile/profile-<timestamp>.html
```
### Task Dependencies
```bash
./gradlew :desktopApp:run --dry-run # Show task graph
./gradlew :desktopApp:dependencies --scan # Visualize dependencies
```
## Performance Optimization
### Configuration Cache
```bash
./gradlew build --configuration-cache # Enable config cache
./gradlew build --configuration-cache-problems=warn
```
### Build Cache
```bash
./gradlew build --build-cache # Enable build cache
./gradlew cleanBuildCache # Clear build cache
```
### Parallel Execution
```bash
./gradlew build --parallel --max-workers=8 # Parallel with 8 workers
```
### Daemon Management
```bash
./gradlew --stop # Stop Gradle daemon
./gradlew --status # Daemon status
```
### Incremental Compilation
```bash
# Already enabled by default in Kotlin, but can verify:
./gradlew :quartz:compileKotlinJvm --info | grep "Incremental"
```
## gradle.properties Optimizations
Add to `gradle.properties` for faster builds:
```properties
# Daemon
org.gradle.daemon=true
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
# Parallel
org.gradle.parallel=true
org.gradle.workers.max=8
# Caching
org.gradle.caching=true
org.gradle.configuration-cache=true
# Kotlin
kotlin.incremental=true
kotlin.daemon.jvmargs=-Xmx2g
```
## Common Workflows
### Full Desktop Build & Run
```bash
./gradlew :desktopApp:clean :desktopApp:run
```
### Quick Desktop Iteration
```bash
# No clean - incremental compilation
./gradlew :desktopApp:run
```
### Android Release Build
```bash
./gradlew :amethyst:clean :amethyst:bundleRelease
```
### Test All KMP Targets
```bash
./gradlew :quartz:test :quartz:testDebugUnitTest
```
### Publish Quartz Locally for Testing
```bash
./gradlew :quartz:publishToMavenLocal
# Then update version in consumer project to test
```

View File

@@ -0,0 +1,643 @@
# Common Build Errors & Solutions
## Table of Contents
- [Compose Version Conflicts](#compose-version-conflicts)
- [secp256k1 JNI Errors](#secp256k1-jni-errors)
- [Source Set Dependency Issues](#source-set-dependency-issues)
- [Proguard/R8 Issues](#proguardr8-issues)
- [Desktop Packaging Errors](#desktop-packaging-errors)
- [Kotlin Compilation Errors](#kotlin-compilation-errors)
- [Dependency Resolution Failures](#dependency-resolution-failures)
- [JVM/JDK Version Issues](#jvmjdk-version-issues)
---
## Compose Version Conflicts
### Error 1: Compose Runtime Mismatch
```
java.lang.IllegalStateException: Version mismatch: Compose runtime is 1.10.0 but compiler is 1.9.0
```
**Cause:** Compose Compiler plugin version doesn't match Compose Runtime
**Solution:**
```kotlin
// In gradle/libs.versions.toml
composeMultiplatform = "1.9.3" // Must align with Kotlin version
kotlin = "2.3.0"
// Check compatibility matrix:
// https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
```
**Verification:**
```bash
./gradlew :commons:dependencies | grep compose
```
### Error 2: AndroidX Compose BOM Conflict
```
Duplicate class androidx.compose.ui.platform.AndroidCompositionLocalMap found in modules...
```
**Cause:** Both Compose Multiplatform and AndroidX Compose BOM providing same classes
**Solution:**
```kotlin
// In commons/build.gradle.kts (KMP module)
// Use Compose Multiplatform, NOT AndroidX BOM
dependencies {
implementation(compose.ui) // ✅ Compose Multiplatform
implementation(compose.material3)
// Don't use in KMP modules:
// implementation(libs.androidx.compose.bom) // ❌ Android-only
}
// In amethyst/build.gradle.kts (Android-only module)
// Can use AndroidX BOM
dependencies {
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
implementation(libs.androidx.ui)
}
```
### Error 3: Material3 WindowSizeClass Not Found
```
Unresolved reference: WindowSizeClass
```
**Cause:** Using Android's WindowSizeClass in shared KMP code
**Solution:**
```kotlin
// Don't use in commonMain or jvmAndroid:
// import androidx.compose.material3.windowsizeclass.WindowSizeClass // ❌
// Use in androidMain only, or create expect/actual:
// commonMain
expect class WindowSizeClassAdapter
// androidMain
actual typealias WindowSizeClassAdapter = androidx.compose.material3.windowsizeclass.WindowSizeClass
// jvmMain (desktop)
actual class WindowSizeClassAdapter { /* Custom impl */ }
```
---
## secp256k1 JNI Errors
### Error 1: JNI Library Not Found (Desktop)
```
java.lang.UnsatisfiedLinkError: no secp256k1jni in java.library.path
```
**Cause:** Desktop using wrong secp256k1 variant (Android JNI instead of JVM JNI)
**Solution:**
```kotlin
// In quartz/build.gradle.kts
sourceSets {
jvmMain {
dependencies {
// ✅ Correct - JVM variant
implementation(libs.secp256k1.kmp.jni.jvm)
// ❌ Wrong - Android variant
// implementation(libs.secp256k1.kmp.jni.android)
}
}
}
```
**Verification:**
```bash
./gradlew :quartz:dependencies --configuration jvmRuntimeClasspath | grep secp256k1
# Should show: secp256k1-kmp-jni-jvm, NOT jni-android
```
### Error 2: Version Mismatch Between Variants
```
java.lang.NoSuchMethodError: fr.acinq.secp256k1.Secp256k1.sign
```
**Cause:** Common, Android, and JVM variants have different versions
**Solution:**
```toml
# In gradle/libs.versions.toml
# All three MUST use same version
secp256k1KmpJniAndroid = "0.22.0"
[libraries]
secp256k1-kmp-common = { ..., version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-android = { ..., version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm = { ..., version.ref = "secp256k1KmpJniAndroid" }
```
### Error 3: Android JNI Not Loaded
```
java.lang.UnsatisfiedLinkError: dalvik.system.PathClassLoader couldn't find "libsecp256k1jni.so"
```
**Cause:** Proguard stripping JNI classes
**Solution:**
```proguard
# In quartz/proguard-rules.pro
-keep class fr.acinq.secp256k1.** { *; }
```
---
## Source Set Dependency Issues
### Error 1: jvmAndroid Defined After androidMain
```
Could not get unknown property 'jvmAndroid' for source set container
```
**Cause:** Source sets must be defined in dependency order
**Solution:**
```kotlin
// ✅ Correct order
sourceSets {
commonMain { }
// Define jvmAndroid BEFORE androidMain and jvmMain
val jvmAndroid = create("jvmAndroid") {
dependsOn(commonMain.get())
}
androidMain {
dependsOn(jvmAndroid) // Now jvmAndroid exists
}
jvmMain {
dependsOn(jvmAndroid)
}
}
```
### Error 2: Dependency in Wrong Source Set
```
Unresolved reference: ObjectMapper (Jackson)
```
**Cause:** JVM-only library in commonMain
**Solution:**
```kotlin
sourceSets {
commonMain {
// ❌ Jackson is JVM-only, can't use here
// implementation(libs.jackson.module.kotlin)
}
val jvmAndroid = create("jvmAndroid") {
dependsOn(commonMain.get())
// ✅ Jackson in jvmAndroid (shared JVM code)
api(libs.jackson.module.kotlin)
}
}
```
### Error 3: Platform-Specific Code in Shared Source Set
```
java.lang.NoClassDefFoundError: android.content.Context
```
**Cause:** Android-specific API in jvmAndroid or commonMain
**Solution:**
```kotlin
// Use expect/actual pattern
// commonMain
expect class PlatformContext
// androidMain
actual typealias PlatformContext = android.content.Context
// jvmMain
actual class PlatformContext {
// Custom desktop implementation
}
```
---
## Proguard/R8 Issues
### Error 1: Native Library Classes Stripped
```
java.lang.NoClassDefFoundError: com.goterl.lazysodium.Sodium
```
**Cause:** R8/Proguard removing JNA/LibSodium classes
**Solution:**
```proguard
# In quartz/proguard-rules.pro
-keep class com.goterl.lazysodium.** { *; }
-keep class com.sun.jna.** { *; }
-keep class fr.acinq.secp256k1.** { *; }
```
### Error 2: Reflection-Based Libraries Broken
```
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of ...
```
**Cause:** Jackson uses reflection, R8 strips class metadata
**Solution:**
```proguard
# Preserve reflection metadata
-keepattributes *Annotation*
-keepattributes Signature
-keepattributes InnerClasses
# Keep all Quartz event classes
-keep class com.vitorpamplona.quartz.** { *; }
```
### Error 3: Enum Values Missing
```
java.lang.IllegalArgumentException: No enum constant ...
```
**Cause:** R8 obfuscating enum names
**Solution:**
```proguard
# Keep all enums
-keep enum ** { *; }
-keepnames class ** { *; }
```
---
## Desktop Packaging Errors
### Error 1: Icon Not Found
```
FAILURE: Build failed with an exception.
* What went wrong: Cannot find icon file: src/jvmMain/resources/icon.icns
```
**Cause:** Icon file missing or wrong path
**Solution:**
```kotlin
// In desktopApp/build.gradle.kts
nativeDistributions {
macOS {
// Ensure file exists at this path
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
}
// Check file exists:
// ls -la desktopApp/src/jvmMain/resources/
}
```
**Icon Requirements:**
- macOS: `.icns` (512x512, 256x256, 128x128, 32x32)
- Windows: `.ico` (256x256, 128x128, 64x64, 32x32, 16x16)
- Linux: `.png` (512x512 recommended)
### Error 2: Main Class Not Found
```
Error: Could not find or load main class com.vitorpamplona.amethyst.desktop.MainKt
```
**Cause:** Wrong mainClass path or Main.kt doesn't have main()
**Solution:**
```kotlin
// In desktopApp/build.gradle.kts
compose.desktop {
application {
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
// ^^^^
// Kotlin compiler adds "Kt" suffix
}
}
// In src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
fun main() = application {
// ...
}
```
### Error 3: Native Library Missing in Package
```
java.lang.UnsatisfiedLinkError: no secp256k1jni in java.library.path
```
**Cause:** Native libraries not bundled in distribution
**Solution:**
```kotlin
// Native libs are automatically included via dependencies
// Verify secp256k1-kmp-jni-jvm is in dependencies:
dependencies {
implementation(libs.secp256k1.kmp.jni.jvm) // ✅ Includes native libs
}
// Test packaged app:
./gradlew :desktopApp:createDistributable
# Run from: desktopApp/build/compose/binaries/main/app/
```
---
## Kotlin Compilation Errors
### Error 1: Expect/Actual Mismatch
```
'actual' declaration has no corresponding expected declaration
```
**Cause:** Signature mismatch or missing expect
**Solution:**
```kotlin
// commonMain - expect declaration
expect class CryptoProvider {
fun sign(message: ByteArray, privateKey: ByteArray): ByteArray
}
// androidMain & jvmMain - actual must match EXACTLY
actual class CryptoProvider {
actual fun sign(message: ByteArray, privateKey: ByteArray): ByteArray {
// Implementation
}
}
// Common mistakes:
// - Different parameter names ❌
// - Different return types ❌
// - Missing 'actual' modifier ❌
```
### Error 2: Target JVM Version Mismatch
```
Compilation failed: module was compiled with an incompatible version of Kotlin
```
**Cause:** Different JVM targets across modules
**Solution:**
```kotlin
// Ensure ALL modules use same JVM target
// In quartz/build.gradle.kts
kotlin {
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21) // ✅ Java 21
}
}
}
// In android {} block
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
```
### Error 3: Compose Compiler Plugin Missing
```
This declaration needs opt-in. Please use @OptIn(ComposeApi::class) or @Composable
```
**Cause:** Compose compiler plugin not applied
**Solution:**
```kotlin
// In build.gradle.kts
plugins {
alias(libs.plugins.jetbrainsComposeCompiler) // ✅ Add this
alias(libs.plugins.composeMultiplatform)
}
```
---
## Dependency Resolution Failures
### Error 1: Repository Not Found
```
Could not find com.github.vitorpamplona.compose-richtext:richtext-ui:f92ef49c9d
```
**Cause:** Jitpack or custom Maven repository not configured
**Solution:**
```kotlin
// In settings.gradle
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = "https://jitpack.io" } // ✅ Add Jitpack
}
}
```
### Error 2: Gradle Version Too Old
```
Version catalogs are not supported in this version of Gradle
```
**Cause:** Gradle < 7.0
**Solution:**
```properties
# In gradle/wrapper/gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
```
Then: `./gradlew wrapper --gradle-version=8.9`
### Error 3: Dependency Variant Not Found
```
No matching variant of fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.22.0 was found
```
**Cause:** Wrong dependency configuration for target
**Solution:**
```kotlin
// In androidMain (Android library module)
dependencies {
// For AAR packaging
implementation("net.java.dev.jna:jna:5.18.1@aar") // ✅ Specify @aar
// secp256k1 works without @aar (auto-detects)
api(libs.secp256k1.kmp.jni.android)
}
```
---
## JVM/JDK Version Issues
### Error 1: Unsupported Class File Version
```
Unsupported class file major version 65
```
**Cause:** Compiled with Java 21, running with older Java
**Solution:**
```bash
# Check Java version
java -version # Should show 21
# Set JAVA_HOME if needed
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
# Or in gradle.properties
org.gradle.java.home=/path/to/jdk-21
```
### Error 2: JVM Toolchain Not Found
```
No matching toolchain found for requested JvmVersion
```
**Cause:** Java 21 not installed or not detected
**Solution:**
```bash
# macOS (Homebrew)
brew install openjdk@21
# Ubuntu
sudo apt install openjdk-21-jdk
# Set JAVA_HOME
export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk # Linux
# Verify
./gradlew -version
```
### Error 3: Gradle Daemon Using Wrong Java
```
Daemon will be stopped at the end of the build because JVM version has changed
```
**Cause:** Daemon started with different Java version
**Solution:**
```bash
# Stop all daemons
./gradlew --stop
# Start with correct JAVA_HOME
export JAVA_HOME=/path/to/jdk-21
./gradlew build
# Or set in gradle.properties permanently
org.gradle.java.home=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
```
---
## General Troubleshooting Steps
### Step 1: Clean Build
```bash
./gradlew clean
./gradlew --stop # Stop daemon
./gradlew build
```
### Step 2: Check Dependencies
```bash
./gradlew :moduleName:dependencies
./gradlew dependencyInsight --dependency libraryName
```
### Step 3: Enable Debug Logging
```bash
./gradlew build --info # Info logging
./gradlew build --debug # Debug logging (verbose)
./gradlew build --stacktrace
```
### Step 4: Invalidate Caches
```bash
# Clear Gradle cache
rm -rf ~/.gradle/caches/
# Clear build outputs
./gradlew clean
# Clear Gradle wrapper cache
rm -rf ~/.gradle/wrapper/
```
### Step 5: Build Scan
```bash
./gradlew build --scan
# Opens interactive diagnostics in browser
```
## Quick Reference: Error Keywords → Solution
| Error Keyword | Likely Cause | Quick Fix |
|---------------|--------------|-----------|
| `UnsatisfiedLinkError` | Wrong JNI variant | Check secp256k1/JNA variants by platform |
| `IllegalStateException` (Compose) | Version mismatch | Align Compose Multiplatform + Kotlin versions |
| `NoClassDefFoundError` | Proguard stripping | Add `-keep` rule for class |
| `Unresolved reference` | Wrong source set | Move to appropriate source set (jvmAndroid) |
| `Duplicate class` | BOM conflict | Remove AndroidX BOM from KMP modules |
| `Version mismatch` | Plugin/runtime version mismatch | Update libs.versions.toml |
| `No matching variant` | Repository or packaging issue | Add repository or @aar suffix |
| `Could not find` (dependency) | Missing repository | Add maven/jitpack to repositories |
| `Unsupported class file` | Java version mismatch | Update JAVA_HOME to Java 21 |
---
## Getting Help
1. **Check Build Scan**: `./gradlew build --scan` for detailed diagnostics
2. **Gradle Forums**: https://discuss.gradle.org/
3. **Kotlin Slack**: #multiplatform channel
4. **Stack Overflow**: Tags `gradle`, `kotlin-multiplatform`, `compose-multiplatform`

View File

@@ -0,0 +1,265 @@
# Module Dependency Graph
## Visual Hierarchy
```
┌─────────────────────────────────────────────────────────┐
│ Root Project │
│ (Amethyst) │
└─────────────────────────────────────────────────────────┘
┌────────────────┼────────────────┬────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────┐
│ :amethyst │ │ :desktopApp │ │ :benchmark │ │:ammolite │
│ (Android) │ │ (JVM) │ │ (Android) │ │ (Support)│
└─────────────┘ └─────────────┘ └─────────────┘ └──────────┘
│ │ │
│ │ │
└────────────────┼────────────────┘
┌─────────────┐
│ :commons │
│ (KMP UI) │
│ │
│ jvmAndroid │
│ / \ │
│ jvm android│
└─────────────┘
┌─────────────┐
│ :quartz │
│(KMP Library)│
│ │
│ commonMain │
│ │ │
│ jvmAndroid │
│ / | \ │
│jvm and ios │
└─────────────┘
```
## Module Details
### :quartz (KMP Nostr Library)
**Type:** Kotlin Multiplatform Library
**Targets:** JVM, Android, iOS (iosArm64, iosSimulatorArm64)
**Dependencies:**
- External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable
- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain
**Role:** Core Nostr protocol implementation, shared across all platforms
### :commons (Shared UI Components)
**Type:** Kotlin Multiplatform Library
**Targets:** JVM, Android
**Dependencies:**
- Module: `:quartz`
- External: Compose Multiplatform, Material3, kotlinx.collections.immutable
- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}
**Role:** Shared Compose UI components for Desktop and Android
### :desktopApp (Desktop Application)
**Type:** JVM Application
**Targets:** JVM (Desktop)
**Dependencies:**
- Modules: `:commons`, `:quartz`
- External: Compose Desktop, kotlinx.coroutines.swing
**Role:** Desktop-specific navigation, layouts, and entry point
### :amethyst (Android Application)
**Type:** Android Application
**Targets:** Android
**Dependencies:**
- Modules: `:commons`, `:quartz`, `:ammolite`
- External: Android SDK, AndroidX, Firebase, Tor
**Role:** Android-specific navigation, layouts, and entry point
### :benchmark (Android Benchmark)
**Type:** Android Library
**Targets:** Android
**Dependencies:**
- Modules: `:commons`, `:quartz`
- External: AndroidX Benchmark
**Role:** Performance benchmarking for Android builds
### :ammolite (Support Module)
**Type:** Android Library
**Targets:** Android
**Dependencies:** Android-specific utilities
**Role:** Android support utilities for amethyst
## Dependency Flow Patterns
### Desktop Build Chain
```
:desktopApp → :commons (jvmMain) → :quartz (jvmMain)
jvmAndroid
commonMain
```
### Android Build Chain
```
:amethyst → :commons (androidMain) → :quartz (androidMain)
↓ ↓
:ammolite jvmAndroid
commonMain
```
## Source Set Dependencies
### :quartz Source Sets
```
commonMain (base)
├─ jvmAndroid (shared JVM code)
│ ├─ androidMain (Android platform)
│ └─ jvmMain (Desktop platform)
└─ iosMain (iOS platform)
├─ iosArm64Main
└─ iosSimulatorArm64Main
```
**Key Dependencies per Source Set:**
- **commonMain**: secp256k1-kmp, kotlinx.coroutines, collection, immutable collections
- **jvmAndroid**: jackson, okhttp, url-detector, rfc3986
- **androidMain**: secp256k1-kmp-jni-android, lazysodium-android, jna (aar)
- **jvmMain**: secp256k1-kmp-jni-jvm, lazysodium-java, jna (jar)
### :commons Source Sets
```
commonMain (base UI)
└─ jvmAndroid (shared JVM UI)
├─ androidMain (Android UI utilities)
└─ jvmMain (Desktop UI utilities)
```
**Key Dependencies per Source Set:**
- **commonMain**: Compose Multiplatform, Material3, :quartz
- **jvmAndroid**: url-detector
- **androidMain**: AndroidX Compose tooling
- **jvmMain**: Compose Desktop
## Critical Dependency Patterns
### 1. secp256k1 Variants
```kotlin
// commonMain - API only
api(libs.secp256k1.kmp.common)
// androidMain - JNI Android
api(libs.secp256k1.kmp.jni.android)
// jvmMain - JNI JVM
implementation(libs.secp256k1.kmp.jni.jvm)
```
**Why:** Different JNI bindings for Android vs Desktop JVM
### 2. JNA Variants (for LibSodium)
```kotlin
// androidMain
implementation("com.goterl:lazysodium-android:5.2.0@aar")
implementation("net.java.dev.jna:jna:5.18.1@aar")
// jvmMain
implementation(libs.lazysodium.java)
implementation(libs.jna) // JAR variant
```
**Why:** Android needs AAR packaging, JVM needs JAR
### 3. Compose Alignment
```kotlin
// commons/build.gradle.kts
implementation(compose.ui) // Compose Multiplatform BOM
implementation(compose.material3)
// Version catalog alignment
composeMultiplatform = "1.9.3"
composeBom = "2025.12.01" // AndroidX Compose
```
**Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align
## Dependency Configuration Types
### API vs Implementation
**Use `api` when:**
- Dependency types appear in module's public API
- Used in expect/actual declarations visible to consumers
- Example: `secp256k1-kmp-common` in quartz (public types)
**Use `implementation` when:**
- Internal implementation detail
- Not exposed to module consumers
- Example: `okhttp` in quartz (internal network client)
### Example from quartz
```kotlin
// Public API - exposed to consumers
api(libs.secp256k1.kmp.common)
api(libs.jackson.module.kotlin) // Event serialization public
// Internal implementation
implementation(libs.okhttp)
implementation(libs.kotlinx.coroutines.core)
```
## Transitive Dependency Impact
### When :desktopApp depends on :commons
- Gets `:quartz` transitively (via :commons)
- Gets `secp256k1-kmp-jvm` transitively (via :quartz jvmMain)
- Does NOT get Android-specific dependencies (scoped to androidMain)
### When :amethyst depends on :commons
- Gets `:quartz` transitively (via :commons)
- Gets `secp256k1-kmp-jni-android` transitively (via :quartz androidMain)
- Does NOT get JVM/Desktop-specific dependencies (scoped to jvmMain)
## Verifying Dependencies
### Check Module Dependencies
```bash
./gradlew :desktopApp:dependencies
./gradlew :amethyst:dependencies
```
### Check Specific Library
```bash
./gradlew dependencyInsight --dependency secp256k1
./gradlew dependencyInsight --dependency compose-ui
```
### Visualize with Build Scan
```bash
./gradlew :desktopApp:dependencies --scan
# Opens interactive dependency graph in browser
```
## Common Dependency Issues
### Issue 1: Wrong secp256k1 Variant in Desktop
**Symptom:** `UnsatisfiedLinkError: no secp256k1jni in java.library.path`
**Cause:** Desktop using Android JNI variant
**Fix:** Ensure jvmMain uses `secp256k1-kmp-jni-jvm`
### Issue 2: Compose Version Mismatch
**Symptom:** `IllegalStateException: Version mismatch`
**Cause:** Compose Multiplatform plugin vs runtime version mismatch
**Fix:** Align `composeMultiplatform` version in libs.versions.toml with Kotlin plugin
### Issue 3: Duplicate JNA Classes
**Symptom:** `DuplicateClassException: com.sun.jna.Native`
**Cause:** Both JAR and AAR JNA variants in classpath
**Fix:** Use AAR (@aar) in androidMain, JAR in jvmMain (never in shared source sets)

View File

@@ -0,0 +1,422 @@
# Version Catalog Guide
## Overview
AmethystMultiplatform uses Gradle's version catalog (`gradle/libs.versions.toml`) to centralize dependency management. This ensures version consistency across all modules and simplifies updates.
## Structure
### sections
```toml
[versions] # Version numbers (referenced by libraries and plugins)
[libraries] # Library dependencies
[plugins] # Gradle plugins
```
## Version References
### Defining Versions
```toml
[versions]
kotlin = "2.3.0"
composeMultiplatform = "1.9.3"
okhttp = "5.3.2"
```
### Special Patterns
#### Android SDK Versions
```toml
android-compileSdk = "36"
android-minSdk = "26"
android-targetSdk = "36"
```
**Access in build.gradle.kts:**
```kotlin
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
```
#### Version Suffixes (Git Commits)
```toml
androidKotlinGeohash = "b481c6a64e" # Jitpack commit hash
markdown = "f92ef49c9d"
```
**Why:** For GitHub dependencies via Jitpack that don't have semantic versions
## Library Declarations
### Basic Pattern
```toml
[libraries]
library-name = { group = "...", name = "...", version.ref = "..." }
```
### Examples
#### Version Reference
```toml
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
```
#### Module Reference (for multi-artifact libs)
```toml
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidxCamera" }
```
#### Without Group (shorthand)
```toml
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
```
**Note:** Inherits version from BOM (compose-bom)
### BOMs (Bill of Materials)
#### AndroidX Compose BOM
```toml
[versions]
composeBom = "2025.12.01"
[libraries]
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
```
**Usage in build.gradle.kts:**
```kotlin
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
implementation(libs.androidx.ui) // Version from BOM
implementation(libs.androidx.material3) // Version from BOM
```
**Benefits:**
- All AndroidX Compose artifacts use compatible versions
- Update single BOM version, not individual libraries
- Prevents version conflicts
### Platform-Specific Variants
#### secp256k1 (KMP crypto library)
```toml
secp256k1KmpJniAndroid = "0.22.0"
[libraries]
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" }
```
**Critical:** All three variants MUST share the same version
#### JNA (for LibSodium)
```toml
jna = "5.18.1"
[libraries]
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
```
**Usage in build.gradle.kts:**
```kotlin
// androidMain - AAR packaging
implementation("net.java.dev.jna:jna:5.18.1@aar")
// jvmMain - JAR packaging
implementation(libs.jna)
```
**Why:** Android needs AAR, JVM needs JAR (different artifact types)
## Plugin Declarations
### Basic Pattern
```toml
[plugins]
plugin-id = { id = "...", version.ref = "..." }
```
### Examples
#### Kotlin Plugins
```toml
[versions]
kotlin = "2.3.0"
[plugins]
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlinxSerializationPlugin' }
```
**Critical:** All Kotlin plugins MUST use the same Kotlin version
#### Android Gradle Plugin
```toml
[versions]
agp = "8.13.2"
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
```
#### Compose Multiplatform
```toml
[versions]
composeMultiplatform = "1.9.3"
[plugins]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
```
### Plugin Application
```kotlin
// In build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.jetbrainsComposeCompiler)
}
```
## Usage in Build Files
### Accessing Versions
```kotlin
// Direct version access
val kotlinVersion = libs.versions.kotlin.get()
val minSdk = libs.versions.android.minSdk.get().toInt()
```
### Accessing Libraries
```kotlin
dependencies {
implementation(libs.kotlinx.coroutines.core)
api(libs.secp256k1.kmp.common)
implementation(libs.okhttp)
}
```
### Accessing Plugins
```kotlin
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
}
```
## Version Catalog Benefits
### 1. Centralized Version Management
Update once, applies everywhere:
```toml
# Change one line
kotlin = "2.3.0" "2.4.0"
# Affects all usages
- kotlinMultiplatform plugin
- jetbrainsKotlinAndroid plugin
- kotlin-stdlib
- All Kotlin-related dependencies
```
### 2. Type-Safe Accessors
```kotlin
// Compile-time checked
implementation(libs.okhttp) // ✅ IDE autocomplete
// vs string-based (error-prone)
implementation("com.squareup.okhttp3:okhttp:5.3.2") // ❌ No autocomplete
```
### 3. Dependency Consistency
```kotlin
// All modules reference same catalog
:quartz libs.okhttp
:commons libs.okhttp
:desktopApp libs.okhttp
// Same version everywhere
```
### 4. Gradle Sync Improvements
- Faster IDE sync (pre-parsed catalog)
- Better dependency resolution
- Clearer error messages
## Common Patterns
### GitHub Dependencies (Jitpack)
```toml
[versions]
markdown = "f92ef49c9d" # Git commit hash
[libraries]
markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" }
```
**Repository config** (in settings.gradle):
```kotlin
repositories {
maven { url = "https://jitpack.io" }
}
```
### Multi-Artifact Libraries
```toml
[versions]
media3 = "1.9.0"
[libraries]
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
```
**Why:** All media3 artifacts share same version for compatibility
### Test Dependencies
```toml
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
```
## Version Update Strategy
### Check for Updates
```bash
# Using Gradle Versions Plugin (if installed)
./gradlew dependencyUpdates
# Manual check
# Browse to Maven Central for specific library
```
### Update Process
1. **Update version in catalog**
```toml
okhttp = "5.3.2" → "5.4.0"
```
2. **Test locally**
```bash
./gradlew clean build
```
3. **Check for breaking changes**
- Review library changelog
- Run full test suite
4. **Commit with clear message**
```
chore: update okhttp 5.3.2 → 5.4.0
```
### Critical Version Alignments
#### Kotlin Ecosystem
```toml
kotlin = "2.3.0"
kotlinxCoroutinesCore = "1.10.2"
kotlinxSerialization = "1.9.0"
```
**Rule:** Kotlin version must be compatible with kotlinx libraries
#### Compose Ecosystem
```toml
composeMultiplatform = "1.9.3"
composeBom = "2025.12.01"
kotlin = "2.3.0"
```
**Rule:** Compose Multiplatform → Kotlin version (see compatibility matrix)
#### AGP & Gradle
```toml
agp = "8.13.2"
# Requires Gradle 8.9+
```
**Rule:** AGP version dictates minimum Gradle version
## Troubleshooting
### Issue 1: Unresolved Reference
**Error:** `Unresolved reference: libs`
**Cause:** Gradle version < 7.0 (version catalogs not supported)
**Fix:** Upgrade Gradle in `gradle/wrapper/gradle-wrapper.properties`
### Issue 2: Library Not Found
**Error:** `Could not find com.example:library:1.0.0`
**Cause:** Repository not configured or typo in catalog
**Fix:**
1. Check repository in settings.gradle
2. Verify group/name/version in libs.versions.toml
### Issue 3: Version Conflict
**Error:** `Conflict with dependency ... and ...`
**Cause:** Different versions of same library via transitive dependencies
**Fix:**
```kotlin
configurations.all {
resolutionStrategy {
force(libs.okhttp.get().toString())
}
}
```
## Best Practices
### 1. Naming Conventions
```toml
# Hyphen-separated, hierarchical
androidx-compose-ui
androidx-compose-material3
kotlinx-coroutines-core
# Platform suffixes
secp256k1-kmp-jni-android
secp256k1-kmp-jni-jvm
```
### 2. Group Related Dependencies
```toml
# Camera APIs together
androidx-camera-core
androidx-camera-camera2
androidx-camera-view
```
### 3. Document Special Cases
```toml
# JNA requires @aar for Android (see build.gradle.kts)
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
```
### 4. Keep BOMs Updated
```toml
# Update BOM, individual libs follow
composeBom = "2025.12.01" # Latest stable
```
### 5. Test Version Updates
```bash
# Before committing
./gradlew :quartz:test
./gradlew :commons:test
./gradlew :desktopApp:run
```

View File

@@ -0,0 +1,45 @@
#!/bin/bash
# Analyze Gradle build performance and generate report
set -e
PROJECT_ROOT="${1:-.}"
cd "$PROJECT_ROOT"
echo "🔍 Analyzing Gradle build performance..."
echo "========================================"
echo ""
# Clean build for accurate timing
echo "Running clean build with --profile..."
./gradlew clean build --profile --scan
# Find the latest profile report
PROFILE_REPORT=$(find build/reports/profile -name "*.html" -type f -printf '%T@ %p\n' | sort -n | tail -1 | cut -f2- -d" ")
if [ -n "$PROFILE_REPORT" ]; then
echo ""
echo "✅ Profile report generated: $PROFILE_REPORT"
echo ""
echo "📊 Build Performance Summary:"
echo "----------------------------"
# Extract key metrics if available
if command -v jq &> /dev/null && [ -f "build/reports/profile/profile.json" ]; then
jq -r '.buildTime, .taskExecutionTime' build/reports/profile/profile.json
else
echo "Open the HTML report for detailed analysis:"
echo "file://$PWD/$PROFILE_REPORT"
fi
else
echo "⚠️ Profile report not found"
fi
echo ""
echo "💡 Build optimization tips:"
echo "- Enable Gradle daemon: org.gradle.daemon=true"
echo "- Parallel execution: org.gradle.parallel=true"
echo "- Configuration cache: org.gradle.configuration-cache=true"
echo "- Build cache: org.gradle.caching=true"
echo ""
echo "Add these to gradle.properties for faster builds"

View File

@@ -0,0 +1,65 @@
#!/bin/bash
# Diagnose and suggest fixes for common dependency conflicts
set -e
PROJECT_ROOT="${1:-.}"
cd "$PROJECT_ROOT"
echo "🔍 Analyzing dependency conflicts..."
echo "===================================="
echo ""
# Run dependency report
echo "Generating dependency insight report..."
./gradlew dependencies --configuration runtimeClasspath > /tmp/gradle-dependencies.txt 2>&1 || true
# Check for common conflict patterns
echo ""
echo "🔎 Checking for common issues:"
echo "------------------------------"
# Check 1: Compose version conflicts
if grep -q "compose" /tmp/gradle-dependencies.txt; then
echo "✓ Compose dependencies found"
echo " Tip: Ensure Compose Multiplatform and AndroidX Compose versions align"
echo " Current project uses:"
echo " - Compose Multiplatform BOM"
echo " - AndroidX Compose BOM"
fi
# Check 2: secp256k1 variants
if grep -q "secp256k1" /tmp/gradle-dependencies.txt; then
echo "✓ secp256k1 dependencies found"
echo " Ensure correct variant:"
echo " - Android: secp256k1-kmp-jni-android"
echo " - JVM/Desktop: secp256k1-kmp-jni-jvm"
echo " - Common: secp256k1-kmp (transitive)"
fi
# Check 3: Kotlin version alignment
KOTLIN_VERSION=$(grep "kotlin =" gradle/libs.versions.toml | cut -d'"' -f2)
echo "✓ Kotlin version: $KOTLIN_VERSION"
echo " All Kotlin plugins should use the same version"
# Check 4: Multiple versions of same library
echo ""
echo "🔍 Checking for version conflicts..."
./gradlew dependencyInsight --configuration runtimeClasspath --dependency okhttp || true
echo ""
echo "💡 Common fixes:"
echo "---------------"
echo "1. Compose conflicts:"
echo " - Align compose-multiplatform plugin version with runtime"
echo " - Use BOM for AndroidX Compose to enforce consistency"
echo ""
echo "2. secp256k1 conflicts:"
echo " - Use 'api' instead of 'implementation' in source sets"
echo " - Ensure androidMain uses jni-android, jvmMain uses jni-jvm"
echo ""
echo "3. Kotlin version conflicts:"
echo " - Update all kotlin plugins to same version in libs.versions.toml"
echo " - Check for transitive Kotlin dependencies"
echo ""
echo "Run './gradlew dependencyInsight --dependency <name>' for specific conflicts"

View File

@@ -0,0 +1,419 @@
---
name: kotlin-coroutines
description: Advanced Kotlin coroutines patterns for AmethystMultiplatform. Use when working with: (1) Structured concurrency (supervisorScope, coroutineScope), (2) Advanced Flow operators (flatMapLatest, combine, merge, shareIn, stateIn), (3) Channels and callbackFlow, (4) Dispatcher management and context switching, (5) Exception handling (CoroutineExceptionHandler, SupervisorJob), (6) Testing async code (runTest, Turbine), (7) Nostr relay connection pools and subscriptions, (8) Backpressure handling in event streams. Delegates to kotlin-expert for basic StateFlow/SharedFlow patterns. Complements nostr-expert for relay communication.
---
# Kotlin Coroutines - Advanced Async Patterns
Expert guidance for complex async operations in Amethyst: relay pools, event streams, structured concurrency, and testing.
## Mental Model
```
Async Architecture in Amethyst:
Relay Pool (supervisorScope)
├── Relay 1 (launch) → callbackFlow → Events
├── Relay 2 (launch) → callbackFlow → Events
└── Relay 3 (launch) → callbackFlow → Events
merge() → distinctBy(id) → shareIn
Multiple Collectors (ViewModels, Services)
```
**Key principles:**
- **supervisorScope** - Children fail independently
- **callbackFlow** - Bridge callbacks to Flow
- **shareIn/stateIn** - Hot flows from cold
- **Backpressure** - buffer(), conflate(), DROP_OLDEST
## When to Use This Skill
Use for **advanced** async patterns:
- Multi-relay subscriptions with supervisorScope
- Complex Flow operators (flatMapLatest, combine, merge)
- callbackFlow for Android callbacks (connectivity, location)
- Backpressure handling in high-frequency streams
- Exception handling with CoroutineExceptionHandler
- Testing coroutines with runTest and Turbine
**Delegate to kotlin-expert for:**
- Basic StateFlow/SharedFlow patterns
- Simple viewModelScope.launch
- MutableStateFlow → asStateFlow()
## Core Patterns
### Pattern: callbackFlow for Relay Subscriptions
```kotlin
// Real pattern from NostrClientStaticReqAsStateFlow.kt
fun INostrClient.reqAsFlow(
relay: NormalizedRelayUrl,
filters: List<Filter>,
): Flow<List<Event>> = callbackFlow {
val subId = RandomInstance.randomChars(10)
var hasBeenLive = false
val eventIds = mutableSetOf<HexKey>()
var currentEvents = listOf<Event>()
val listener = object : IRequestListener {
override fun onEvent(event: Event, ...) {
if (event.id !in eventIds) {
currentEvents = if (hasBeenLive) {
// After EOSE: prepend
listOf(event) + currentEvents
} else {
// Before EOSE: append
currentEvents + event
}
eventIds.add(event.id)
trySend(currentEvents)
}
}
override fun onEose(...) {
hasBeenLive = true
}
}
openReqSubscription(subId, mapOf(relay to filters), listener)
awaitClose { close(subId) }
}
```
**Key techniques:**
1. Deduplication with Set
2. EOSE handling (append → prepend strategy)
3. trySend (non-blocking from callback)
4. awaitClose for cleanup
### Pattern: Structured Concurrency for Relays
```kotlin
suspend fun connectToRelays(relays: List<Relay>) = supervisorScope {
relays.forEach { relay ->
launch {
try {
relay.connect()
relay.subscribe(filters).collect { event ->
eventChannel.send(event)
}
} catch (e: IOException) {
Log.e("Relay", "Connection failed: ${relay.url}", e)
// Other relays continue
}
}
}
}
```
**Why supervisorScope:**
- One relay failure doesn't cancel others
- All cancelled together when scope cancelled
- Proper cleanup guaranteed
### Pattern: Exception Handling for Services
```kotlin
// Real pattern from PushNotificationReceiverService.kt
class MyService : Service() {
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.e("Service", "Caught: ${throwable.message}", throwable)
}
private val scope = CoroutineScope(
Dispatchers.IO + SupervisorJob() + exceptionHandler
)
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
}
```
**Pattern benefits:**
- SupervisorJob: children fail independently
- ExceptionHandler: log instead of crash
- Scoped lifecycle: cancel all on destroy
### Pattern: Network Connectivity as Flow
```kotlin
// Real pattern from ConnectivityFlow.kt
val status = callbackFlow {
val networkCallback = object : NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(ConnectivityStatus.Active(...))
}
override fun onLost(network: Network) {
trySend(ConnectivityStatus.Off)
}
}
connectivityManager.registerCallback(networkCallback)
// Initial state
activeNetwork?.let { trySend(ConnectivityStatus.Active(...)) }
awaitClose {
connectivityManager.unregisterCallback(networkCallback)
}
}
.distinctUntilChanged()
.debounce(200) // Stabilize flapping
.flowOn(Dispatchers.IO)
```
**Key patterns:**
1. Emit initial state immediately
2. Register callback in flow body
3. Cleanup in awaitClose
4. Stabilize with debounce + distinctUntilChanged
### Pattern: Merge Events from Multiple Relays
```kotlin
fun observeFromRelays(
relays: List<NormalizedRelayUrl>,
filters: List<Filter>
): Flow<Event> =
relays.map { relay ->
client.reqAsFlow(relay, filters)
.flatMapConcat { it.asFlow() }
}.merge()
.distinctBy { it.id }
```
**Flow:**
- Each relay: `Flow<List<Event>>`
- flatMapConcat: flatten to `Flow<Event>`
- merge(): combine all relays
- distinctBy: deduplicate across relays
## Advanced Operators
For comprehensive coverage of Flow operators:
- **flatMapLatest, combine, zip, merge** → See [advanced-flow-operators.md](references/advanced-flow-operators.md)
- **shareIn, stateIn** → Conversion to hot flows
- **buffer, conflate** → Backpressure strategies
- **debounce, sample** → Rate limiting
### Quick Reference
| Operator | Use Case | Example |
|----------|----------|---------|
| **flatMapLatest** | Cancel previous, switch to new | Search (cancel old query) |
| **combine** | Latest from ALL flows | combine(account, settings, connectivity) |
| **merge** | Single stream from multiple | merge(relay1, relay2, relay3) |
| **shareIn** | Multiple collectors, single upstream | Share expensive computation |
| **stateIn** | StateFlow from Flow | ViewModel state |
| **buffer(DROP_OLDEST)** | High-frequency streams | Real-time event feed |
| **conflate** | Latest only | UI updates |
| **debounce** | Wait for quiet period | Search input |
## Nostr Relay Patterns
For complete relay-specific patterns:
→ See [relay-patterns.md](references/relay-patterns.md)
Covers:
- Multi-relay subscription management
- Connection lifecycle and reconnection
- Event deduplication strategies
- Backpressure for high-frequency events
- EOSE handling patterns
## Testing
For comprehensive testing patterns:
→ See [testing-coroutines.md](references/testing-coroutines.md)
**Quick testing pattern:**
```kotlin
@Test
fun `relay subscription receives events`() = runTest {
val client = FakeNostrClient()
client.reqAsFlow(relay, filters).test {
assertEquals(emptyList(), awaitItem())
client.sendEvent(event1)
assertEquals(listOf(event1), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
```
**Testing tools:**
- `runTest` - Virtual time, auto cleanup
- Turbine `.test {}` - Flow assertions
- `advanceTimeBy()` - Control time
- Fake implementations over mocks
## Common Scenarios
### Scenario: Implement New Relay Feature
**Steps:**
1. callbackFlow for subscription
2. Deduplication (Set of event IDs)
3. awaitClose for cleanup
4. Test with FakeNostrClient
**Example:** Add subscription for specific event kind
```kotlin
fun observeKind(kind: Int): Flow<Event> = callbackFlow {
val listener = object : IRequestListener {
override fun onEvent(event: Event, ...) {
if (event.kind == kind) {
trySend(event)
}
}
}
client.subscribe(listener)
awaitClose { client.unsubscribe(listener) }
}
```
### Scenario: Handle Network Connectivity Changes
**Steps:**
1. callbackFlow for connectivity
2. flatMapLatest to reconnect
3. debounce to stabilize
4. Exception handling for failures
**Example:** Reconnect relays on connectivity
```kotlin
connectivityFlow
.flatMapLatest { status ->
when (status) {
Active -> relayPool.observeEvents()
else -> emptyFlow()
}
}
.catch { e -> Log.e("Error", e) }
.collect { event -> handleEvent(event) }
```
### Scenario: Optimize Multi-Collector Performance
**Steps:**
1. Use shareIn for expensive upstream
2. Configure SharingStarted strategy
3. Set replay buffer size
4. Test with multiple collectors
**Example:** Share relay subscription
```kotlin
val events: SharedFlow<Event> = client
.reqAsFlow(relay, filters)
.flatMapConcat { it.asFlow() }
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
replay = 0
)
```
## Anti-Patterns
**Using GlobalScope**
```kotlin
GlobalScope.launch { /* Leaks, no structured concurrency */ }
```
**Use scoped coroutines**
```kotlin
viewModelScope.launch { /* Cancelled with ViewModel */ }
```
---
**Forgetting awaitClose**
```kotlin
callbackFlow {
registerCallback()
// Missing cleanup!
}
```
**Always cleanup**
```kotlin
callbackFlow {
registerCallback()
awaitClose { unregisterCallback() }
}
```
---
**Blocking in Flow**
```kotlin
flow.map { Thread.sleep(1000); process(it) }
```
**Suspend, don't block**
```kotlin
flow.map { delay(1000); process(it) }.flowOn(Dispatchers.Default)
```
---
**Ignoring backpressure**
```kotlin
fastProducer.collect { slowConsumer(it) } // Blocks producer!
```
**Handle backpressure**
```kotlin
fastProducer
.buffer(64, BufferOverflow.DROP_OLDEST)
.collect { slowConsumer(it) }
```
## Delegation
**Use kotlin-expert for:**
- Basic StateFlow/SharedFlow patterns
- viewModelScope.launch usage
- Simple MutableStateFlow → asStateFlow()
**Use nostr-expert for:**
- Nostr protocol details (NIPs, event structure)
- Event creation and signing
- Cryptographic operations
**This skill provides:**
- Advanced async patterns
- Structured concurrency
- Complex Flow operators
- Testing strategies
- Relay-specific async patterns
## Resources
- **references/advanced-flow-operators.md** - All Flow operators with examples
- **references/relay-patterns.md** - Nostr relay async patterns from codebase
- **references/testing-coroutines.md** - Complete testing guide
## Quick Decision Tree
```
Need async operation?
├─ Simple ViewModel state update → kotlin-expert (StateFlow)
├─ Android callback → This skill (callbackFlow)
├─ Multiple concurrent operations → This skill (supervisorScope)
├─ Complex Flow transformation → This skill (references/advanced-flow-operators.md)
├─ Relay subscription → This skill (references/relay-patterns.md)
└─ Testing async code → This skill (references/testing-coroutines.md)
```

View File

@@ -0,0 +1,309 @@
# Advanced Flow Operators
Comprehensive guide to Flow operators for complex async patterns in Amethyst.
## Transformation Operators
### flatMapLatest - Cancel Previous, Switch to New
**Use when:** Latest value matters, previous operations should cancel
```kotlin
// User types in search box → cancel previous search
searchQuery
.flatMapLatest { query ->
repository.search(query) // Cancels previous search
}
.collect { results -> updateUI(results) }
```
**Amethyst pattern:**
```kotlin
// Switch relays based on latest account
accountFlow
.flatMapLatest { account ->
relayPool.observeEvents(account.relays)
}
```
### flatMapConcat - Sequential Processing
**Use when:** Order matters, process one at a time
```kotlin
eventIds
.flatMapConcat { id ->
repository.fetchEvent(id)
}
.collect { event -> process(event) }
```
### flatMapMerge - Concurrent Processing
**Use when:** Process multiple simultaneously, order doesn't matter
```kotlin
relays
.flatMapMerge(concurrency = 10) { relay ->
relay.subscribe(filters)
}
.collect { event -> handleEvent(event) }
```
## Combination Operators
### combine - Latest from Multiple Flows
**Use when:** Need latest value from ALL flows
```kotlin
combine(
accountFlow,
settingsFlow,
connectivityFlow
) { account, settings, connectivity ->
AppState(account, settings, connectivity)
}.collect { state -> render(state) }
```
**Pattern:** Re-emits whenever ANY source emits
### zip - Pair Values in Order
**Use when:** Need corresponding values from flows
```kotlin
zip(requestFlow, responseFlow) { req, res ->
Pair(req, res)
}
```
**Pattern:** Waits for BOTH to emit before pairing
### merge - Combine Multiple Flows
**Use when:** Treat multiple flows as single stream
```kotlin
merge(
relay1.events,
relay2.events,
relay3.events
).collect { event -> handleEvent(event) }
```
## Backpressure & Buffering
### shareIn - Hot Flow from Cold
**Use when:** Multiple collectors should share single upstream
```kotlin
val sharedEvents = repository.observeEvents()
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
replay = 0
)
// Multiple collectors share same upstream
sharedEvents.collect { /* collector 1 */ }
sharedEvents.collect { /* collector 2 */ }
```
**SharingStarted strategies:**
- `Eagerly` - Start immediately, never stop
- `Lazily` - Start on first subscriber, never stop
- `WhileSubscribed(stopTimeout)` - Stop after last unsubscribe + timeout
### stateIn - StateFlow from Cold Flow
**Use when:** Convert Flow to StateFlow (always has value)
```kotlin
val uiState: StateFlow<UiState> = repository.observeData()
.map { data -> UiState.Success(data) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UiState.Loading
)
```
**Amethyst pattern:**
```kotlin
// Connectivity status as StateFlow
val connectivity: StateFlow<ConnectivityStatus> =
connectivityFlow.status
.stateIn(
scope = serviceScope,
started = SharingStarted.Eagerly,
initialValue = ConnectivityStatus.Off
)
```
### buffer - Control Backpressure
**Use when:** Producer faster than consumer
```kotlin
eventFlow
.buffer(capacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.collect { event -> slowProcessor(event) }
```
**Strategies:**
- `SUSPEND` - Slow down producer (default)
- `DROP_OLDEST` - Drop oldest in buffer
- `DROP_LATEST` - Drop newest emission
### conflate - Keep Only Latest
**Use when:** Only latest value matters, skip intermediate
```kotlin
locationFlow
.conflate() // Skip intermediate locations
.collect { location -> updateMap(location) }
```
## Debouncing & Throttling
### debounce - Wait for Quiet Period
**Use when:** Wait for user to stop typing
```kotlin
searchQuery
.debounce(300) // Wait 300ms after last emission
.flatMapLatest { query -> search(query) }
```
**Amethyst pattern:**
```kotlin
// ConnectivityFlow.kt:87
connectivityFlow
.distinctUntilChanged()
.debounce(200) // Wait 200ms for network to stabilize
.flowOn(Dispatchers.IO)
```
### sample - Periodic Sampling
**Use when:** Rate-limit high-frequency emissions
```kotlin
sensorData
.sample(1000) // Sample every 1 second
.collect { data -> process(data) }
```
## Error Handling
### catch - Handle Upstream Errors
**Use when:** Graceful degradation needed
```kotlin
repository.fetchData()
.catch { e ->
Log.e("Error", e)
emit(emptyList()) // Fallback value
}
.collect { data -> updateUI(data) }
```
**Pattern:** Only catches UPSTREAM errors, not in collect block
### retry/retryWhen - Automatic Retry
```kotlin
relayConnection
.retry(3) { cause ->
cause is IOException // Only retry on network errors
}
```
## Context Switching
### flowOn - Change Upstream Dispatcher
**Use when:** Offload work from current context
```kotlin
repository.fetchData()
.map { heavyProcessing(it) }
.flowOn(Dispatchers.Default) // Heavy work on Default
.collect { updateUI(it) } // Collect on Main
```
**Critical:** Only affects UPSTREAM operators
**Amethyst pattern:**
```kotlin
// ConnectivityFlow.kt:87
callbackFlow { /* ... */ }
.distinctUntilChanged()
.debounce(200)
.flowOn(Dispatchers.IO) // All upstream on IO
```
## Common Patterns
### Pattern: Multi-Relay Subscription
```kotlin
fun observeFromMultipleRelays(relays: List<Relay>, filters: List<Filter>): Flow<Event> =
relays.map { relay ->
relay.subscribe(filters)
}.merge()
.distinctBy { it.id }
```
### Pattern: Load + Cache + Observe
```kotlin
fun observeWithCache(id: String): Flow<Data> = flow {
// Emit cached value immediately
cache[id]?.let { emit(it) }
// Then observe updates
emitAll(repository.observe(id))
}.distinctUntilChanged()
```
### Pattern: Retry with Exponential Backoff
```kotlin
fun <T> Flow<T>.retryWithBackoff(
maxRetries: Int = 3,
initialDelay: Long = 1000
): Flow<T> = retryWhen { cause, attempt ->
if (attempt >= maxRetries || cause !is IOException) {
false
} else {
delay(initialDelay * (1L shl attempt.toInt()))
true
}
}
```
## Performance Tips
1. **Use shareIn for expensive operations**
- Compute once, share with multiple collectors
2. **Choose right backpressure strategy**
- UI updates: `conflate()` or `DROP_OLDEST`
- Events: `buffer()` with appropriate size
3. **flowOn placement matters**
- Place after expensive operators to offload them
4. **Avoid unnecessary emissions**
- Use `distinctUntilChanged()` when appropriate
- Consider `debounce()` for high-frequency sources
5. **StateFlow vs SharedFlow**
- StateFlow: Always has value, conflates
- SharedFlow: Optional replay, configurable buffering

View File

@@ -0,0 +1,480 @@
# Nostr Relay Async Patterns
Proven coroutine patterns for Nostr relay connections, subscriptions, and event streaming in Amethyst.
## Core Pattern: callbackFlow for Relay Subscriptions
### Pattern: Subscription as Flow
**Real implementation from NostrClientStaticReqAsStateFlow.kt:**
```kotlin
fun INostrClient.reqAsFlow(
relay: NormalizedRelayUrl,
filters: List<Filter>,
): Flow<List<Event>> =
callbackFlow {
val subId = RandomInstance.randomChars(10)
var hasBeenLive = false
val eventIds = mutableSetOf<HexKey>()
var currentEvents = listOf<Event>()
val listener = object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event.id !in eventIds) {
if (hasBeenLive) {
// After EOSE: prepend new events
val list = ArrayList<Event>(1 + currentEvents.size)
list.add(event)
list.addAll(currentEvents)
currentEvents = list
} else {
// Before EOSE: append events
currentEvents = currentEvents + event
}
eventIds.add(event.id)
trySend(currentEvents)
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
hasBeenLive = true
}
}
openReqSubscription(subId, mapOf(relay to filters), listener)
awaitClose {
close(subId)
}
}
```
**Key techniques:**
1. **callbackFlow** - Bridge callback API to Flow
2. **Deduplication** - `eventIds` set prevents duplicates
3. **EOSE handling** - Changes insertion strategy (append → prepend)
4. **awaitClose** - Cleanup when flow cancelled
5. **trySend** - Non-blocking emission from callback
## Multi-Relay Patterns
### Pattern: Merge Events from Multiple Relays
```kotlin
fun observeFromRelays(
relays: List<NormalizedRelayUrl>,
filters: List<Filter>
): Flow<Event> =
relays.map { relay ->
client.reqAsFlow(relay, filters)
.flatMapConcat { it.asFlow() }
}.merge()
.distinctBy { it.id }
```
**Explanation:**
- Each relay produces `Flow<List<Event>>`
- `flatMapConcat` flattens to `Flow<Event>`
- `merge()` combines all relay flows
- `distinctBy` deduplicates across relays
### Pattern: Concurrent Relay Operations with supervisorScope
```kotlin
suspend fun subscribeToRelays(
relays: List<Relay>,
filters: List<Filter>
) = supervisorScope {
relays.forEach { relay ->
launch {
relay.subscribe(filters).collect { event ->
eventChannel.send(event)
}
}
}
}
```
**Why supervisorScope:**
- If one relay fails, others continue
- All children cancelled when scope cancelled
- Structured concurrency maintained
## Backpressure Handling
### Pattern: Buffer with Drop Strategy
**For high-frequency event streams:**
```kotlin
relayFlow
.buffer(
capacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
.collect { event -> processEvent(event) }
```
**Strategy selection:**
- `DROP_OLDEST` - For real-time feeds (lose old events OK)
- `DROP_LATEST` - For priority queues (lose new events OK)
- `SUSPEND` - For critical events (slow down producer)
### Pattern: Conflate for UI Updates
```kotlin
val uiEvents: Flow<UiEvent> = relayEvents
.map { event -> toUiEvent(event) }
.conflate() // Skip intermediate, show latest
.flowOn(Dispatchers.Default)
```
## Connection Management
### Pattern: Network Connectivity as Flow
**Real implementation from ConnectivityFlow.kt:**
```kotlin
@OptIn(FlowPreview::class)
val status = callbackFlow {
trySend(ConnectivityStatus.StartingService)
val connectivityManager = context.getConnectivityManager()
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
connectivityManager.getNetworkCapabilities(network)?.let {
trySend(ConnectivityStatus.Active(
network.networkHandle,
it.isMeteredOrMobileData()
))
}
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
val isMobile = networkCapabilities.isMeteredOrMobileData()
trySend(ConnectivityStatus.Active(
network.networkHandle,
isMobile
))
}
override fun onLost(network: Network) {
trySend(ConnectivityStatus.Off)
}
}
connectivityManager.registerDefaultNetworkCallback(networkCallback)
// Send initial state
connectivityManager.activeNetwork?.let { network ->
connectivityManager.getNetworkCapabilities(network)?.let {
trySend(ConnectivityStatus.Active(
network.networkHandle,
it.isMeteredOrMobileData()
))
}
}
awaitClose {
connectivityManager.unregisterNetworkCallback(networkCallback)
trySend(ConnectivityStatus.Off)
}
}
.distinctUntilChanged()
.debounce(200) // Stabilize rapid changes
.flowOn(Dispatchers.IO)
```
**Key patterns:**
1. **Initial state** - Emit current connectivity immediately
2. **Callback registration** - Register listener in flow body
3. **Cleanup** - Unregister in `awaitClose`
4. **Stabilization** - `debounce(200)` prevents flapping
5. **Deduplication** - `distinctUntilChanged()` skips redundant updates
### Pattern: Reconnect on Connectivity Change
```kotlin
connectivityFlow
.flatMapLatest { status ->
when (status) {
is ConnectivityStatus.Active -> {
relayPool.connectAll()
relayPool.observeEvents()
}
else -> emptyFlow()
}
}
.collect { event -> handleEvent(event) }
```
## Exception Handling in Async Operations
### Pattern: CoroutineExceptionHandler + SupervisorJob
**Real implementation from PushNotificationReceiverService.kt:**
```kotlin
class PushNotificationReceiverService : FirebaseMessagingService() {
// Catch all uncaught exceptions
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable)
}
// Children fail independently, handler catches all
private val scope = CoroutineScope(
Dispatchers.IO + SupervisorJob() + exceptionHandler
)
override fun onMessageReceived(remoteMessage: RemoteMessage) {
scope.launch(Dispatchers.IO) {
parseMessage(remoteMessage.data)?.let { receiveIfNew(it) }
}
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
}
```
**Why this pattern:**
- **SupervisorJob** - One failure doesn't cancel others
- **ExceptionHandler** - Log exceptions, don't crash
- **Scoped lifecycle** - Cancel all on destroy
### Pattern: Retry with Backoff for Relay Connections
```kotlin
fun connectWithRetry(relay: Relay): Flow<ConnectionStatus> = flow {
var attempt = 0
val maxRetries = 5
val baseDelay = 1000L
while (attempt < maxRetries) {
try {
emit(ConnectionStatus.Connecting)
relay.connect()
emit(ConnectionStatus.Connected)
return@flow
} catch (e: Exception) {
attempt++
emit(ConnectionStatus.Error(e, attempt))
if (attempt < maxRetries) {
val delay = baseDelay * (1L shl attempt) // Exponential backoff
delay(delay)
}
}
}
emit(ConnectionStatus.Failed)
}
```
## Subscription Lifecycle
### Pattern: Auto-Cleanup Subscription
```kotlin
@Composable
fun ObserveRelayEvents(
filters: List<Filter>,
onEvent: (Event) -> Unit
) {
val scope = rememberCoroutineScope()
DisposableEffect(filters) {
val job = scope.launch {
relayClient.reqAsFlow(filters).collect { events ->
events.forEach { onEvent(it) }
}
}
onDispose {
job.cancel() // Cancels flow, triggers awaitClose
}
}
}
```
**Lifecycle:**
1. Composable enters → subscribe
2. filters change → cancel + re-subscribe
3. Composable leaves → cancel + cleanup
### Pattern: Multiple Concurrent Subscriptions
```kotlin
fun observeMultipleFeeds(
account: Account
): Flow<Event> = channelFlow {
supervisorScope {
// Home feed
launch {
client.reqAsFlow(filters = homeFeedFilters)
.collect { events -> events.forEach { send(it) } }
}
// Notifications
launch {
client.reqAsFlow(filters = notificationFilters)
.collect { events -> events.forEach { send(it) } }
}
// DMs
launch {
client.reqAsFlow(filters = dmFilters)
.collect { events -> events.forEach { send(it) } }
}
}
}
```
**Benefits:**
- All subscriptions run concurrently
- One failure doesn't affect others (supervisorScope)
- Single output channel for all events
## Performance Optimization
### Pattern: Shared Upstream for Multiple Collectors
```kotlin
class RelayViewModel(private val client: INostrClient) : ViewModel() {
val events: SharedFlow<Event> = client
.reqAsFlow(relay, filters)
.flatMapConcat { it.asFlow() }
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
replay = 0
)
}
// Multiple collectors share single relay subscription
events.collect { /* UI 1 */ }
events.collect { /* UI 2 */ }
```
### Pattern: Event Deduplication Cache
```kotlin
class EventCache {
private val seen = mutableSetOf<HexKey>()
fun filterNew(events: List<Event>): List<Event> =
events.filter { event ->
if (event.id in seen) {
false
} else {
seen.add(event.id)
true
}
}
}
val deduplicatedEvents = relayEvents
.map { events -> cache.filterNew(events) }
.filter { it.isNotEmpty() }
```
## Testing Relay Flows
### Pattern: Test with Fake Relay
```kotlin
@Test
fun `subscription receives events`() = runTest {
val fakeRelay = FakeRelay()
val client = NostrClient(fakeRelay)
val events = mutableListOf<Event>()
val job = launch {
client.reqAsFlow(relay, filters).collect { list ->
events.addAll(list)
}
}
// Simulate relay responses
fakeRelay.sendEvent(testEvent1)
advanceTimeBy(100)
fakeRelay.sendEvent(testEvent2)
advanceTimeBy(100)
assertEquals(2, events.size)
job.cancel()
}
```
## Common Pitfalls
### ❌ Forgetting awaitClose
```kotlin
// BAD: Subscription never cleaned up
callbackFlow {
relay.subscribe(listener)
// Missing awaitClose!
}
```
```kotlin
// GOOD: Proper cleanup
callbackFlow {
relay.subscribe(listener)
awaitClose {
relay.unsubscribe(listener)
}
}
```
### ❌ Using GlobalScope
```kotlin
// BAD: Unstructured, leaks
GlobalScope.launch {
relay.connect()
}
```
```kotlin
// GOOD: Scoped to lifecycle
viewModelScope.launch {
relay.connect()
}
```
### ❌ Blocking in Flow Operators
```kotlin
// BAD: Blocks collector
flow.map { event ->
Thread.sleep(1000) // Blocks!
process(event)
}
```
```kotlin
// GOOD: Use flowOn to offload
flow
.map { event ->
delay(1000) // Suspends, doesn't block
process(event)
}
.flowOn(Dispatchers.Default)
```

View File

@@ -0,0 +1,493 @@
# Testing Coroutines
Comprehensive guide for testing async code with runTest, Turbine, and best practices.
## runTest - Standard Testing
### Basic Pattern
```kotlin
@Test
fun `test suspend function`() = runTest {
val result = repository.fetchData()
assertEquals(expected, result)
}
```
**What runTest does:**
- Skips delays automatically
- Provides TestScope
- Advances virtual time
- Waits for all coroutines to complete
### Testing StateFlow
```kotlin
@Test
fun `stateflow updates correctly`() = runTest {
val viewModel = MyViewModel()
// Initial state
assertEquals(UiState.Loading, viewModel.state.value)
// Trigger action
viewModel.loadData()
advanceUntilIdle() // Run all pending coroutines
// Verify final state
assertEquals(UiState.Success(data), viewModel.state.value)
}
```
### Testing with Time Control
```kotlin
@Test
fun `debounce works correctly`() = runTest {
val viewModel = SearchViewModel()
viewModel.search("a")
advanceTimeBy(100) // 100ms passed
viewModel.search("ab")
advanceTimeBy(100)
viewModel.search("abc")
advanceTimeBy(300) // Debounce completes
// Only "abc" should have triggered search
assertEquals(listOf("abc"), viewModel.searchQueries)
}
```
**Time control functions:**
- `advanceTimeBy(millis)` - Move virtual time forward
- `advanceUntilIdle()` - Run all pending work
- `runCurrent()` - Run currently scheduled tasks only
## Turbine - Flow Testing Library
### Basic Collection Testing
```kotlin
@Test
fun `flow emits expected values`() = runTest {
repository.observeData().test {
assertEquals(Item1, awaitItem())
assertEquals(Item2, awaitItem())
assertEquals(Item3, awaitItem())
awaitComplete()
}
}
```
### Testing Flow Transformations
```kotlin
@Test
fun `map transforms correctly`() = runTest {
val source = flowOf(1, 2, 3)
source
.map { it * 2 }
.test {
assertEquals(2, awaitItem())
assertEquals(4, awaitItem())
assertEquals(6, awaitItem())
awaitComplete()
}
}
```
### Testing Relay Subscriptions
```kotlin
@Test
fun `relay subscription receives events`() = runTest {
val fakeClient = FakeNostrClient()
fakeClient.reqAsFlow(relay, filters).test {
// Initially empty
assertEquals(emptyList(), awaitItem())
// Send event
fakeClient.sendEvent(event1)
assertEquals(listOf(event1), awaitItem())
// Send another
fakeClient.sendEvent(event2)
assertEquals(listOf(event1, event2), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
```
### Testing Error Handling
```kotlin
@Test
fun `catch handles errors gracefully`() = runTest {
val errorFlow = flow {
emit(1)
throw IOException("Network error")
}.catch { emit(-1) } // Fallback value
errorFlow.test {
assertEquals(1, awaitItem())
assertEquals(-1, awaitItem())
awaitComplete()
}
}
```
### Testing StateFlow with Turbine
```kotlin
@Test
fun `stateflow emits updates`() = runTest {
val viewModel = MyViewModel()
viewModel.state.test {
// Skip initial value
assertEquals(UiState.Loading, awaitItem())
// Trigger update
viewModel.loadData()
assertEquals(UiState.Success(data), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
```
**Turbine assertions:**
- `awaitItem()` - Get next emission or fail
- `awaitComplete()` - Verify flow completed
- `awaitError()` - Verify flow threw exception
- `expectNoEvents()` - Assert no emissions in timeframe
- `cancelAndIgnoreRemainingEvents()` - Stop test
## Testing Patterns for Amethyst
### Pattern: Test Relay Connection Flow
```kotlin
@Test
fun `reconnects on connectivity change`() = runTest {
val connectivityFlow = MutableStateFlow(ConnectivityStatus.Off)
val relayPool = FakeRelayPool()
connectivityFlow
.flatMapLatest { status ->
when (status) {
is ConnectivityStatus.Active -> relayPool.connectAll()
else -> emptyFlow()
}
}
.test {
// Initially offline
expectNoEvents()
// Go online
connectivityFlow.value = ConnectivityStatus.Active(1L, false)
assertTrue(relayPool.connected)
cancelAndIgnoreRemainingEvents()
}
}
```
### Pattern: Test Event Deduplication
```kotlin
@Test
fun `deduplicates events across relays`() = runTest {
val relay1 = FakeRelay()
val relay2 = FakeRelay()
merge(relay1.events, relay2.events)
.distinctBy { it.id }
.test {
// Both relays send same event
relay1.send(event1)
relay2.send(event1)
// Only one emission
assertEquals(event1, awaitItem())
expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}
```
### Pattern: Test Backpressure Handling
```kotlin
@Test
fun `drops oldest events when buffer full`() = runTest {
val fastProducer = flow {
repeat(100) { emit(it) }
}
fastProducer
.buffer(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.test {
// Slow consumer
delay(100)
// Should have dropped oldest, kept newest
val items = mutableListOf<Int>()
repeat(10) {
items.add(awaitItem())
}
// Newest items present
assertTrue(90 in items)
assertTrue(99 in items)
awaitComplete()
}
}
```
### Pattern: Test Concurrent Subscriptions
```kotlin
@Test
fun `multiple subscriptions run concurrently`() = runTest {
val client = FakeNostrClient()
val feed1 = async { client.reqAsFlow(relay1, filters1).first() }
val feed2 = async { client.reqAsFlow(relay2, filters2).first() }
client.sendTo(relay1, event1)
client.sendTo(relay2, event2)
assertEquals(listOf(event1), feed1.await())
assertEquals(listOf(event2), feed2.await())
}
```
## Fakes and Mocks
### Fake NostrClient
```kotlin
class FakeNostrClient : INostrClient {
private val subscriptions = mutableMapOf<String, MutableSharedFlow<Event>>()
override fun reqAsFlow(
relay: NormalizedRelayUrl,
filters: List<Filter>
): Flow<List<Event>> = callbackFlow {
val subId = RandomInstance.randomChars(10)
val flow = MutableSharedFlow<Event>()
subscriptions[subId] = flow
val events = mutableListOf<Event>()
flow.collect { event ->
events.add(event)
send(events.toList())
}
awaitClose {
subscriptions.remove(subId)
}
}
fun sendEvent(event: Event) {
subscriptions.values.forEach { it.tryEmit(event) }
}
fun sendTo(relay: NormalizedRelayUrl, event: Event) {
subscriptions[relay.url]?.tryEmit(event)
}
}
```
### Fake Relay Pool
```kotlin
class FakeRelayPool {
var connected = false
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events.asSharedFlow()
fun connectAll(): Flow<Unit> = flow {
connected = true
emit(Unit)
}
fun disconnect() {
connected = false
}
suspend fun sendEvent(event: Event) {
_events.emit(event)
}
}
```
## Testing Exception Handling
### Test CoroutineExceptionHandler
```kotlin
@Test
fun `exception handler catches errors`() = runTest {
val errors = mutableListOf<Throwable>()
val handler = CoroutineExceptionHandler { _, throwable ->
errors.add(throwable)
}
val scope = CoroutineScope(
Dispatchers.Unconfined + SupervisorJob() + handler
)
scope.launch {
throw IOException("Test error")
}
advanceUntilIdle()
assertEquals(1, errors.size)
assertTrue(errors[0] is IOException)
}
```
### Test Retry Logic
```kotlin
@Test
fun `retries failed connections`() = runTest {
var attempts = 0
val maxRetries = 3
flow {
attempts++
if (attempts < maxRetries) {
throw IOException("Connection failed")
}
emit("Success")
}
.retry(maxRetries)
.test {
assertEquals("Success", awaitItem())
awaitComplete()
assertEquals(3, attempts)
}
}
```
## Common Testing Patterns
### Pattern: Verify No Emissions After Cancellation
```kotlin
@Test
fun `no emissions after cancellation`() = runTest {
val flow = flow {
emit(1)
delay(1000)
emit(2) // Should not emit
}
flow.test {
assertEquals(1, awaitItem())
cancel()
// Verify no more emissions
expectNoEvents()
}
}
```
### Pattern: Test Time-Based Operations
```kotlin
@Test
fun `periodic emission works`() = runTest {
flow {
repeat(3) {
emit(it)
delay(1000)
}
}.test {
assertEquals(0, awaitItem())
advanceTimeBy(1000)
assertEquals(1, awaitItem())
advanceTimeBy(1000)
assertEquals(2, awaitItem())
awaitComplete()
}
}
```
### Pattern: Test Hot Flow Conversion
```kotlin
@Test
fun `shareIn creates hot flow`() = runTest {
var emissions = 0
val source = flow {
repeat(3) {
emissions++
emit(it)
}
}
val shared = source.shareIn(
scope = this,
started = SharingStarted.Eagerly,
replay = 1
)
// First collector
shared.take(2).collect()
assertEquals(2, emissions) // Emitted 0, 1
// Second collector - shares upstream
shared.take(1).collect()
assertEquals(3, emissions) // Only emitted 2, not restarted
cancel()
}
```
## Best Practices
1. **Use runTest for all coroutine tests**
- Provides virtual time
- Automatic cleanup
2. **Use Turbine for Flow testing**
- Clearer assertions
- Better error messages
3. **Test both success and error paths**
- Normal flow
- Exception handling
- Edge cases
4. **Control virtual time explicitly**
- Don't rely on real delays
- Use `advanceTimeBy()` and `advanceUntilIdle()`
5. **Create fakes, not mocks**
- Simpler to maintain
- More realistic behavior
- Easier to debug
6. **Test cancellation behavior**
- Verify cleanup happens
- Check no emissions after cancel
7. **Test concurrent operations**
- Use `async` to spawn concurrent work
- Verify independence with SupervisorJob

View File

@@ -0,0 +1,811 @@
---
name: kotlin-expert
description: Advanced Kotlin patterns for AmethystMultiplatform. Flow state management (StateFlow/SharedFlow), sealed hierarchies (classes vs interfaces), immutability (@Immutable, data classes), DSL builders (type-safe fluent APIs), inline functions (reified generics, performance). Use when working with: (1) State management patterns (StateFlow/SharedFlow/MutableStateFlow), (2) Sealed classes or sealed interfaces, (3) @Immutable annotations for Compose, (4) DSL builders with lambda receivers, (5) inline/reified functions, (6) Kotlin performance optimization. Complements kotlin-coroutines agent (async patterns) - this skill focuses on Amethyst-specific Kotlin idioms.
---
# Kotlin Expert
Advanced Kotlin patterns for AmethystMultiplatform. Covers Flow state management, sealed hierarchies, immutability, DSL builders, and inline functions with real codebase examples.
## Mental Model
**Kotlin in Amethyst:**
```
State Management (Hot Flows)
├── StateFlow<T> # Single value, always has value, replays to new subscribers
├── SharedFlow<T> # Event stream, configurable replay, multiple subscribers
└── MutableStateFlow<T> # Private mutable, public via .asStateFlow()
Type Safety (Sealed Hierarchies)
├── sealed class # State variants with data (AccountState.LoggedIn/LoggedOut)
└── sealed interface # Generic result types (SignerResult<T>)
Compose Performance (@Immutable)
├── @Immutable # 173+ event classes - prevents recomposition
└── data class # Structural equality, copy(), immutable by convention
DSL Patterns
├── Builder classes # Fluent APIs (TagArrayBuilder)
├── Lambda receivers # inline fun tagArray { ... }
└── Method chaining # return this
Performance
├── inline fun # Eliminate lambda overhead
├── reified type params # Runtime type info (OptimizedJsonMapper)
└── value class # Zero-cost wrappers (NOT USED yet in Amethyst)
```
**Delegation:**
- **kotlin-coroutines agent**: Deep async (structured concurrency, channels, operators)
- **kotlin-multiplatform skill**: expect/actual, source sets
- **This skill**: Amethyst Kotlin idioms, state patterns, type safety
---
## 1. Flow State Management
### StateFlow: State that Changes
**Mental model:** StateFlow is a "hot" observable state holder. Always has a value, new collectors immediately get current state.
**Amethyst pattern:**
```kotlin
// AccountManager.kt:48-50
class AccountManager {
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
fun login(key: String) {
_accountState.value = AccountState.LoggedIn(...)
}
}
```
**Key principles:**
1. **Private mutable, public immutable**: `_accountState` (MutableStateFlow) private, `accountState` (StateFlow) public
2. **Always has value**: Initial value required (`LoggedOut`)
3. **Single value**: Replays ONE most recent value to new subscribers
4. **Hot**: Stays in memory, all collectors share same instance
**See:** AccountManager.kt:48-50, RelayConnectionManager.kt:49-52
### SharedFlow: Event Streams
**Mental model:** SharedFlow is a "hot" broadcast stream for events. Configurable replay buffer, doesn't require initial value.
**Amethyst pattern:**
```kotlin
// RelayConnectionManager.kt:52-53
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = client.connectedRelaysFlow()
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = client.availableRelaysFlow()
```
**When to use StateFlow vs SharedFlow:**
| Scenario | Use StateFlow | Use SharedFlow |
|----------|---------------|----------------|
| **UI state** | ✅ Current screen data, login status | ❌ |
| **One-time events** | ❌ | ✅ Navigation, snackbars, toasts |
| **Always has value** | ✅ | ❌ Optional |
| **Replay count** | 1 (latest only) | Configurable (0, 1, n) |
| **Backpressure** | Conflates (drops old) | Configurable buffer |
**Best practice:**
```kotlin
// State: Use StateFlow
private val _uiState = MutableStateFlow(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
// Events: Use SharedFlow
private val _navigationEvents = MutableSharedFlow<NavEvent>(replay = 0)
val navigationEvents: SharedFlow<NavEvent> = _navigationEvents.asSharedFlow()
```
### Flow Anti-Patterns
**Exposing mutable state:**
```kotlin
val accountState: MutableStateFlow<AccountState> // BAD: Can be mutated externally
```
**Expose immutable:**
```kotlin
val accountState: StateFlow<AccountState> = _accountState.asStateFlow() // GOOD
```
---
**SharedFlow for state:**
```kotlin
val loginState = MutableSharedFlow<LoginState>() // BAD: State might get lost
```
**StateFlow for state:**
```kotlin
val loginState = MutableStateFlow(LoginState.LoggedOut) // GOOD: Always has value
```
**See:** `references/flow-patterns.md` for comprehensive examples.
---
## 2. Sealed Hierarchies
### Sealed Classes: State Variants
**Mental model:** Sealed classes represent a closed set of variants that share common data/behavior.
**Amethyst pattern:**
```kotlin
// AccountManager.kt:36-46
sealed class AccountState {
data object LoggedOut : AccountState()
data class LoggedIn(
val signer: NostrSigner,
val pubKeyHex: String,
val npub: String,
val nsec: String?,
val isReadOnly: Boolean
) : AccountState()
}
// Usage
when (state) {
is AccountState.LoggedOut -> showLogin()
is AccountState.LoggedIn -> showFeed(state.pubKeyHex)
} // Exhaustive - compiler enforces all cases
```
**Key principles:**
1. **Closed hierarchy**: All subclasses known at compile-time
2. **Exhaustive when**: Compiler ensures all cases handled
3. **Shared data**: Sealed class can hold common properties
4. **Single inheritance**: Subclass can't extend another class
**When to use:**
- Modeling UI states (Loading, Success, Error)
- Login states (LoggedOut, LoggedIn)
- Result types with different data per variant
### Sealed Interfaces: Generic Result Types
**Mental model:** Sealed interfaces for contracts with multiple implementations that need generics or multiple inheritance.
**Amethyst pattern:**
```kotlin
// SignerResult.kt:25-46
sealed interface SignerResult<T : IResult> {
sealed interface RequestAddressed<T : IResult> : SignerResult<T> {
class Successful<T : IResult>(val result: T) : RequestAddressed<T>
class Rejected<T : IResult> : RequestAddressed<T>
class TimedOut<T : IResult> : RequestAddressed<T>
class ReceivedButCouldNotPerform<T : IResult>(
val message: String?
) : RequestAddressed<T>
}
}
// Usage with generics
fun handleResult(result: SignerResult<SignResult>) {
when (result) {
is SignerResult.RequestAddressed.Successful -> processEvent(result.result.event)
is SignerResult.RequestAddressed.Rejected -> showRejected()
is SignerResult.RequestAddressed.TimedOut -> showTimeout()
}
}
```
**Key principles:**
1. **Multiple inheritance**: Subtype can implement other interfaces
2. **Variance**: Supports `out`/`in` modifiers for generics
3. **No constructor**: Can't hold state directly (subtypes can)
4. **Nested hierarchies**: Can create sub-sealed hierarchies
### Sealed Class vs Sealed Interface
| Feature | Sealed Class | Sealed Interface |
|---------|--------------|------------------|
| **Constructor** | ✅ Can hold common state | ❌ No constructor |
| **Inheritance** | ❌ Single parent only | ✅ Multiple interfaces |
| **Generics** | ❌ No variance | ✅ Covariance/contravariance |
| **Use case** | State variants | Result types, contracts |
**Decision tree:**
```
Need to hold common data in base?
YES → sealed class
NO → sealed interface
Need generics with variance (out/in)?
YES → sealed interface
NO → Either works
Subtypes need multiple inheritance?
YES → sealed interface
NO → Either works
```
**Amethyst examples:**
- `sealed class AccountState` - state variants with different data
- `sealed interface SignerResult<T>` - generic result types with variance
**See:** `references/sealed-class-catalog.md` for all sealed types in quartz.
---
## 3. Immutability & Compose Performance
### @Immutable Annotation
**Mental model:** @Immutable tells Compose "this value never changes after construction." Compose can skip recomposition if @Immutable object reference doesn't change.
**Amethyst pattern:**
```kotlin
// TextNoteEvent.kt:51-63
@Immutable
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey
) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
// All properties immutable (val), no mutable state
}
```
**Key principles:**
1. **All properties immutable**: Only `val`, never `var`
2. **No mutable collections**: Use `ImmutableList`, `Array`, not `MutableList`
3. **Deep immutability**: Nested objects also immutable
4. **Compose optimization**: Skips recomposition if reference equals
**Why it matters:**
```kotlin
// Without @Immutable
@Composable
fun NoteCard(note: TextNoteEvent) { // Recomposes every time parent recomposes
Text(note.content)
}
// With @Immutable
@Composable
fun NoteCard(note: TextNoteEvent) { // Only recomposes if note reference changes
Text(note.content)
}
```
**173+ @Immutable classes** in quartz - all events immutable for Compose performance.
### Data Classes & Immutability
**Pattern:**
```kotlin
@Immutable
data class RelayStatus(
val url: NormalizedRelayUrl,
val connected: Boolean,
val error: String? = null
) {
// Implicit: equals(), hashCode(), copy(), toString()
}
// Usage
val oldStatus = RelayStatus(url, connected = false)
val newStatus = oldStatus.copy(connected = true) // Immutable update
```
**Key principles:**
1. **Structural equality**: `equals()` compares properties, not reference
2. **copy()**: Create modified copies without mutating
3. **All properties in constructor**: For proper `equals()`/`hashCode()`
4. **Prefer val**: Make properties immutable
### kotlinx.collections.immutable
**Pattern:**
```kotlin
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
// Instead of List (which could be mutable internally)
val relays: ImmutableList<String> = persistentListOf("wss://relay1.com", "wss://relay2.com")
// Add returns new instance
val updated = relays.add("wss://relay3.com") // relays unchanged, updated has 3 items
```
**When to use:**
- Compose state that needs collection
- Publicly exposed collections
- Shared state across threads
**See:** `references/immutability-patterns.md`
---
## 4. DSL Builders
### Type-Safe Fluent APIs
**Mental model:** DSL (Domain-Specific Language) builders use lambda receivers and method chaining to create readable, type-safe APIs.
**Amethyst pattern:**
```kotlin
// TagArrayBuilder.kt:23-90
class TagArrayBuilder<T : IEvent> {
private val tagList = mutableMapOf<String, MutableList<Tag>>()
fun add(tag: Array<String>): TagArrayBuilder<T> {
if (tag.isEmpty() || tag[0].isEmpty()) return this
tagList.getOrPut(tag[0], ::mutableListOf).add(tag)
return this // Method chaining
}
fun remove(tagName: String): TagArrayBuilder<T> {
tagList.remove(tagName)
return this // Method chaining
}
fun build() = tagList.flatMap { it.value }.toTypedArray()
}
// Inline function with lambda receiver (line 90)
inline fun <T : Event> tagArray(initializer: TagArrayBuilder<T>.() -> Unit = {}): TagArray =
TagArrayBuilder<T>().apply(initializer).build()
```
**Usage:**
```kotlin
val tags = tagArray<TextNoteEvent> {
add(arrayOf("e", eventId, relay, "reply"))
add(arrayOf("p", pubkey))
remove("a") // Remove address tags
}
```
**Key patterns:**
1. **Method chaining**: Return `this` from mutator methods
2. **Lambda receiver**: `TagArrayBuilder<T>.() -> Unit` - lambda has `this: TagArrayBuilder<T>`
3. **inline function**: Eliminates lambda overhead
4. **apply()**: Executes lambda with receiver, returns receiver
### DSL Pattern Template
```kotlin
class MyBuilder {
private val items = mutableListOf<Item>()
fun add(item: Item): MyBuilder {
items.add(item)
return this
}
fun build(): Result = Result(items.toList())
}
inline fun myDsl(init: MyBuilder.() -> Unit): Result =
MyBuilder().apply(init).build()
// Usage
val result = myDsl {
add(Item("foo"))
add(Item("bar"))
}
```
**Why inline?**
- Eliminates lambda object allocation
- Enables `reified` type parameters
- Better performance for frequently-called DSLs
**See:** `references/dsl-builder-examples.md` for more patterns.
---
## 5. Inline Functions & reified
### inline fun: Eliminate Overhead
**Mental model:** `inline` copies function body to call site. No lambda object created, direct code insertion.
**Pattern:**
```kotlin
// Without inline
fun <T> measureTime(block: () -> T): T {
val start = System.currentTimeMillis()
val result = block() // Lambda object allocated
println("Time: ${System.currentTimeMillis() - start}ms")
return result
}
// With inline
inline fun <T> measureTime(block: () -> T): T {
val start = System.currentTimeMillis()
val result = block() // No allocation, code inlined
println("Time: ${System.currentTimeMillis() - start}ms")
return result
}
```
**Benefits:**
1. **Zero overhead**: No lambda object allocation
2. **Non-local returns**: Can `return` from outer function inside lambda
3. **reified enabled**: Access to type parameter at runtime
### reified: Runtime Type Access
**Mental model:** `reified` makes generic type `T` available at runtime. Only works with `inline`.
**Amethyst pattern:**
```kotlin
// OptimizedJsonMapper.kt:48
expect object OptimizedJsonMapper {
inline fun <reified T : OptimizedSerializable> fromJsonTo(json: String): T
}
// Usage
val event: TextNoteEvent = OptimizedJsonMapper.fromJsonTo(jsonString)
// Compiler inlines and passes TextNoteEvent::class info
```
**Without reified:**
```kotlin
// Would need to pass class explicitly
fun <T> fromJson(json: String, clazz: KClass<T>): T {
return when (clazz) {
TextNoteEvent::class -> parseTextNote(json) as T
// ...
}
}
val event = fromJson(json, TextNoteEvent::class) // Verbose
```
**With reified:**
```kotlin
inline fun <reified T> fromJson(json: String): T {
return when (T::class) { // Can access T::class!
TextNoteEvent::class -> parseTextNote(json) as T
// ...
}
}
val event = fromJson<TextNoteEvent>(json) // Clean
```
### noinline & crossinline
**noinline**: Prevent specific lambda from being inlined
```kotlin
inline fun foo(
inlined: () -> Unit,
noinline notInlined: () -> Unit // Can be stored, passed around
) {
inlined()
someFunction(notInlined) // Can pass to non-inline function
}
```
**crossinline**: Lambda can't do non-local returns
```kotlin
inline fun foo(crossinline block: () -> Unit) {
launch {
block() // OK: crossinline allows lambda in different context
}
}
```
---
## 6. Value Classes (Opportunity)
**Mental model:** `value class` is a compile-time wrapper with zero runtime overhead. Single property, no boxing.
**Not currently used in Amethyst** - potential optimization.
**Pattern:**
```kotlin
@JvmInline
value class EventId(val hex: String)
@JvmInline
value class PubKey(val hex: String)
// Type safety without runtime cost
fun fetchEvent(eventId: EventId): Event {
// eventId.hex accessed without wrapper object
}
val id = EventId("abc123")
fetchEvent(id) // Type safe
// fetchEvent(PubKey("xyz")) // Compile error!
```
**When to use:**
- Type safety for primitives (IDs, hex strings, timestamps)
- High-frequency allocations (event processing)
- Clear domain types without overhead
**Restrictions:**
- Single property only
- Must be `val`
- Can't have `init` block with logic
- Inline at compile-time, may box in some cases
**Amethyst opportunity:**
```kotlin
// Current (String everywhere, no type safety)
fun fetchEvent(id: String): Event // Could pass wrong string
// With value class
@JvmInline value class EventId(val hex: String)
@JvmInline value class PubKeyHex(val hex: String)
@JvmInline value class Bech32(val encoded: String)
fun fetchEvent(id: EventId): Event // Type safe, zero cost
```
---
## Common Patterns
### Pattern: StateFlow State Management
```kotlin
class MyViewModel {
private val _state = MutableStateFlow(State.Initial)
val state: StateFlow<State> = _state.asStateFlow()
fun loadData() {
viewModelScope.launch {
_state.value = State.Loading
val result = repository.getData()
_state.value = when (result) {
is Success -> State.Success(result.data)
is Error -> State.Error(result.message)
}
}
}
}
sealed class State {
data object Initial : State()
data object Loading : State()
data class Success(val data: List<Item>) : State()
data class Error(val message: String) : State()
}
```
### Pattern: Sealed Result with Generics
```kotlin
sealed interface Result<out T> {
data class Success<T>(val value: T) : Result<T>
data class Error(val exception: Exception) : Result<Nothing>
data object Loading : Result<Nothing>
}
// Use with variance
fun <T> fetchData(): Result<T> = ...
val userResult: Result<User> = fetchData()
val itemResult: Result<List<Item>> = fetchData()
```
### Pattern: Immutable Event Builder
```kotlin
@Immutable
data class Event(
val id: String,
val kind: Int,
val content: String,
val tags: ImmutableList<Tag>
) {
companion object {
fun builder() = EventBuilder()
}
}
class EventBuilder {
private var id: String = ""
private var kind: Int = 1
private var content: String = ""
private val tags = mutableListOf<Tag>()
fun id(value: String) = apply { id = value }
fun kind(value: Int) = apply { kind = value }
fun content(value: String) = apply { content = value }
fun tag(tag: Tag) = apply { tags.add(tag) }
fun build() = Event(id, kind, content, tags.toImmutableList())
}
// Usage
val event = Event.builder()
.id("abc")
.kind(1)
.content("Hello")
.tag(Tag.P("pubkey"))
.build()
```
---
## Delegation Guide
**When to delegate:**
| Topic | Delegate To | This Skill Covers |
|-------|-------------|-------------------|
| Structured concurrency, channels | kotlin-coroutines agent | Flow state patterns only |
| expect/actual, source sets | kotlin-multiplatform skill | Platform-agnostic Kotlin |
| General Compose patterns | compose-expert skill | @Immutable for performance |
| Build configuration | gradle-expert skill | - |
**Ask kotlin-coroutines agent for:**
- Advanced Flow operators (flatMapLatest, combine, zip)
- Channel patterns
- Structured concurrency (supervisorScope, coroutineScope)
- Error handling in coroutines
**This skill teaches:**
- StateFlow/SharedFlow state management
- Sealed hierarchies
- @Immutable for Compose
- DSL builders
- Inline/reified patterns
---
## Anti-Patterns
**Mutable public state:**
```kotlin
val accountState: MutableStateFlow<AccountState> // BAD
```
**Immutable public interface:**
```kotlin
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
```
---
**Sealed class for generic results:**
```kotlin
sealed class Result<T> { // BAD: Can't use variance
data class Success<T>(val value: T) : Result<T>()
}
```
**Sealed interface for generics:**
```kotlin
sealed interface Result<out T> { // GOOD: Covariance
data class Success<T>(val value: T) : Result<T>
}
```
---
**Mutable properties in @Immutable class:**
```kotlin
@Immutable
data class Event(
var content: String // BAD: var breaks immutability
)
```
**All val:**
```kotlin
@Immutable
data class Event(
val content: String
)
```
---
**Passing class explicitly when reified available:**
```kotlin
inline fun <T> parse(json: String, clazz: KClass<T>): T // BAD
```
**Use reified:**
```kotlin
inline fun <reified T> parse(json: String): T // GOOD
```
---
## Quick Reference
### Flow Decision Tree
```
Need to expose state?
YES → StateFlow (always has value, single latest)
NO → Need events? → SharedFlow (optional replay, broadcast)
Need to mutate?
Internal only → MutableStateFlow (private)
Expose publicly → StateFlow via .asStateFlow()
```
### Sealed Decision Tree
```
Need common data in base type?
YES → sealed class
NO → sealed interface
Need generics with variance?
YES → sealed interface
NO → Either works
Need multiple inheritance?
YES → sealed interface
NO → Either works
```
### Inline Decision Tree
```
Passing lambda to function?
Called frequently? → inline (performance)
Need reified? → inline (required)
Need to store/pass lambda? → regular fun (can't inline)
```
---
## Resources
### Official Docs
- [StateFlow and SharedFlow | Android Developers](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow)
- [Sealed Classes | Kotlin Docs](https://kotlinlang.org/docs/sealed-classes.html)
- [Inline Functions | Kotlin Docs](https://kotlinlang.org/docs/inline-functions.html)
### Bundled References
- `references/flow-patterns.md` - StateFlow/SharedFlow examples from AccountManager, RelayManager
- `references/sealed-class-catalog.md` - All sealed types in quartz
- `references/dsl-builder-examples.md` - TagArrayBuilder, other DSL patterns
- `references/immutability-patterns.md` - @Immutable usage, data classes, collections
### Codebase Examples
- AccountManager.kt:36-50 - sealed class AccountState, StateFlow pattern
- RelayConnectionManager.kt:44-52 - StateFlow state management
- SignerResult.kt:25-46 - sealed interface with generics
- TextNoteEvent.kt:51-63 - @Immutable event class
- TagArrayBuilder.kt:23-90 - DSL builder pattern, inline function
- OptimizedJsonMapper.kt:48 - inline fun with reified
---
**Version:** 1.0.0
**Last Updated:** 2025-12-30
**Codebase Reference:** AmethystMultiplatform commit 258c4e011

View File

@@ -0,0 +1,602 @@
# DSL Builder Examples
Type-safe fluent APIs and DSL patterns from the codebase.
## Table of Contents
- [TagArrayBuilder Pattern](#tagarraybuilder-pattern)
- [Builder Variations](#builder-variations)
- [DSL Principles](#dsl-principles)
- [Creating Custom DSLs](#creating-custom-dsls)
---
## TagArrayBuilder Pattern
### Core Implementation
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt:23-91`
```kotlin
class TagArrayBuilder<T : IEvent> {
private val tagList = mutableMapOf<String, MutableList<Tag>>()
fun remove(tagName: String): TagArrayBuilder<T> {
tagList.remove(tagName)
return this // Method chaining
}
fun remove(tagName: String, tagValue: String): TagArrayBuilder<T> {
tagList[tagName]?.removeAll { it.valueOrNull() == tagValue }
if (tagList[tagName]?.isEmpty() == true) {
tagList.remove(tagName)
}
return this
}
fun removeIf(
predicate: (Tag, Tag) -> Boolean,
toCompare: Tag
): TagArrayBuilder<T> {
val tagName = toCompare.nameOrNull() ?: return this
tagList[tagName]?.removeAll { predicate(it, toCompare) }
if (tagList[tagName]?.isEmpty() == true) {
tagList.remove(tagName)
}
return this
}
fun add(tag: Array<String>): TagArrayBuilder<T> {
if (tag.isEmpty() || tag[0].isEmpty()) return this
tagList.getOrPut(tag[0], ::mutableListOf).add(tag)
return this
}
fun addFirst(tag: Array<String>): TagArrayBuilder<T> {
if (tag.isEmpty() || tag[0].isEmpty()) return this
tagList.getOrPut(tag[0], ::mutableListOf).add(0, tag)
return this
}
fun addUnique(tag: Array<String>): TagArrayBuilder<T> {
if (tag.isEmpty() || tag[0].isEmpty()) return this
tagList[tag[0]] = mutableListOf(tag) // Replace existing
return this
}
fun addAll(tag: List<Array<String>>): TagArrayBuilder<T> {
tag.forEach(::add)
return this
}
fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray()
fun build() = toTypedArray()
}
// Inline DSL function with lambda receiver
inline fun <T : Event> tagArray(
initializer: TagArrayBuilder<T>.() -> Unit = {}
): TagArray = TagArrayBuilder<T>().apply(initializer).build()
```
### Usage Examples
**Basic usage:**
```kotlin
val tags = tagArray<TextNoteEvent> {
add(arrayOf("e", eventId, relay, "reply"))
add(arrayOf("p", pubkey))
add(arrayOf("t", "bitcoin"))
}
```
**Advanced patterns:**
```kotlin
// Remove and add
val tags = tagArray<TextNoteEvent> {
addAll(existingTags)
remove("a") // Remove all address tags
addUnique(arrayOf("client", "Amethyst")) // Replace client tag
}
// Conditional building
val tags = tagArray<TextNoteEvent> {
add(arrayOf("e", rootId, "", "root"))
if (replyToId != null) {
add(arrayOf("e", replyToId, "", "reply"))
}
mentionedPubkeys.forEach { pubkey ->
add(arrayOf("p", pubkey))
}
hashtags.forEach { tag ->
add(arrayOf("t", tag.lowercase()))
}
}
// Custom predicate removal
val tags = tagArray<TextNoteEvent> {
addAll(originalTags)
removeIf(
predicate = { tag, compare -> tag[1] == compare[1] },
toCompare = arrayOf("e", eventIdToRemove)
)
}
```
---
## Builder Variations
### PrivateTagArrayBuilder
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt`
```kotlin
class PrivateTagArrayBuilder {
private val builder = TagArrayBuilder<Event>()
fun add(tag: PrivateTag): PrivateTagArrayBuilder {
builder.add(tag.toArray())
return this
}
fun addAll(tags: List<PrivateTag>): PrivateTagArrayBuilder {
tags.forEach { add(it) }
return this
}
fun build(): Array<Array<String>> = builder.build()
}
// DSL function
inline fun privateTagArray(
initializer: PrivateTagArrayBuilder.() -> Unit
): Array<Array<String>> = PrivateTagArrayBuilder().apply(initializer).build()
```
**Usage:**
```kotlin
val privateTags = privateTagArray {
add(PrivateTag.Event(eventId, marker = "bookmark"))
add(PrivateTag.Profile(pubkey))
addAll(existingPrivateTags)
}
```
### TlvBuilder
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt`
```kotlin
class TlvBuilder {
private val entries = mutableListOf<TlvEntry>()
fun add(type: TlvType, value: ByteArray): TlvBuilder {
entries.add(TlvEntry(type, value))
return this
}
fun addRelay(relay: String): TlvBuilder {
add(TlvType.Relay, relay.encodeToByteArray())
return this
}
fun addAuthor(pubkey: ByteArray): TlvBuilder {
add(TlvType.Author, pubkey)
return this
}
fun addKind(kind: Int): TlvBuilder {
add(TlvType.Kind, kind.toByteArray())
return this
}
fun build(): ByteArray {
return entries.flatMap { it.encode() }.toByteArray()
}
}
fun tlv(init: TlvBuilder.() -> Unit): ByteArray =
TlvBuilder().apply(init).build()
```
**Usage:**
```kotlin
val tlvData = tlv {
addAuthor(pubkeyBytes)
addRelay("wss://relay.damus.io")
addKind(1)
}
```
### MapOfSetBuilder
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt`
```kotlin
class MapOfSetBuilder<K, V> {
private val map = mutableMapOf<K, MutableSet<V>>()
fun add(key: K, value: V): MapOfSetBuilder<K, V> {
map.getOrPut(key) { mutableSetOf() }.add(value)
return this
}
fun addAll(key: K, values: Collection<V>): MapOfSetBuilder<K, V> {
map.getOrPut(key) { mutableSetOf() }.addAll(values)
return this
}
fun remove(key: K, value: V): MapOfSetBuilder<K, V> {
map[key]?.remove(value)
if (map[key]?.isEmpty() == true) {
map.remove(key)
}
return this
}
fun build(): Map<K, Set<V>> = map.mapValues { it.value.toSet() }
}
inline fun <K, V> mapOfSets(
init: MapOfSetBuilder<K, V>.() -> Unit
): Map<K, Set<V>> = MapOfSetBuilder<K, V>().apply(init).build()
```
**Usage:**
```kotlin
val relayMap = mapOfSets<String, EventId> {
add("wss://relay1.com", eventId1)
add("wss://relay1.com", eventId2)
add("wss://relay2.com", eventId3)
}
// Result: {"wss://relay1.com": [eventId1, eventId2], "wss://relay2.com": [eventId3]}
```
---
## DSL Principles
### 1. Lambda with Receiver
**Mental model:** Lambda receiver makes `this` refer to builder instance inside lambda.
```kotlin
// Without receiver
fun buildTags(config: (TagArrayBuilder<Event>) -> Unit) {
val builder = TagArrayBuilder<Event>()
config(builder) // Must pass builder explicitly
builder.build()
}
buildTags { builder ->
builder.add(...) // Verbose
}
// With receiver
inline fun buildTags(config: TagArrayBuilder<Event>.() -> Unit) {
TagArrayBuilder<Event>().apply(config).build()
}
buildTags {
add(...) // Clean - 'this' is builder
}
```
### 2. Method Chaining
**Pattern:** Return `this` from mutator methods.
```kotlin
class Builder {
private var value: String = ""
fun setValue(v: String): Builder {
value = v
return this // Enable chaining
}
fun append(s: String): Builder {
value += s
return this
}
fun build(): String = value
}
// Usage
val result = Builder()
.setValue("Hello")
.append(" ")
.append("World")
.build()
```
### 3. Inline for Performance
**Why inline:**
- Eliminates lambda allocation
- Allows `reified` type parameters
- Better for hot paths (frequently called)
```kotlin
// NOT inline - lambda object created each call
fun <T> myDsl(init: Builder<T>.() -> Unit): Result<T> {
return Builder<T>().apply(init).build()
}
// Inline - lambda code inlined at call site
inline fun <T> myDsl(init: Builder<T>.() -> Unit): Result<T> {
return Builder<T>().apply(init).build()
}
```
### 4. Type Safety
**Use generics for compile-time safety:**
```kotlin
// Type-safe builder
class EventBuilder<T : Event> {
fun addTag(tag: Tag<T>): EventBuilder<T> { // Only accepts tags for this event type
tags.add(tag)
return this
}
}
// Usage
val textNote = EventBuilder<TextNoteEvent>()
.addTag(TextNoteTag.Subject("Hello")) // OK
// .addTag(ChannelTag.Name("test")) // Compile error!
.build()
```
---
## Creating Custom DSLs
### Pattern: Simple Builder DSL
```kotlin
class QueryBuilder {
private val filters = mutableListOf<String>()
private var limit: Int? = null
private var offset: Int? = null
fun filter(field: String, value: String): QueryBuilder {
filters.add("$field:$value")
return this
}
fun limit(n: Int): QueryBuilder {
limit = n
return this
}
fun offset(n: Int): QueryBuilder {
offset = n
return this
}
fun build(): String {
val parts = mutableListOf<String>()
if (filters.isNotEmpty()) {
parts.add(filters.joinToString(" AND "))
}
if (limit != null) {
parts.add("LIMIT $limit")
}
if (offset != null) {
parts.add("OFFSET $offset")
}
return parts.joinToString(" ")
}
}
inline fun query(init: QueryBuilder.() -> Unit): String =
QueryBuilder().apply(init).build()
// Usage
val sql = query {
filter("status", "active")
filter("age", ">18")
limit(10)
offset(20)
}
// Result: "status:active AND age:>18 LIMIT 10 OFFSET 20"
```
### Pattern: Nested Builders
```kotlin
class FilterBuilder {
private val conditions = mutableListOf<String>()
fun equals(field: String, value: String) {
conditions.add("$field = '$value'")
}
fun greaterThan(field: String, value: Int) {
conditions.add("$field > $value")
}
fun build(): String = conditions.joinToString(" AND ")
}
class QueryBuilder {
private var filterClause: String = ""
private var selectClause: String = "*"
fun select(vararg fields: String): QueryBuilder {
selectClause = fields.joinToString(", ")
return this
}
fun where(init: FilterBuilder.() -> Unit): QueryBuilder {
filterClause = FilterBuilder().apply(init).build()
return this
}
fun build(): String {
return "SELECT $selectClause WHERE $filterClause"
}
}
inline fun query(init: QueryBuilder.() -> Unit): String =
QueryBuilder().apply(init).build()
// Usage
val sql = query {
select("id", "name", "age")
where {
equals("status", "active")
greaterThan("age", 18)
}
}
// Result: "SELECT id, name, age WHERE status = 'active' AND age > 18"
```
### Pattern: Type-Safe HTML DSL
```kotlin
abstract class Tag(val name: String) {
private val children = mutableListOf<Tag>()
private val attributes = mutableMapOf<String, String>()
fun <T : Tag> tag(tag: T, init: T.() -> Unit): T {
tag.init()
children.add(tag)
return tag
}
fun attr(name: String, value: String) {
attributes[name] = value
}
fun render(builder: StringBuilder, indent: String) {
builder.append("$indent<$name")
attributes.forEach { (k, v) -> builder.append(" $k=\"$v\"") }
if (children.isEmpty()) {
builder.append("/>\n")
} else {
builder.append(">\n")
children.forEach { it.render(builder, "$indent ") }
builder.append("$indent</$name>\n")
}
}
}
class HTML : Tag("html")
class Head : Tag("head")
class Body : Tag("body")
class Div : Tag("div")
class P : Tag("p")
class A : Tag("a")
fun HTML.head(init: Head.() -> Unit) = tag(Head(), init)
fun HTML.body(init: Body.() -> Unit) = tag(Body(), init)
fun Body.div(init: Div.() -> Unit) = tag(Div(), init)
fun Div.p(init: P.() -> Unit) = tag(P(), init)
fun Div.a(init: A.() -> Unit) = tag(A(), init)
fun html(init: HTML.() -> Unit): HTML = HTML().apply(init)
// Usage
val page = html {
head {
// ...
}
body {
div {
attr("class", "container")
p {
attr("id", "intro")
}
a {
attr("href", "https://example.com")
}
}
}
}
```
---
## Best Practices
### ✅ DO
1. **Return `this` for chaining:**
```kotlin
fun add(item: Item): Builder {
items.add(item)
return this
}
```
2. **Use `inline` for DSL functions:**
```kotlin
inline fun myDsl(init: Builder.() -> Unit) = Builder().apply(init).build()
```
3. **Provide sensible defaults:**
```kotlin
inline fun query(
init: QueryBuilder.() -> Unit = {} // Empty lambda as default
) = QueryBuilder().apply(init).build()
```
4. **Validate in `build()`:**
```kotlin
fun build(): Result {
require(fields.isNotEmpty()) { "Must specify at least one field" }
return Result(fields)
}
```
### ❌ DON'T
1. **Forget to return `this`:**
```kotlin
fun add(item: Item) { // BAD: Can't chain
items.add(item)
}
```
2. **Mutate after build:**
```kotlin
val builder = Builder()
builder.add("foo")
val result = builder.build()
builder.add("bar") // BAD: Confusing state
```
3. **Expose mutable state:**
```kotlin
class Builder {
val items = mutableListOf<Item>() // BAD: Can be mutated externally
}
```
4. **Make DSL functions non-inline unnecessarily:**
```kotlin
fun myDsl(init: Builder.() -> Unit) = ... // BAD: Lambda allocation overhead
```
---
## References
- TagArrayBuilder.kt:23-91
- PrivateTagArrayBuilder.kt
- TlvBuilder.kt
- [Type-Safe Builders | Kotlin Docs](https://kotlinlang.org/docs/type-safe-builders.html)
- [DSLs with Kotlin](https://kt.academy/article/dsl-intro)

View File

@@ -0,0 +1,405 @@
# Flow Patterns in Amethyst
StateFlow and SharedFlow usage patterns from the codebase.
## Table of Contents
- [StateFlow for State Management](#stateflow-for-state-management)
- [Flow Composition](#flow-composition)
- [Common Patterns](#common-patterns)
- [Anti-Patterns](#anti-patterns)
---
## StateFlow for State Management
### AccountManager Pattern
**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt:36-115`
```kotlin
sealed class AccountState {
data object LoggedOut : AccountState()
data class LoggedIn(
val signer: NostrSigner,
val pubKeyHex: String,
val npub: String,
val nsec: String?,
val isReadOnly: Boolean,
) : AccountState()
}
class AccountManager {
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
fun generateNewAccount(): AccountState.LoggedIn {
val keyPair = KeyPair()
val signer = NostrSignerInternal(keyPair)
val state = AccountState.LoggedIn(
signer = signer,
pubKeyHex = keyPair.pubKey.toHexKey(),
npub = keyPair.pubKey.toNpub(),
nsec = keyPair.privKey?.toNsec(),
isReadOnly = false
)
_accountState.value = state // Update state
return state
}
fun loginWithKey(keyInput: String): Result<AccountState.LoggedIn> {
// ... validation ...
val state = AccountState.LoggedIn(...)
_accountState.value = state
return Result.success(state)
}
fun logout() {
_accountState.value = AccountState.LoggedOut
}
}
```
**Pattern highlights:**
- Private `MutableStateFlow` for internal mutations
- Public `StateFlow` via `.asStateFlow()` for read-only access
- Sealed class for type-safe state variants
- Initial value required (`AccountState.LoggedOut`)
### RelayConnectionManager Pattern
**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt:44-80`
```kotlin
data class RelayStatus(
val url: NormalizedRelayUrl,
val connected: Boolean,
val error: String? = null,
val messageCount: Int = 0
)
open class RelayConnectionManager(
websocketBuilder: WebsocketBuilder
) : IRelayClientListener {
private val client = NostrClient(websocketBuilder)
// Map of relay URLs to their status
private val _relayStatuses = MutableStateFlow<Map<NormalizedRelayUrl, RelayStatus>>(emptyMap())
val relayStatuses: StateFlow<Map<NormalizedRelayUrl, RelayStatus>> = _relayStatuses.asStateFlow()
// Delegated StateFlows from client
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = client.connectedRelaysFlow()
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = client.availableRelaysFlow()
fun addRelay(url: String): NormalizedRelayUrl? {
val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null
updateRelayStatus(normalized) { it.copy(connected = false, error = null) }
return normalized
}
fun removeRelay(url: NormalizedRelayUrl) {
_relayStatuses.value = _relayStatuses.value - url // Immutable update (remove from map)
}
private fun updateRelayStatus(
relay: NormalizedRelayUrl,
update: (RelayStatus) -> RelayStatus
) {
_relayStatuses.value = _relayStatuses.value.toMutableMap().apply {
val current = get(relay) ?: RelayStatus(relay, false)
put(relay, update(current))
}
}
// IRelayClientListener implementation
override fun onConnect(relay: NormalizedRelayUrl) {
updateRelayStatus(relay) { it.copy(connected = true, error = null) }
}
override fun onError(relay: NormalizedRelayUrl, error: String) {
updateRelayStatus(relay) { it.copy(connected = false, error = error) }
}
}
```
**Pattern highlights:**
- `Map` as state value for collection tracking
- Immutable map updates (copy with modifications)
- Helper function `updateRelayStatus` for consistent updates
- Delegation pattern (client exposes its own StateFlows)
---
## Flow Composition
### Multiple StateFlows in UI
**Pattern:**
```kotlin
@Composable
fun LoginScreen(accountManager: AccountManager) {
val accountState by accountManager.accountState.collectAsState()
when (accountState) {
is AccountState.LoggedOut -> {
LoginForm(onLogin = { key -> accountManager.loginWithKey(key) })
}
is AccountState.LoggedIn -> {
MainApp(account = accountState as AccountState.LoggedIn)
}
}
}
```
### Observing Multiple Flows
**Pattern:**
```kotlin
@Composable
fun RelayStatusCard(relayManager: RelayConnectionManager) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
Column {
Text("${connectedRelays.size} of ${relayStatuses.size} relays connected")
relayStatuses.forEach { (url, status) ->
RelayRow(
url = url,
connected = status.connected,
error = status.error
)
}
}
}
```
---
## Common Patterns
### Pattern: Immutable State Updates
```kotlin
// Map updates
_relayStatuses.value = _relayStatuses.value + (url to newStatus) // Add
_relayStatuses.value = _relayStatuses.value - url // Remove
_relayStatuses.value = _relayStatuses.value.mapValues { (key, value) ->
if (key == targetUrl) value.copy(connected = true) else value
}
// List updates
_items.value = _items.value + newItem // Append
_items.value = _items.value.filter { it.id != removedId } // Remove
_items.value = _items.value.map { if (it.id == id) it.copy(name = newName) else it } // Update
// Object updates
_user.value = _user.value.copy(name = newName)
```
### Pattern: Conditional State Transitions
```kotlin
fun attemptLogin(credentials: Credentials) {
if (_loginState.value is LoginState.LoggingIn) {
return // Already logging in, ignore
}
_loginState.value = LoginState.LoggingIn
viewModelScope.launch {
try {
val user = repository.login(credentials)
_loginState.value = LoginState.Success(user)
} catch (e: Exception) {
_loginState.value = LoginState.Error(e.message ?: "Login failed")
}
}
}
```
### Pattern: Derived State
```kotlin
class MyViewModel {
private val _items = MutableStateFlow<List<Item>>(emptyList())
val items: StateFlow<List<Item>> = _items.asStateFlow()
// Derived state (computed from items)
val itemCount: StateFlow<Int> = items.map { it.size }
.stateIn(viewModelScope, SharingStarted.Lazily, 0)
val hasItems: StateFlow<Boolean> = items.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.Lazily, false)
}
// Usage in Compose
@Composable
fun ItemList(viewModel: MyViewModel) {
val itemCount by viewModel.itemCount.collectAsState()
val hasItems by viewModel.hasItems.collectAsState()
if (hasItems) {
Text("$itemCount items")
} else {
Text("No items")
}
}
```
### Pattern: State with Loading/Error
```kotlin
sealed class UiState<out T> {
data object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
}
class FeedViewModel {
private val _feedState = MutableStateFlow<UiState<List<Event>>>(UiState.Loading)
val feedState: StateFlow<UiState<List<Event>>> = _feedState.asStateFlow()
fun loadFeed() {
viewModelScope.launch {
_feedState.value = UiState.Loading
try {
val events = repository.getEvents()
_feedState.value = UiState.Success(events)
} catch (e: Exception) {
_feedState.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
}
// UI
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val state by viewModel.feedState.collectAsState()
when (state) {
is UiState.Loading -> LoadingSpinner()
is UiState.Success -> EventList((state as UiState.Success).data)
is UiState.Error -> ErrorMessage((state as UiState.Error).message)
}
}
```
---
## Anti-Patterns
### ❌ Exposing Mutable State
```kotlin
// BAD: External code can mutate
class BadViewModel {
val state: MutableStateFlow<State> = MutableStateFlow(State.Initial)
}
// Caller can do:
viewModel.state.value = State.Hacked // Bypass internal logic!
```
### ✅ Expose Immutable
```kotlin
// GOOD: Only ViewModel can mutate
class GoodViewModel {
private val _state = MutableStateFlow(State.Initial)
val state: StateFlow<State> = _state.asStateFlow()
fun updateState(newState: State) {
// Controlled mutation with validation
_state.value = newState
}
}
```
---
### ❌ Not Using Immutable Updates
```kotlin
// BAD: Mutating collection doesn't trigger StateFlow update
val list = mutableListOf<Item>()
list.add(newItem)
_items.value = list // Same reference, no update emitted!
```
### ✅ Create New Instance
```kotlin
// GOOD: New list instance
_items.value = _items.value + newItem // New list created, update emitted
```
---
### ❌ StateFlow for Events
```kotlin
// BAD: Events get lost if no collector
class BadViewModel {
val navigationEvent: StateFlow<NavEvent?> = MutableStateFlow(null)
fun navigate(event: NavEvent) {
_navigationEvent.value = event // Lost if UI not observing!
}
}
```
### ✅ SharedFlow for Events
```kotlin
// GOOD: Events queued
class GoodViewModel {
private val _navigationEvent = MutableSharedFlow<NavEvent>(replay = 0)
val navigationEvent: SharedFlow<NavEvent> = _navigationEvent.asSharedFlow()
fun navigate(event: NavEvent) {
viewModelScope.launch {
_navigationEvent.emit(event) // Queued for collector
}
}
}
```
---
### ❌ Blocking Operations in State Update
```kotlin
// BAD: Blocking main thread
fun loadData() {
_state.value = fetchDataFromNetwork() // Blocks!
}
```
### ✅ Async Updates
```kotlin
// GOOD: Use coroutines
fun loadData() {
viewModelScope.launch {
_state.value = UiState.Loading
val data = withContext(Dispatchers.IO) {
fetchDataFromNetwork()
}
_state.value = UiState.Success(data)
}
}
```
---
## References
- AccountManager.kt:36-115
- RelayConnectionManager.kt:44-80
- [StateFlow and SharedFlow | Android Developers](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow)
- [Hot vs Cold Flows](https://carrion.dev/en/posts/kotlin-flows-hot-cold/)

View File

@@ -0,0 +1,641 @@
# Immutability Patterns
@Immutable annotation, data classes, and immutable collections for Compose performance.
## Table of Contents
- [Why Immutability Matters](#why-immutability-matters)
- [@Immutable Annotation](#immutable-annotation)
- [Data Classes](#data-classes)
- [Immutable Collections](#immutable-collections)
- [Common Patterns](#common-patterns)
- [Performance Impact](#performance-impact)
---
## Why Immutability Matters
### Compose Recomposition
**Mental model:** Compose tracks state changes by comparing references. If an `@Immutable` object reference doesn't change, Compose skips recomposition.
```kotlin
// Without @Immutable - Recomposes on every parent recomposition
data class User(val name: String, val age: Int)
@Composable
fun UserCard(user: User) { // Recomposes unnecessarily
Text(user.name)
}
// With @Immutable - Only recomposes when user reference changes
@Immutable
data class User(val name: String, val age: Int)
@Composable
fun UserCard(user: User) { // Smart recomposition
Text(user.name)
}
```
**Performance difference:**
- Without `@Immutable`: 1000 `UserCard` recompositions per screen update
- With `@Immutable`: 10 `UserCard` recompositions (only changed users)
### Thread Safety
Immutable objects are inherently thread-safe:
```kotlin
@Immutable
data class Event(
val id: String,
val content: String,
val createdAt: Long
)
// Safe to share across coroutines without synchronization
val sharedEvent: Event = fetchEvent()
launch { processEvent(sharedEvent) } // Safe
launch { saveEvent(sharedEvent) } // Safe
```
---
## @Immutable Annotation
### Basic Usage
**Pattern from Amethyst:**
```kotlin
// TextNoteEvent.kt:51-63
@Immutable
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey
) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
// All properties are val (immutable)
// No var properties
// No mutable collections
}
```
**Requirements for @Immutable:**
1. All properties must be `val` (no `var`)
2. All property types must be immutable or primitives
3. No mutable collections (`MutableList`, `MutableMap`)
4. Arrays are allowed (treated as immutable by contract)
5. No public mutable state
### @Immutable vs @Stable
**@Immutable:** Value never changes after construction
```kotlin
@Immutable
data class User(val name: String, val age: Int)
// Once created, user.name and user.age never change
```
**@Stable:** Value can change, but changes are tracked
```kotlin
@Stable
class MutableCounter {
var count by mutableStateOf(0) // Changes tracked by Compose
}
```
**Amethyst uses @Immutable extensively:**
- 173+ event classes annotated with `@Immutable`
- All Nostr events immutable by design
- Critical for feed performance (thousands of events)
---
## Data Classes
### Immutable Data Classes
**Pattern:**
```kotlin
@Immutable
data class RelayStatus(
val url: NormalizedRelayUrl,
val connected: Boolean,
val error: String? = null,
val messageCount: Int = 0
) {
// Immutable properties only (val)
// Default values allowed
}
```
**Benefits:**
1. **Structural equality:** `equals()` compares values, not references
2. **copy():** Create modified copies without mutation
3. **toString():** Debugging-friendly output
4. **hashCode():** Consistent hashing for collections
5. **componentN():** Destructuring support
### copy() for Updates
**Mental model:** Instead of mutating, create modified copies.
```kotlin
val status = RelayStatus(
url = "wss://relay.damus.io",
connected = false,
error = null
)
// Immutable update
val updatedStatus = status.copy(connected = true)
// Original unchanged
assert(status.connected == false)
assert(updatedStatus.connected == true)
```
**StateFlow pattern:**
```kotlin
private val _relayStatuses = MutableStateFlow<Map<String, RelayStatus>>(emptyMap())
fun updateRelay(url: String, connected: Boolean) {
_relayStatuses.value = _relayStatuses.value.mapValues { (key, status) ->
if (key == url) {
status.copy(connected = connected) // Immutable update
} else {
status
}
}
}
```
### All Properties in Constructor
**Why important for data classes:**
```kotlin
// BAD: Properties outside constructor not included in equals/hashCode
data class User(val name: String) {
var age: Int = 0 // NOT in equals/hashCode/copy!
}
val user1 = User("Alice")
val user2 = User("Alice")
user1.age = 25
user2.age = 30
assert(user1 == user2) // TRUE! age not compared
assert(user1.copy() == user1) // TRUE! age not copied
// GOOD: All properties in constructor
@Immutable
data class User(
val name: String,
val age: Int // Included in equals/hashCode/copy
)
```
---
## Immutable Collections
### kotlinx.collections.immutable
**Installation:**
```kotlin
// build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
}
```
**Why use:**
- Structural sharing (efficient copies)
- Explicit immutability (compiler enforced)
- Safe for Compose state
### ImmutableList
```kotlin
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
// Create immutable list
val relays: ImmutableList<String> = persistentListOf(
"wss://relay1.com",
"wss://relay2.com"
)
// Add returns NEW list
val updated = relays.add("wss://relay3.com")
assert(relays.size == 2) // Original unchanged
assert(updated.size == 3) // New list has 3 items
// Convert from regular list
val mutableList = mutableListOf("a", "b", "c")
val immutable = mutableList.toImmutableList()
```
### ImmutableMap
```kotlin
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
// Create immutable map
val relayStatuses: ImmutableMap<String, RelayStatus> = persistentMapOf(
"wss://relay1.com" to RelayStatus(...),
"wss://relay2.com" to RelayStatus(...)
)
// Put returns NEW map
val updated = relayStatuses.put("wss://relay3.com", RelayStatus(...))
// Remove returns NEW map
val removed = relayStatuses.remove("wss://relay1.com")
```
### ImmutableSet
```kotlin
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
val connectedRelays: ImmutableSet<String> = persistentSetOf(
"wss://relay1.com",
"wss://relay2.com"
)
val updated = connectedRelays.add("wss://relay3.com")
```
### Structural Sharing
**Mental model:** Immutable collections reuse internal structure for efficiency.
```kotlin
val list1 = persistentListOf(1, 2, 3, 4, 5) // 5 items
val list2 = list1.add(6) // Shares structure with list1
// Internally:
// list1 and list2 share nodes for items 1-5
// list2 has one additional node for item 6
// O(1) time, O(1) space for add operation
```
---
## Common Patterns
### Pattern: Immutable State Updates
```kotlin
@Immutable
data class FeedState(
val events: ImmutableList<Event>,
val loading: Boolean,
val error: String?
)
class FeedViewModel {
private val _state = MutableStateFlow(
FeedState(
events = persistentListOf(),
loading = false,
error = null
)
)
val state: StateFlow<FeedState> = _state.asStateFlow()
fun loadEvents() {
_state.value = _state.value.copy(loading = true, error = null)
viewModelScope.launch {
try {
val events = repository.getEvents()
_state.value = _state.value.copy(
events = events.toImmutableList(),
loading = false
)
} catch (e: Exception) {
_state.value = _state.value.copy(
loading = false,
error = e.message
)
}
}
}
fun addEvent(event: Event) {
_state.value = _state.value.copy(
events = _state.value.events.add(event) // Immutable add
)
}
fun removeEvent(eventId: String) {
_state.value = _state.value.copy(
events = _state.value.events.filter { it.id != eventId }.toImmutableList()
)
}
}
```
### Pattern: Deep Immutability
```kotlin
// Nested immutable structures
@Immutable
data class User(
val name: String,
val profile: Profile // Also immutable
)
@Immutable
data class Profile(
val bio: String,
val avatar: String,
val relays: ImmutableList<String> // Immutable collection
)
// Safe deep copy
val user = User(
name = "Alice",
profile = Profile(
bio = "Nostr enthusiast",
avatar = "https://...",
relays = persistentListOf("wss://relay1.com")
)
)
val updatedUser = user.copy(
profile = user.profile.copy(
bio = "Bitcoin & Nostr enthusiast" // Deep update
)
)
```
### Pattern: Collection Builder to Immutable
```kotlin
// Build mutable, convert to immutable
fun processEvents(input: List<Event>): ImmutableList<Event> {
val processed = mutableListOf<Event>()
for (event in input) {
if (event.isValid()) {
processed.add(event.normalize())
}
}
return processed.toImmutableList() // Convert once at end
}
```
### Pattern: Immutable Map Updates
```kotlin
private val _relayStatuses = MutableStateFlow<ImmutableMap<String, RelayStatus>>(
persistentMapOf()
)
fun updateRelay(url: String, connected: Boolean) {
val currentStatuses = _relayStatuses.value
val currentStatus = currentStatuses[url] ?: RelayStatus(url, false)
_relayStatuses.value = currentStatuses.put(
url,
currentStatus.copy(connected = connected)
)
}
fun removeRelay(url: String) {
_relayStatuses.value = _relayStatuses.value.remove(url)
}
```
---
## Performance Impact
### Benchmarks (Approximate)
**Recomposition cost:**
```kotlin
// 1000 items in LazyColumn
// Without @Immutable: ~100ms per frame (skipped frames)
// With @Immutable: ~16ms per frame (smooth 60fps)
@Immutable
data class Item(val id: String, val name: String)
@Composable
fun ItemList(items: ImmutableList<Item>) {
LazyColumn {
items(items, key = { it.id }) { item ->
ItemRow(item) // Only recomposes when item changes
}
}
}
```
**Structural sharing efficiency:**
```kotlin
val list1 = persistentListOf(1..10000)
val list2 = list1.add(10001) // O(log n) time, shares structure
// Regular list (copy on modification):
val mutableList = (1..10000).toMutableList()
val copy = mutableList.toList() + 10001 // O(n) time, full copy
```
### When to Use Immutable Collections
**Use ImmutableList/Map/Set when:**
- Storing in Compose state (@Immutable class)
- Sharing across coroutines
- Frequent modifications (structural sharing efficient)
- Need compile-time immutability guarantee
**Use Array when:**
- Fixed size, no modifications
- Nostr protocol (tags are `Array<Array<String>>`)
- Performance-critical (array access is fastest)
**Use regular List/Map/Set when:**
- Local scope only
- Build once, read many times
- Converting to immutable at boundary
---
## Anti-Patterns
### ❌ Mutable Properties in @Immutable Class
```kotlin
@Immutable
data class BadEvent(
val id: String,
var content: String // BAD: var breaks immutability
)
```
### ✅ All val Properties
```kotlin
@Immutable
data class GoodEvent(
val id: String,
val content: String
)
```
---
### ❌ Mutable Collections in @Immutable Class
```kotlin
@Immutable
data class BadState(
val items: MutableList<Item> // BAD: Can mutate items
)
// Caller can mutate:
val state = BadState(mutableListOf())
state.items.add(newItem) // Breaks immutability!
```
### ✅ Immutable Collections
```kotlin
@Immutable
data class GoodState(
val items: ImmutableList<Item>
)
// Caller must create new state:
val updated = state.copy(items = state.items.add(newItem))
```
---
### ❌ Direct Mutation
```kotlin
val status = RelayStatus(url, connected = false)
status.connected = true // Compile error (val)
// But could happen with mutable nested objects:
@Immutable
data class Config(
val settings: Settings // If Settings is mutable...
)
class Settings {
var theme: String = "dark" // BAD
}
val config = Config(Settings())
config.settings.theme = "light" // Mutates "immutable" config!
```
### ✅ Deep Immutability
```kotlin
@Immutable
data class Config(
val settings: Settings
)
@Immutable
data class Settings(
val theme: String // val only
)
val config = Config(Settings("dark"))
val updated = config.copy(
settings = config.settings.copy(theme = "light")
)
```
---
### ❌ Exposing Mutable Internal State
```kotlin
@Immutable
class BadViewModel {
private val _items = mutableListOf<Item>()
val items: List<Item> = _items // BAD: Exposes mutable list
fun addItem(item: Item) {
_items.add(item)
}
}
// Caller can cast and mutate:
val vm = BadViewModel()
(vm.items as MutableList).clear() // Breaks encapsulation!
```
### ✅ Convert to Immutable at Boundary
```kotlin
@Immutable
class GoodViewModel {
private val _items = mutableListOf<Item>()
val items: ImmutableList<Item>
get() = _items.toImmutableList() // GOOD: Copy to immutable
fun addItem(item: Item) {
_items.add(item)
}
}
```
---
## Checklist for Immutability
**For @Immutable classes:**
- [ ] All properties are `val`, never `var`
- [ ] No mutable collections (`MutableList`, `MutableMap`, `MutableSet`)
- [ ] Nested objects are also `@Immutable` or primitives
- [ ] No public mutable state
- [ ] Use `copy()` for updates, never mutation
- [ ] Arrays used only when truly immutable by contract
**For StateFlow state:**
- [ ] State class is `@Immutable`
- [ ] Use immutable collections (ImmutableList, ImmutableMap)
- [ ] Create new instances for updates (`copy()`, `.add()`, `.put()`)
- [ ] Never mutate state in-place
**For Compose performance:**
- [ ] All `@Composable` parameters are `@Immutable` or `@Stable`
- [ ] Lists use `ImmutableList` and `key` parameter in `items()`
- [ ] Heavy objects (events, profiles) cached and reused
---
## References
- TextNoteEvent.kt:51-63 - @Immutable event example
- RelayConnectionManager.kt - Immutable map updates
- [Compose Performance | Android Developers](https://developer.android.com/jetpack/compose/performance/stability)
- [kotlinx.collections.immutable | GitHub](https://github.com/Kotlin/kotlinx.collections.immutable)
- [@Stable and @Immutable | Compose Docs](https://developer.android.com/jetpack/compose/performance/stability/fix)

View File

@@ -0,0 +1,482 @@
# Sealed Class Catalog
Comprehensive list of sealed types in AmethystMultiplatform with usage patterns.
## Table of Contents
- [State Management](#state-management)
- [Result Types](#result-types)
- [Tag Variants](#tag-variants)
- [Sealed Class vs Sealed Interface](#sealed-class-vs-sealed-interface)
- [Patterns](#patterns)
---
## State Management
### AccountState (Sealed Class)
**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt:36-46`
```kotlin
sealed class AccountState {
data object LoggedOut : AccountState()
data class LoggedIn(
val signer: NostrSigner,
val pubKeyHex: String,
val npub: String,
val nsec: String?,
val isReadOnly: Boolean
) : AccountState()
}
```
**Why sealed class:**
- Two distinct states with different data
- `LoggedIn` holds data, `LoggedOut` doesn't
- No need for generics or multiple inheritance
**Usage:**
```kotlin
fun handleAccountState(state: AccountState) {
when (state) {
is AccountState.LoggedOut -> showLogin()
is AccountState.LoggedIn -> {
showFeed(
pubkey = state.pubKeyHex,
canSign = !state.isReadOnly
)
}
} // Exhaustive - compiler enforces
}
```
### VerificationState (Sealed Class)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/VerificationState.kt`
```kotlin
sealed class VerificationState {
data object NotStarted : VerificationState()
data object Started : VerificationState()
data class Failed(val reason: String) : VerificationState()
data object Verified : VerificationState()
}
```
**Pattern:**
- State machine (NotStarted → Started → Failed/Verified)
- Only `Failed` carries data (reason)
- Rest are singletons (`data object`)
---
## Result Types
### SignerResult (Sealed Interface with Generics)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt:25-46`
```kotlin
sealed interface SignerResult<T : IResult> {
sealed interface RequestAddressed<T : IResult> : SignerResult<T> {
class Successful<T : IResult>(val result: T) : RequestAddressed<T>
class Rejected<T : IResult> : RequestAddressed<T>
class TimedOut<T : IResult> : RequestAddressed<T>
class ReceivedButCouldNotPerform<T : IResult>(
val message: String? = null
) : RequestAddressed<T>
class ReceivedButCouldNotParseEventFromResult<T : IResult>(
val eventJson: String
) : RequestAddressed<T>
class ReceivedButCouldNotVerifyResultingEvent<T : IResult>(
val invalidEvent: Event
) : RequestAddressed<T>
}
}
interface IResult
data class SignResult(val event: Event) : IResult
data class EncryptionResult(val ciphertext: String) : IResult
data class DecryptionResult(val plaintext: String) : IResult
```
**Why sealed interface:**
- Generic result type `<T : IResult>`
- Nested sealed hierarchy (RequestAddressed)
- Need covariance for flexible result types
**Usage:**
```kotlin
suspend fun signEvent(event: Event): SignerResult<SignResult> {
return when (val result = remoteSigner.sign(event)) {
is SignerResult.RequestAddressed.Successful -> result
is SignerResult.RequestAddressed.Rejected -> {
logger.warn("Signing rejected")
result
}
is SignerResult.RequestAddressed.TimedOut -> {
logger.error("Signing timed out")
result
}
is SignerResult.RequestAddressed.ReceivedButCouldNotPerform -> {
logger.error("Signer error: ${result.message}")
result
}
}
}
```
### CacheResults (Sealed Class with Generics)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/caches/CacheResults.kt`
```kotlin
sealed class CacheResults<T> {
data class Found<T>(val value: T) : CacheResults<T>()
class NotFound<T> : CacheResults<T>()
}
```
**Pattern:**
- Simple binary result (found/not found)
- `Found` carries data, `NotFound` doesn't
- Generic for reusability
---
## Tag Variants
### MuteTag (Sealed Class)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt`
```kotlin
sealed class MuteTag(
val nameOrNull: String?,
val valueOrNull: String?
) {
class Event(eventId: String) : MuteTag("e", eventId)
class Profile(pubkey: String) : MuteTag("p", pubkey)
class Word(word: String) : MuteTag("word", word)
class Thread(threadId: String) : MuteTag("thread", threadId)
companion object {
fun parse(tag: Array<String>): MuteTag? {
return when (tag.getOrNull(0)) {
"e" -> tag.getOrNull(1)?.let { Event(it) }
"p" -> tag.getOrNull(1)?.let { Profile(it) }
"word" -> tag.getOrNull(1)?.let { Word(it) }
"thread" -> tag.getOrNull(1)?.let { Thread(it) }
else -> null
}
}
}
fun toArray(): Array<String> {
return arrayOf(nameOrNull ?: "", valueOrNull ?: "")
}
}
```
**Pattern:**
- Common base class with shared properties
- Each variant represents different tag type
- Factory method `parse()` for parsing
- `toArray()` for serialization
### BookmarkIdTag (Sealed Class)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/BookmarkIdTag.kt`
```kotlin
sealed class BookmarkIdTag {
abstract val id: String
abstract val marker: String?
data class Event(override val id: String, override val marker: String?) : BookmarkIdTag()
data class Profile(override val id: String, override val marker: String?) : BookmarkIdTag()
data class Address(override val id: String, override val marker: String?) : BookmarkIdTag()
companion object {
fun parse(tag: Array<String>): BookmarkIdTag? {
val marker = tag.getOrNull(3)
return when (tag.getOrNull(0)) {
"e" -> tag.getOrNull(1)?.let { Event(it, marker) }
"p" -> tag.getOrNull(1)?.let { Profile(it, marker) }
"a" -> tag.getOrNull(1)?.let { Address(it, marker) }
else -> null
}
}
}
}
```
**Pattern:**
- Abstract properties in sealed class
- Data classes implement abstract properties
- Parse factory returns sealed variant
---
## Exception Hierarchies
### SignerExceptions (Sealed Class)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt`
```kotlin
sealed class SignerExceptions(message: String) : Exception(message) {
class UnableToSign(message: String) : SignerExceptions(message)
class UnableToDecrypt(message: String) : SignerExceptions(message)
class UnableToEncrypt(message: String) : SignerExceptions(message)
class UnableToGetPublicKey(message: String) : SignerExceptions(message)
}
```
**Pattern:**
- Sealed exception hierarchy
- Extends `Exception` base class
- Type-safe error handling
**Usage:**
```kotlin
try {
signer.sign(event)
} catch (e: SignerExceptions) {
when (e) {
is SignerExceptions.UnableToSign -> logger.error("Signing failed: ${e.message}")
is SignerExceptions.UnableToDecrypt -> logger.error("Decryption failed: ${e.message}")
is SignerExceptions.UnableToEncrypt -> logger.error("Encryption failed: ${e.message}")
is SignerExceptions.UnableToGetPublicKey -> logger.error("No public key: ${e.message}")
}
}
```
---
## Sealed Class vs Sealed Interface
### When to Use Sealed Class
**Examples from codebase:**
1. **AccountState** - State variants with different data
2. **VerificationState** - State machine
3. **MuteTag** - Tag variants with common base properties
4. **SignerExceptions** - Exception hierarchy
**Characteristics:**
- Need common constructor parameters
- Single inheritance only
- State variants
- Exception hierarchies
### When to Use Sealed Interface
**Examples from codebase:**
1. **SignerResult<T>** - Generic result types needing variance
2. **RelayUrlNormalizer.Result** - Binary result with no shared state
**Characteristics:**
- Need generics with variance (`out`, `in`)
- No common state needed
- Multiple inheritance possible
- Contract/capability representation
---
## Patterns
### Pattern: State Machine
```kotlin
sealed class ConnectionState {
data object Disconnected : ConnectionState()
data object Connecting : ConnectionState()
data class Connected(val relay: String) : ConnectionState()
data class Failed(val error: String) : ConnectionState()
}
// Allowed transitions
fun transition(from: ConnectionState, event: Event): ConnectionState {
return when (from) {
is ConnectionState.Disconnected -> {
when (event) {
is Event.Connect -> ConnectionState.Connecting
else -> from
}
}
is ConnectionState.Connecting -> {
when (event) {
is Event.Success -> ConnectionState.Connected(event.relay)
is Event.Error -> ConnectionState.Failed(event.message)
is Event.Cancel -> ConnectionState.Disconnected
else -> from
}
}
is ConnectionState.Connected -> {
when (event) {
is Event.Disconnect -> ConnectionState.Disconnected
is Event.Error -> ConnectionState.Failed(event.message)
else -> from
}
}
is ConnectionState.Failed -> {
when (event) {
is Event.Retry -> ConnectionState.Connecting
is Event.Cancel -> ConnectionState.Disconnected
else -> from
}
}
}
}
```
### Pattern: Result Type
```kotlin
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Exception) : Result<Nothing>
data object Loading : Result<Nothing>
}
// Extension functions
fun <T> Result<T>.getOrNull(): T? = when (this) {
is Result.Success -> data
else -> null
}
fun <T> Result<T>.getOrThrow(): T = when (this) {
is Result.Success -> data
is Result.Error -> throw exception
is Result.Loading -> error("Still loading")
}
fun <T, R> Result<T>.map(transform: (T) -> R): Result<R> = when (this) {
is Result.Success -> Result.Success(transform(data))
is Result.Error -> this
is Result.Loading -> Result.Loading
}
```
### Pattern: Tagged Union (Discriminated Union)
```kotlin
sealed class Command {
data class SendEvent(val event: Event) : Command()
data class Subscribe(val filters: List<Filter>) : Command()
data class Unsubscribe(val subId: String) : Command()
data object Close : Command()
fun toJson(): String = when (this) {
is SendEvent -> """["EVENT",${event.toJson()}]"""
is Subscribe -> """["REQ","sub",${filters.joinToString { it.toJson() }}]"""
is Unsubscribe -> """["CLOSE","$subId"]"""
is Close -> """["CLOSE"]"""
}
}
```
### Pattern: Nested Sealed Hierarchies
```kotlin
sealed interface UiState {
sealed interface Loading : UiState {
data object Initial : Loading
data class Refreshing(val currentData: List<Item>) : Loading
}
sealed interface Content : UiState {
data class Success(val data: List<Item>) : Content
data object Empty : Content
}
sealed interface Error : UiState {
data class Network(val message: String) : Error
data class Server(val code: Int, val message: String) : Error
}
}
// Usage
fun renderUi(state: UiState) {
when (state) {
is UiState.Loading.Initial -> showFullScreenLoader()
is UiState.Loading.Refreshing -> showRefreshIndicator(state.currentData)
is UiState.Content.Success -> showList(state.data)
is UiState.Content.Empty -> showEmptyState()
is UiState.Error.Network -> showNetworkError(state.message)
is UiState.Error.Server -> showServerError(state.code, state.message)
}
}
```
---
## All Sealed Types in Quartz
**Complete list of sealed types found in codebase:**
### Commons
- AccountState (class)
### Quartz
- BaseZapSplitSetup (class)
- MuteTag (class)
- BookmarkIdTag (class)
- SignerResult (interface)
- VerificationState (class)
- CacheResults (class)
- SignerExceptions (class)
- RelayUrlNormalizer.Result (interface)
**Total:** 8 sealed types (7 classes, 1 interface)
---
## Decision Tree
```
Need to represent variants of a concept?
YES → Use sealed type
NO → Regular class/interface
Variants have different data?
YES → sealed class or sealed interface
NO → enum (if simple constants)
Need generics with variance (out/in)?
YES → sealed interface
NO → sealed class (simpler)
Need common constructor/properties?
YES → sealed class
NO → sealed interface
Need multiple inheritance?
YES → sealed interface
NO → Either works
Representing state machine?
→ sealed class (state transitions)
Representing result/error types?
→ sealed interface (if generic, else class)
Representing tag/command variants?
→ sealed class (common structure)
```
---
## References
- [Sealed Classes | Kotlin Docs](https://kotlinlang.org/docs/sealed-classes.html)
- [Effective Kotlin: Sealed Classes](https://kt.academy/article/ek-sealed-classes)
- [Complete Guide: Sealed Classes & Interfaces 2025](https://proandroiddev.com/complete-technical-guide-sealed-classes-sealed-interfaces-enums-in-kotlin-28ffc39116df)

View File

@@ -0,0 +1,402 @@
---
name: kotlin-multiplatform
description: |
Platform abstraction decision-making for Amethyst KMP project. Guides when to abstract vs keep platform-specific,
source set placement (commonMain, jvmAndroid, platform-specific), expect/actual patterns. Covers primary targets
(Android, JVM/Desktop, iOS) with web/wasm future considerations. Integrates with gradle-expert for dependency issues.
Triggers on: abstraction decisions ("should I share this?"), source set placement questions, expect/actual creation,
build.gradle.kts work, incorrect placement detection, KMP dependency suggestions.
---
# Kotlin Multiplatform: Platform Abstraction Decisions
Expert guidance for KMP architecture in Amethyst - deciding what to share vs keep platform-specific.
## When to Use This Skill
Making platform abstraction decisions:
- "Should I create expect/actual or keep Android-only?"
- "Can I share this ViewModel logic?"
- "Where does this crypto/JSON/network implementation belong?"
- "This uses Android Context - can it be abstracted?"
- "Is this code in the wrong module?"
- Preparing for iOS/web/wasm targets
- Detecting incorrect placements
## Abstraction Decision Tree
**Central question:** "Should this code be reused across platforms?"
Follow this decision path (< 1 minute):
```
Q: Is it used by 2+ platforms?
├─ NO → Keep platform-specific
│ Example: Android-only permission handling
└─ YES → Continue ↓
Q: Is it pure Kotlin (no platform APIs)?
├─ YES → commonMain
│ Example: Nostr event parsing, business rules
└─ NO → Continue ↓
Q: Does it vary by platform or by JVM vs non-JVM?
├─ By platform (Android ≠ iOS ≠ Desktop)
│ → expect/actual
│ Example: Secp256k1Instance (uses different security APIs)
├─ By JVM (Android = Desktop ≠ iOS/web)
│ → jvmAndroid
│ Example: Jackson JSON parsing (JVM library)
└─ Complex/UI-related
→ Keep platform-specific
Example: Navigation (Activity vs Window too different)
Final check:
Q: Maintenance cost of abstraction < duplication cost?
├─ YES → Proceed with abstraction
└─ NO → Duplicate (simpler)
```
### Real Examples from Codebase
**Crypto → expect/actual:**
```kotlin
// commonMain - expect declaration
expect object Secp256k1Instance {
fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray
}
// androidMain - uses Android Keystore
// jvmMain - uses Desktop JVM crypto
// iosMain - uses iOS Security framework
```
**Why:** Each platform has different security APIs.
**JSON parsing → jvmAndroid:**
```kotlin
// quartz/build.gradle.kts
val jvmAndroid = create("jvmAndroid") {
api(libs.jackson.module.kotlin)
}
```
**Why:** Jackson is JVM-only, works on Android + Desktop, not iOS/web.
**Navigation → platform-specific:**
- Android: `MainActivity` (Activity + Compose Navigation)
- Desktop: `Window` + sidebar + MenuBar
**Why:** UI paradigms fundamentally different.
## Mental Model: Source Sets as Dependency Graph
Think of source sets as a dependency graph, not folders.
```
┌─────────────────────────────────────────────┐
│ commonMain = Contract (pure Kotlin) │
│ - Business logic, protocol, data models │
│ - No platform APIs │
└────────────┬────────────────────────────────┘
├──────────────────────┬────────────────────
│ │
▼ ▼
┌───────────────────┐ ┌──────────────────┐
│ jvmAndroid │ │ iosMain │
│ JVM libs shared │ │ iOS common │
│ - Jackson │ │ │
│ - OkHttp │ └────┬─────────────┘
└───┬───────────┬───┘ │
│ │ │
▼ ▼ ├─→ iosArm64Main
┌─────────┐ ┌──────────┐ └─→ iosSimulatorArm64Main
│android │ │jvmMain │
│Main │ │(Desktop) │
└─────────┘ └──────────┘
Future: jsMain, wasmMain
```
**Key insight:** jvmAndroid is NOT a platform - it's a shared JVM layer.
## The jvmAndroid Pattern
**Unique to Amethyst.** Shares JVM libraries between Android + Desktop.
### When to Use jvmAndroid
Use jvmAndroid when:
- JVM-specific libraries (Jackson, OkHttp, url-detector)
- Android implementation = Desktop implementation (same JVM)
- Library doesn't work on iOS/web
Do NOT use jvmAndroid for:
- Pure Kotlin code (use commonMain)
- Platform-specific APIs (use androidMain/jvmMain)
- Code that should work on all platforms
### Example from quartz/build.gradle.kts
```kotlin
// Must be defined BEFORE androidMain and jvmMain
val jvmAndroid = create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
api(libs.jackson.module.kotlin) // JSON parsing - JVM only
api(libs.url.detector) // URL extraction - JVM only
implementation(libs.okhttp) // HTTP client - JVM only
}
}
// Both depend on jvmAndroid
jvmMain { dependsOn(jvmAndroid) }
androidMain { dependsOn(jvmAndroid) }
```
**Why Jackson in jvmAndroid, not commonMain?**
- Jackson is JVM-specific library
- Works on Android (runs on JVM)
- Works on Desktop (runs on JVM)
- Does NOT work on iOS (not JVM) or web (not JVM)
**Web/wasm consideration:** For future web support, consider migrating from Jackson kotlinx.serialization (see Target-Specific Guidance).
## What to Abstract vs Keep Platform-Specific
Quick decision guidelines based on codebase patterns:
### Always Abstract
- **Crypto** (Secp256k1, encryption, signing)
- **Core protocol logic** (Nostr events, NIPs)
- **Why:** Needed everywhere, platform security APIs vary
### Often Abstract
- **I/O operations** (file reading, caching)
- **Logging** (platform logging systems differ)
- **Serialization** (if using kotlinx.serialization)
- **Why:** Commonly reused, platform implementations available
### Sometimes Abstract
- **Business logic:** YES - state machines, data processing
- **ViewModels:** YES - state + business logic shareable (StateFlow/SharedFlow)
- **Screen layouts:** NO - platform-native (Window vs Activity)
- **Why:** ViewModels contain platform-agnostic state; Screens render differently per platform
### Rarely Abstract
- **Complex UI components** (composables with heavy platform dependencies)
- **Why:** Platform paradigms can differ significantly
### Never Abstract
- **Navigation** (Activity vs Window fundamentally different)
- **Permissions** (Android vs iOS APIs incompatible)
- **Platform UX patterns**
- **Why:** Too platform-specific, abstraction creates leaky APIs
### Evidence from shared-ui-analysis.md
| Component | Shared? | Rationale |
|-----------|---------|-----------|
| PubKeyFormatter, ZapFormatter | YES | Pure Kotlin, no platform APIs |
| TimeAgoFormatter | ABSTRACTED | Needs StringProvider for localized strings |
| ViewModels (state + logic) | YES | StateFlow/SharedFlow platform-agnostic, Compose Multiplatform lifecycle compatible |
| Screen layouts (Scaffold, nav) | NO | Window vs Activity, sidebar vs bottom nav fundamentally different |
| Image loading (Coil) | ABSTRACTED | Coil 3.x supports KMP, needs expect/actual wrapper |
## expect/actual Mechanics
**When to use:** Code needed by 2+ platforms, varies by platform.
### Pattern Categories from Codebase
**Objects (singletons):**
```kotlin
// 24 expect declarations found, common pattern:
expect object Secp256k1Instance { ... }
expect object Log { ... }
expect object LibSodiumInstance { ... }
```
**Classes (instantiable):**
```kotlin
expect class AESCBC { ... }
expect class DigestInstance { ... }
```
**Functions (utilities):**
```kotlin
expect fun platform(): String
expect fun currentTimeSeconds(): Long
```
**See** [references/expect-actual-catalog.md](references/expect-actual-catalog.md) for complete catalog with rationale.
## Target-Specific Guidance
### Android, JVM (Desktop), iOS - Current Primary Targets
**Status:** Mature patterns, stable APIs
**Android (androidMain):**
- Uses Android framework (Activity, Context, etc.)
- secp256k1-kmp-jni-android for crypto
- AndroidX libraries
**Desktop JVM (jvmMain):**
- Uses Compose Desktop (Window, MenuBar, etc.)
- secp256k1-kmp-jni-jvm for crypto
- Pure JVM libraries
**iOS (iosMain):**
- Active development, framework configured
- Architecture targets: macosArm64Main, iosArm64Main, iosSimulatorArm64Main
- Platform APIs via platform.posix, Security framework
### Web, wasm - Future Targets
**Status:** Not yet implemented, consider for future-proofing
**Constraints to know:**
- No platform.posix (file I/O different)
- No JVM libraries (Jackson, OkHttp won't work)
- Different async model (JS event loop vs threads)
**Future-proofing tips:**
1. Prefer pure Kotlin in commonMain
2. Use kotlinx.* libraries:
- kotlinx.serialization instead of Jackson
- ktor instead of OkHttp (ktor supports web)
- kotlinx.datetime instead of custom date handling
3. Avoid platform.posix for file operations
4. Test abstractions work without JVM assumptions
**Example migration path:**
```kotlin
// Current: jvmAndroid (JVM-only)
api(libs.jackson.module.kotlin)
// Future: commonMain (all platforms)
api(libs.kotlinx.serialization.json)
```
## Integration: When to Invoke Other Skills
### Invoke gradle-expert
Trigger gradle-expert skill when encountering:
- Dependency conflicts (e.g., secp256k1-android vs secp256k1-jvm version mismatch)
- Build errors related to source sets
- Version catalog issues (libs.versions.toml)
- "Duplicate class" errors
- Performance/build time issues
**Example trigger:**
```
Error: Duplicate class found: fr.acinq.secp256k1.Secp256k1
```
Invoke gradle-expert for dependency conflict resolution.
### Flags to Raise
**Platform code in commonMain:**
```kotlin
// ❌ INCORRECT - Android API in commonMain
expect fun getContext(): Context // Context is Android-only!
```
Flag: "Android API in commonMain won't compile on other platforms"
**Duplicated business logic:**
```kotlin
// ❌ INCORRECT - Same logic in both
// androidMain/.../CryptoUtils.kt
fun validateSignature(...) { ... }
// jvmMain/.../CryptoUtils.kt
fun validateSignature(...) { ... } // Duplicated!
```
Flag: "Business logic duplicated, should be in commonMain or expect/actual"
**Reinventing wheel - suggest KMP alternatives:**
- Custom date/time kotlinx.datetime
- OkHttp ktor (supports web)
- Jackson kotlinx.serialization
- Custom UUID kotlinx.uuid (when stable)
## Common Pitfalls
### 1. Over-Abstraction
**Problem:** Creating expect/actual for UI components
```kotlin
// ❌ BAD
expect fun NavigationComponent(...)
```
**Why:** Navigation paradigms too different (Activity vs Window)
**Fix:** Keep platform-specific, accept duplication
### 2. Under-Sharing
**Problem:** Duplicating business logic across platforms
```kotlin
// ❌ BAD - duplicated in androidMain and jvmMain
fun parseNostrEvent(json: String): Event { ... }
```
**Why:** Bug fixes need to be applied twice, tests duplicated
**Fix:** Move to commonMain (pure Kotlin) or create expect/actual
### 3. Leaky Abstractions
**Problem:** Platform code in commonMain
```kotlin
// commonMain - ❌ BAD
import android.content.Context // Won't compile on iOS!
```
**Fix:** Use expect/actual or dependency injection
### 4. Premature Abstraction
**Problem:** Creating expect/actual before second platform needs it
```kotlin
// ❌ BAD - only used on Android currently
expect fun showNotification(...)
```
**Why:** Wrong abstraction boundaries, wasted effort
**Fix:** Wait until iOS actually needs it, then abstract
### 5. Wrong Source Set
**Problem:** JVM libraries in commonMain
```kotlin
// commonMain - ❌ BAD
import com.fasterxml.jackson.databind.ObjectMapper
```
**Why:** Jackson won't compile on iOS/web
**Fix:** Move to jvmAndroid or migrate to kotlinx.serialization
## Quick Reference
| Code Type | Recommended Location | Reason |
|-----------|---------------------|--------|
| Pure Kotlin business logic | commonMain | Works everywhere |
| Nostr protocol, NIPs | commonMain | Core logic, no platform APIs |
| JVM libs (Jackson, OkHttp) | jvmAndroid | Android + Desktop only |
| Crypto (varies by platform) | expect in commonMain, actual in platforms | Different security APIs per platform |
| I/O, logging | expect in commonMain, actual in platforms | Platform implementations differ |
| State (business logic) | commonMain or commons/jvmAndroid | Reusable StateFlow patterns |
| **ViewModels** | **commons/commonMain/viewmodels/** | **StateFlow/SharedFlow + logic shareable, Compose MP lifecycle compatible** |
| UI formatters (pure) | commons/commonMain | Reusable, no dependencies |
| UI components (simple) | commons/commonMain | Cards, buttons, dialogs |
| **Screen layouts** | **Platform-specific** | **Window vs Activity, sidebar vs bottom nav** |
| Navigation | Platform-specific only | Activity vs Window too different |
| Permissions | Platform-specific only | APIs incompatible |
| Platform UX (menus, etc.) | Platform-specific only | Native feel required |
## See Also
- [references/abstraction-examples.md](references/abstraction-examples.md) - Good/bad abstraction examples with rationale
- [references/source-set-hierarchy.md](references/source-set-hierarchy.md) - Visual hierarchy with Amethyst examples
- [references/expect-actual-catalog.md](references/expect-actual-catalog.md) - All 24 expect/actual pairs with "why abstracted"
- [references/target-compatibility.md](references/target-compatibility.md) - Platform constraints and future-proofing
## Scripts
- `scripts/validate-kmp-structure.sh` - Detect incorrect placements, validate source sets
- `scripts/suggest-kmp-dependency.sh` - Suggest KMP library alternatives (ktor, kotlinx.serialization, etc.)

View File

@@ -0,0 +1,311 @@
# Abstraction Examples from Amethyst Codebase
Real examples of abstraction decisions with rationale.
## Good Abstractions (Why They Work)
### 1. Secp256k1Instance - Crypto Signing
**Location:** expect in commonMain, actual in androidMain/jvmMain/iosMain
**Code:**
```kotlin
// quartz/src/commonMain/.../Secp256k1Instance.kt
expect object Secp256k1Instance {
fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray
fun verifySchnorr(signature: ByteArray, hash: ByteArray, pubKey: ByteArray): Boolean
}
```
**Why abstracted:**
- Used by all platforms (Android, Desktop, iOS)
- Security APIs fundamentally different:
- Android: secp256k1-kmp-jni-android (Android Keystore integration)
- Desktop: secp256k1-kmp-jni-jvm (pure JVM crypto)
- iOS: Native Security framework
- Core protocol requirement (Nostr signatures)
**Decision rationale:** Always abstract crypto - varies by platform security APIs, critical for all platforms.
---
### 2. Log - Platform Logging
**Location:** expect object in commonMain
**Code:**
```kotlin
// quartz/src/commonMain/.../Log.kt
expect object Log {
fun d(tag: String, message: String)
fun w(tag: String, message: String, throwable: Throwable?)
fun e(tag: String, message: String, throwable: Throwable?)
}
```
**Why abstracted:**
- Used throughout quartz module (protocol library)
- Logging systems differ:
- Android: android.util.Log
- Desktop: println or logging framework
- iOS: NSLog or OSLog
- Simple interface, easy to implement
**Decision rationale:** Often abstract logging - platform systems differ, widely used, simple interface.
---
### 3. Platform Utils - Time & Platform Name
**Location:** expect functions in commonMain
**Code:**
```kotlin
// quartz/src/commonMain/.../Platform.kt
expect fun platform(): String
expect fun currentTimeSeconds(): Long
```
**Why abstracted:**
- Used by Nostr event creation (timestamps)
- Platform name for debugging
- Simple utilities, clear platform boundary
**Decision rationale:** Platform utilities are good abstraction candidates - simple, useful everywhere.
---
### 4. Jackson JSON (jvmAndroid Pattern)
**Location:** jvmAndroid source set
**Code:**
```kotlin
// quartz/build.gradle.kts
val jvmAndroid = create("jvmAndroid") {
api(libs.jackson.module.kotlin) // JVM-only library
}
```
**Why jvmAndroid (not commonMain):**
- Jackson is JVM-specific library
- Works on Android (JVM) + Desktop (JVM)
- Does NOT work on iOS (not JVM) or web (not JVM)
- Performance-critical JSON parsing
**Decision rationale:** Use jvmAndroid for JVM libraries shared between Android and Desktop.
**Future consideration:** For web support, migrate to kotlinx.serialization (works on all platforms).
---
## Bad/Over-Abstractions (Why They Failed)
### 1. Navigation Abstraction (Avoided)
**What COULD have been done:**
```kotlin
// ❌ Over-abstraction - DON'T DO THIS
expect interface Navigator {
fun navigate(route: String)
fun popBackStack()
}
```
**Why NOT abstracted:**
- Navigation paradigms fundamentally different:
- Android: Activity + Compose Navigation + back stack
- Desktop: Window + screen state + no back stack concept
- Complex APIs don't map well
- Creates leaky abstraction
**Actual approach:** Keep platform-specific
- Android: `INav` interface + Compose Navigation
- Desktop: Simple screen enum + state
**Decision rationale:** Never abstract navigation - platforms too different, abstraction would be leaky.
---
### 2. String Resources (Abstraction Planned)
**Current state:** Platform-specific (over-duplication)
**Problem:**
```kotlin
// Android uses R.string.*
Text(stringResource(R.string.post_not_found))
// Desktop uses hardcoded strings
Text("Post not found")
```
**Why NOT yet abstracted:** Waiting for second platform to fully implement UI, then will create StringProvider interface.
**Planned abstraction:**
```kotlin
// commonMain
interface StringProvider {
fun get(key: String): String
}
// androidMain
class AndroidStringProvider(context: Context): StringProvider { ... }
// jvmMain
class DesktopStringProvider: StringProvider { ... }
```
**Lesson:** Don't abstract prematurely - wait until second platform needs it, then create proper abstraction.
---
## Platform-Specific Code (Why NOT Abstracted)
### 1. MainActivity (Android Activity)
**Location:** amethyst/src/main/.../MainActivity.kt
**Code:**
```kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
setContent {
AmethystTheme {
AccountScreen(accountStateViewModel)
}
}
}
}
```
**Why platform-specific:**
- AppCompatActivity is Android framework
- Activity lifecycle unique to Android
- enableEdgeToEdge() is Android-specific API
- No equivalent on Desktop (uses Window)
**Decision rationale:** Android Activity is platform-specific by nature.
---
### 2. Desktop Window & MenuBar
**Location:** desktopApp/src/jvmMain/.../Main.kt
**Code:**
```kotlin
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Amethyst"
) {
MenuBar {
Menu("File") {
Item("New Note", onClick = { ... }, shortcut = KeyShortcut(Key.N, ctrl = true))
Item("Quit", onClick = ::exitApplication)
}
}
NavigationRail { ... } // Sidebar navigation
}
}
```
**Why platform-specific:**
- Window, MenuBar, NavigationRail are Compose Desktop APIs
- Keyboard shortcuts (Ctrl+N) are desktop paradigm
- Sidebar navigation vs Android bottom nav
- No equivalent on Android
**Decision rationale:** Desktop UX patterns are platform-specific by nature.
---
### 3. AccountViewModel (Android ViewModel)
**Location:** amethyst/.../AccountStateViewModel.kt
**Partially abstracted:**
- Business logic → IAccountState interface (can be shared)
- UI state + lifecycle → AndroidX ViewModel (Android-only)
**Why not fully abstracted:**
- AndroidX ViewModel lifecycle tied to Android
- Desktop doesn't need ViewModel (simpler state management)
- SavedStateHandle is Android-specific
**Decision rationale:** Extract business logic to interface, keep UI state platform-specific.
---
## Migration Examples (Android → Shared)
### Example 1: PubKeyFormatter (Pure Kotlin)
**Before:**
```kotlin
// amethyst/ui/note/PubKeyFormatter.kt
fun String.toDisplayHexKey(): String {
return "${take(8)}:${takeLast(8)}"
}
```
**After:**
```kotlin
// commons/commonMain/formatters/PubKeyFormatter.kt
fun String.toDisplayHexKey(): String {
return "${take(8)}:${takeLast(8)}"
}
// Both apps use it
import com.vitorpamplona.amethyst.commons.formatters.toDisplayHexKey
```
**Why successful:**
- Pure Kotlin, no platform dependencies
- Widely reused
- Simple utility function
---
### Example 2: TimeAgoFormatter (Requires Abstraction)
**Problem:**
```kotlin
// Uses Android R.string.*
fun timeAgo(timestamp: Long): String {
return context.getString(R.string.x_minutes_ago, minutes)
}
```
**Solution:** Abstract string resources
```kotlin
// commonMain
fun timeAgo(timestamp: Long, stringProvider: StringProvider): String {
return stringProvider.get("x_minutes_ago", minutes)
}
// androidMain
stringProvider = AndroidStringProvider(context)
// jvmMain
stringProvider = DesktopStringProvider()
```
**Why successful:** Clear platform boundary (string resources), useful on both platforms.
---
## Decision Pattern Summary
| Pattern | Abstract? | Why |
|---------|-----------|-----|
| Pure Kotlin utilities | ✅ YES | No platform dependency, easy |
| Crypto APIs | ✅ YES (expect/actual) | Platform security APIs differ |
| JVM libraries | ⚠️ jvmAndroid | Works on Android+Desktop only |
| UI components (simple) | ✅ YES | Composables work cross-platform |
| UI components (complex) | ❌ NO | Platform dependencies |
| Navigation | ❌ NO | Paradigms too different |
| ViewModels | ⚠️ PARTIAL | Business logic yes, UI state no |
| String resources | ⚠️ PLANNED | Needs abstraction layer |

View File

@@ -0,0 +1,163 @@
# Complete expect/actual Catalog
All 24 expect declarations in Amethyst quartz module with rationale.
| # | Name | Type | Purpose | Why Abstracted | Files |
|---|------|------|---------|----------------|-------|
| 1 | AESCBC | class | AES CBC encryption | Platform crypto APIs differ | quartz/.../ciphers/AESCBC.kt |
| 2 | AESGCM | class | AES GCM encryption | Platform crypto APIs differ | quartz/.../ciphers/AESGCM.kt |
| 3 | DigestInstance | class | Hash digests (SHA256) | Platform implementations | quartz/.../diggest/DigestInstance.kt |
| 4 | MacInstance | class | MAC (HMAC) operations | Platform crypto APIs | quartz/.../mac/MacInstance.kt |
| 5 | Sha256 | object | SHA256 hashing | Platform-specific optimizations | quartz/.../sha256/Sha256.kt |
| 6 | LargeCache | object | Large object caching | Platform storage APIs differ | quartz/.../cache/LargeCache.kt |
| 7 | UriParser | object | URI parsing | Platform URL APIs differ | quartz/.../UriParser.kt |
| 8 | UrlEncoder | object | URL encoding | Platform encoding differs | quartz/.../UrlEncoder.kt |
| 9 | Urls | object | URL utilities | Platform URL handling | quartz/.../Urls.kt |
| 10 | Platform | functions | platform(), currentTimeSeconds() | Platform name & time APIs | quartz/.../Platform.kt |
| 11 | Rfc3986 | object | RFC 3986 URL normalization | Used in jvmAndroid | quartz/.../Rfc3986.kt |
| 12 | Secp256k1Instance | object | Bitcoin crypto (secp256k1) | Different libs per platform | quartz/.../Secp256k1Instance.kt |
| 13 | SecureRandom | object | Cryptographically secure random | Platform random APIs differ | quartz/.../SecureRandom.kt |
| 14 | StringExt | functions | String utilities | Platform string handling | quartz/.../StringExt.kt |
| 15 | UnicodeNormalizer | object | Unicode normalization | Platform text APIs | quartz/.../UnicodeNormalizer.kt |
| 16 | GZip | object | GZip compression | Platform compression APIs | quartz/.../GZip.kt |
| 17 | LibSodiumInstance | object | NaCl/libsodium (NIP-44 encryption) | Different libs per platform | quartz/.../LibSodiumInstance.kt |
| 18 | Log | object | Logging | Platform logging systems | quartz/.../Log.kt |
| 19 | BigDecimal | class | Arbitrary precision decimal | Not in Kotlin common stdlib | quartz/.../BigDecimal.kt |
| 20 | BitSet | class | Bit set data structure | Not in Kotlin common stdlib | quartz/.../BitSet.kt |
| 21 | ServerInfoParser | object | Server info parsing (NIP-96) | Platform JSON parsing | quartz/.../nip96.../ServerInfoParser.kt |
| 22 | EventHasherSerializer | object | Event hashing | Platform-specific optimizations | quartz/.../nip01Core.../EventHasherSerializer.kt |
| 23 | OptimizedJsonMapper | object | JSON mapping | Platform JSON libraries | quartz/.../nip01Core.../OptimizedJsonMapper.kt |
| 24 | Address | data class | Address data structure | Platform-specific string handling | quartz/.../nip01Core.../Address.kt |
## Pattern Analysis
### Objects (Singletons) - 19 total
Most common pattern for platform-specific singletons:
- Crypto: Secp256k1Instance, LibSodiumInstance, Sha256
- I/O: UriParser, UrlEncoder, GZip
- Utils: Log, Platform, SecureRandom
### Classes (Instantiable) - 4 total
For objects that need to maintain state:
- AESCBC, AESGCM (cipher state)
- DigestInstance, MacInstance (hash/MAC state)
- BigDecimal, BitSet (data structures)
### Functions - 2 total
Simple utilities:
- platform(), currentTimeSeconds()
## Why Abstracted Categories
### Crypto (8 items)
**Always abstract:** Security APIs fundamentally different across platforms
- Android: Android Keystore, secp256k1-android
- Desktop: JVM crypto, secp256k1-jvm
- iOS: Security framework, native crypto
### I/O & Platform Utils (7 items)
**Often abstract:** File systems, URLs, compression differ
- Platform storage APIs
- URL handling varies
- Compression libraries differ
### Data Structures (2 items)
**Abstract when missing:** Not available in Kotlin common stdlib
- BigDecimal, BitSet not in common
### JSON/Parsing (3 items)
**Platform-specific optimization:** Uses platform JSON libraries
- Android/Desktop: Jackson (via jvmAndroid)
- iOS: Native parsers
### Logging (1 item)
**Always abstract:** Platform logging systems differ
- Android: android.util.Log
- Desktop: println or logging framework
- iOS: NSLog or OSLog
## Actual Implementation Examples
### Simple Object Pattern
```kotlin
// commonMain
expect object Log {
fun d(tag: String, message: String)
}
// androidMain
actual object Log {
actual fun d(tag: String, message: String) {
android.util.Log.d(tag, message)
}
}
// jvmMain
actual object Log {
actual fun d(tag: String, message: String) {
println("[$tag] $message")
}
}
```
### Complex Object with Dependencies
```kotlin
// commonMain
expect object Secp256k1Instance {
fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray
}
// androidMain - uses JNI bindings
actual object Secp256k1Instance {
actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray {
return fr.acinq.secp256k1.Secp256k1.signSchnorr(data, privKey, null)
}
}
// jvmMain - different JNI library
actual object Secp256k1Instance {
actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray {
return fr.acinq.secp256k1.Secp256k1.signSchnorr(data, privKey, null)
}
}
// iosMain - native iOS implementation
actual object Secp256k1Instance {
actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray {
// Uses iOS Security framework or native lib
}
}
```
### Class Pattern
```kotlin
// commonMain
expect class BigDecimal {
constructor(value: String)
fun add(other: BigDecimal): BigDecimal
override fun toString(): String
}
// jvmAndroid (works on Android + Desktop)
actual typealias BigDecimal = java.math.BigDecimal
// iosMain
actual class BigDecimal {
private val value: NSDecimalNumber
actual constructor(value: String) {
this.value = NSDecimalNumber(value)
}
// ... implementation
}
```
## Decision Patterns
Ask for each declaration:
1. **Used by 2+ platforms?** → YES (otherwise platform-specific)
2. **Pure Kotlin possible?** → NO (otherwise commonMain)
3. **Varies by platform?** → YES (expect/actual)
4. **JVM-only library?** → NO (otherwise jvmAndroid)

View File

@@ -0,0 +1,330 @@
# Source Set Hierarchy in Amethyst
Visual guide to source set organization with concrete examples from the codebase.
## Hierarchy Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ commonMain │
│ Pure Kotlin, no platform APIs │
│ Examples: │
│ - Nostr event parsing (TextNoteEvent, MetadataEvent) │
│ - Business logic (data validation, crypto algorithms) │
│ - Data models (@Immutable data classes) │
│ Dependencies: kotlin-stdlib, kotlinx-coroutines │
└──────────────────────┬──────────────────────────────────────┘
┌────────────┴────────────┬───────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌───────────────────┐ ┌──────────────┐
│ jvmAndroid │ │ iosMain │ │ Future: │
│ JVM libraries │ │ iOS common │ │ jsMain │
│ Examples: │ │ Examples: │ │ wasmMain │
│ - Jackson JSON │ │ - Platform API │ └──────────────┘
│ - OkHttp HTTP │ │ - Actuals for │
│ - url-detector │ │ crypto/I/O │
│ Dependencies: │ │ Dependencies: │
│ - Jackson │ │ - Platform libs │
│ - OkHttp │ └───────┬───────────┘
└────┬─────────┬───┘ │
│ │ │
│ │ ├─→ iosArm64Main (device ARM64)
│ │ └─→ iosSimulatorArm64Main (Apple Silicon)
▼ ▼
┌──────────┐ ┌───────────┐
│android │ │ jvmMain │
│Main │ │ (Desktop) │
│Examples: │ │ Examples: │
│- Activity│ │- Window │
│- ViewModel│ │- MenuBar │
│- Android │ │- Desktop │
│ APIs │ │ Compose │
│Deps: │ │ Deps: │
│- secp256k│ │- secp256k │
│ 1-android│ │ 1-jvm │
│- androidx│ │- Compose │
│ │ │ Desktop │
└──────────┘ └───────────┘
```
## Dependency Flow
```
Code in commonMain
↓ can use
Nothing (only Kotlin stdlib)
Code in jvmAndroid
↓ can use
commonMain + JVM libraries (Jackson, OkHttp)
Code in androidMain
↓ can use
commonMain + jvmAndroid + Android framework
Code in jvmMain
↓ can use
commonMain + jvmAndroid + JVM + Compose Desktop
Code in iosMain
↓ can use
commonMain + iOS platform APIs
```
## Real Examples from Amethyst
### commonMain - Pure Kotlin
**File:** `quartz/src/commonMain/.../TextNoteEvent.kt`
```kotlin
@Immutable
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseThreadedEvent(...) {
// Pure Kotlin - works everywhere
override fun indexableContent() = "Subject: " + subject() + "\n" + content
}
```
**Why commonMain:**
- Pure Kotlin code
- No platform APIs
- Data class with business logic
- Needed by all platforms
---
### jvmAndroid - JVM Libraries
**File:** `quartz/build.gradle.kts`
```kotlin
val jvmAndroid = create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
// Normalizes URLs
api(libs.rfc3986.normalizer)
// Performant Parser of JSONs into Events
api(libs.jackson.module.kotlin)
// Parses URLs from Text
api(libs.url.detector)
// Websockets API
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
}
}
jvmMain { dependsOn(jvmAndroid) } // Desktop gets Jackson, OkHttp
androidMain { dependsOn(jvmAndroid) } // Android gets Jackson, OkHttp
```
**Why jvmAndroid:**
- Jackson, OkHttp are JVM-only libraries
- Works on Android (JVM) and Desktop (JVM)
- Does NOT work on iOS (not JVM) or web (not JVM)
**Usage in code:**
```kotlin
// Can use Jackson in jvmAndroid source set
val mapper = ObjectMapper()
val event = mapper.readValue(json, Event::class.java)
```
---
### androidMain - Android Platform
**File:** `amethyst/src/main/.../MainActivity.kt`
```kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() // Android API
super.onCreate(savedInstanceState)
setContent { // Compose for Android
AmethystTheme {
val accountStateViewModel: AccountStateViewModel = viewModel()
AccountScreen(accountStateViewModel)
}
}
}
}
```
**Why androidMain:**
- AppCompatActivity is Android framework
- Activity lifecycle Android-specific
- AndroidX libraries (viewModel())
**Dependencies:**
```kotlin
androidMain {
dependsOn(jvmAndroid) // Gets Jackson, OkHttp
dependencies {
implementation(libs.androidx.core.ktx)
api(libs.secp256k1.kmp.jni.android) // Android crypto
}
}
```
---
### jvmMain - Desktop Platform
**File:** `desktopApp/src/jvmMain/.../Main.kt`
```kotlin
fun main() = application {
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp
)
Window( // Compose Desktop API
onCloseRequest = ::exitApplication,
state = windowState,
title = "Amethyst"
) {
MenuBar { // Desktop-specific
Menu("File") {
Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true))
}
}
NavigationRail { ... } // Sidebar
}
}
```
**Why jvmMain:**
- Window, MenuBar, NavigationRail are Compose Desktop
- Keyboard shortcuts desktop paradigm
- Different UX from Android (sidebar vs bottom nav)
**Dependencies:**
```kotlin
jvmMain {
dependsOn(jvmAndroid) // Gets Jackson, OkHttp
dependencies {
implementation(libs.secp256k1.kmp.jni.jvm) // Desktop crypto
implementation(compose.desktop.currentOs)
}
}
```
---
### iosMain - iOS Platform
**File:** `quartz/build.gradle.kts`
```kotlin
iosMain {
dependsOn(commonMain.get())
dependencies {
// iOS platform dependencies
}
}
val iosArm64Main by getting { dependsOn(iosMain.get()) }
val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) }
```
**Why iosMain:**
- iOS platform APIs
- Native crypto (Security framework)
- Different from Android/Desktop
**Architecture targets:**
- iosArm64Main: Device (iPhone, iPad)
- iosSimulatorArm64Main: Apple Silicon simulator
---
## Build Order Matters
**CRITICAL:** jvmAndroid must be defined BEFORE androidMain and jvmMain:
```kotlin
// ✅ CORRECT ORDER
val jvmAndroid = create("jvmAndroid") { ... }
jvmMain { dependsOn(jvmAndroid) }
androidMain { dependsOn(jvmAndroid) }
// ❌ WRONG - Build error
androidMain { dependsOn(jvmAndroid) } // jvmAndroid not defined yet!
val jvmAndroid = create("jvmAndroid") { ... }
```
See comment in quartz/build.gradle.kts:131:
```kotlin
// Must be defined before androidMain and jvmMain
val jvmAndroid = create("jvmAndroid") { ... }
```
## Choosing the Right Source Set
Decision flowchart:
```
Q: Where should this code go?
├─ Pure Kotlin? (no platform APIs)
│ └─ commonMain
├─ JVM library? (Jackson, OkHttp)
│ └─ jvmAndroid
├─ Android API? (Activity, Context)
│ └─ androidMain
├─ Desktop API? (Window, MenuBar)
│ └─ jvmMain
└─ iOS API? (platform.posix, Security)
└─ iosMain
```
## Future: Web/wasm Source Sets
**Not yet implemented**, but structure would be:
```
commonMain
├─→ jsMain (JavaScript/Web)
│ └─ JS-specific: DOM APIs, fetch
└─→ wasmMain (WebAssembly)
└─ wasm-specific: limited APIs
```
**Constraints:**
- Cannot use jvmAndroid (Jackson, OkHttp)
- Cannot use platform.posix
- Must use pure Kotlin or web-compatible libs (ktor, kotlinx.serialization)
## Summary Table
| Source Set | Extends | Can Use | Example Code |
|------------|---------|---------|--------------|
| commonMain | - | Kotlin stdlib only | TextNoteEvent, business logic |
| jvmAndroid | commonMain | JVM libs (Jackson, OkHttp) | JSON parsing, HTTP |
| androidMain | jvmAndroid | Android framework | Activity, ViewModel |
| jvmMain | jvmAndroid | JVM + Compose Desktop | Window, MenuBar |
| iosMain | commonMain | iOS platform | Security framework |
| iosMain | Simulator (Intel) | Architecture-specific |
| iosArm64Main | iosMain | Device (ARM64) | Architecture-specific |
| jsMain | commonMain | JS/DOM | Web (future) |
| wasmMain | commonMain | wasm APIs | WebAssembly (future) |

View File

@@ -0,0 +1,344 @@
# Target Compatibility Guide
Current targets (Android, JVM/Desktop, iOS) and future targets (web, wasm) with constraints.
## Current Primary Targets
### Android (androidMain)
**Status:** ✅ Mature, production-ready
**Runtime:** JVM (Dalvik/ART)
**Available:**
- Android framework (Activity, Context, Intent, etc.)
- AndroidX libraries (ViewModel, Navigation, etc.)
- JVM libraries via jvmAndroid (Jackson, OkHttp)
- Platform-specific crypto: secp256k1-kmp-jni-android
**Constraints:**
- Mobile UX paradigms (bottom navigation, vertical scroll)
- Touch-first interaction
- Limited screen space
- Battery/performance constraints
**Example code:**
```kotlin
// androidMain
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Android-specific lifecycle
}
}
```
---
### JVM / Desktop (jvmMain)
**Status:** ✅ Active development, functional
**Runtime:** JVM
**Available:**
- Pure JVM libraries
- JVM libraries via jvmAndroid (Jackson, OkHttp)
- Compose Desktop (Window, MenuBar, etc.)
- Platform-specific crypto: secp256k1-kmp-jni-jvm
**Constraints:**
- Desktop UX paradigms (sidebar, menus, keyboard shortcuts)
- Keyboard + mouse interaction
- Larger screen space
- Different navigation patterns (no back stack)
**Example code:**
```kotlin
// jvmMain
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Amethyst"
) {
MenuBar { ... } // Desktop-specific
NavigationRail { ... } // Sidebar
}
}
```
---
### iOS (iosMain + architecture targets)
**Status:** ⚠️ In development, framework configured
**Runtime:** Native iOS
**Source sets:**
- iosMain (common iOS code)
- iosArm64Main (device - iPhone/iPad)
- iosSimulatorArm64Main (Apple Silicon simulator)
**Available:**
- iOS platform APIs (platform.posix, Foundation, etc.)
- Native crypto (Security framework)
- SwiftUI integration (via KMP framework)
**NOT available:**
- JVM libraries (Jackson, OkHttp)
- jvmAndroid source set
- JVM-specific APIs
**Constraints:**
- Mobile UX (similar to Android)
- Swift/Objective-C interop
- XCFramework distribution
- CocoaPods integration
**Example code:**
```kotlin
// iosMain
actual object Secp256k1Instance {
actual fun signSchnorr(...): ByteArray {
// Use iOS Security framework
}
}
```
**XCFramework setup:**
```kotlin
// quartz/build.gradle.kts
kotlin {
listOf(macosArm64(), iosArm64(), iosSimulatorArm64())
.forEach { target ->
target.binaries.framework {
baseName = "quartz-kmpKit"
isStatic = true
}
}
}
```
---
## Future Targets
### Web / JavaScript (jsMain)
**Status:** ❌ Not implemented, consider for future
**Runtime:** JavaScript (browser or Node.js)
**Available:**
- Kotlin/JS stdlib
- JS/DOM APIs
- kotlinx.* libraries (serialization, coroutines, datetime)
- ktor-client (HTTP)
**NOT available:**
- ❌ JVM libraries (Jackson, OkHttp)
- ❌ jvmAndroid source set
- ❌ platform.posix (no file system access like native)
- ❌ Blocking APIs (different async model - JS event loop)
**Constraints:**
- Single-threaded event loop
- No blocking calls
- Different async patterns (Promises, async/await)
- Browser security (CORS, no file system)
**Migration path from current code:**
| Current (jvmAndroid) | Web-compatible alternative |
|---------------------|---------------------------|
| Jackson JSON | kotlinx.serialization |
| OkHttp HTTP | ktor-client |
| java.math.BigDecimal | Kotlin BigDecimal (coming) |
| Blocking I/O | Suspending functions |
**Example migration:**
```kotlin
// Current: jvmAndroid
val mapper = ObjectMapper()
val event = mapper.readValue(json, Event::class.java)
// Future: commonMain (works on web)
val json = Json { ignoreUnknownKeys = true }
val event = json.decodeFromString<Event>(jsonString)
```
---
### WebAssembly (wasmMain)
**Status:** ❌ Not implemented, experimental Kotlin/Wasm
**Runtime:** WebAssembly
**Available:**
- Kotlin/Wasm stdlib
- Limited kotlinx.* libraries
- wasm-specific APIs
**NOT available:**
- ❌ JVM libraries
- ❌ Full platform.posix
- ❌ Many kotlinx libraries (limited wasm support)
**Constraints:**
- Even more limited than JS
- Experimental Kotlin support
- Limited library ecosystem
**Recommendation:** Focus on web (jsMain) first, wasm later.
---
## Cross-Target Compatibility Matrix
| Feature | Android | JVM/Desktop | iOS | Web (JS) | wasm |
|---------|---------|-------------|-----|----------|------|
| Pure Kotlin | ✅ | ✅ | ✅ | ✅ | ✅ |
| kotlinx.coroutines | ✅ | ✅ | ✅ | ✅ | ⚠️ |
| kotlinx.serialization | ✅ | ✅ | ✅ | ✅ | ⚠️ |
| kotlinx.datetime | ✅ | ✅ | ✅ | ✅ | ⚠️ |
| ktor-client | ✅ | ✅ | ✅ | ✅ | ❌ |
| Jackson JSON | ✅ (jvmAndroid) | ✅ (jvmAndroid) | ❌ | ❌ | ❌ |
| OkHttp | ✅ (jvmAndroid) | ✅ (jvmAndroid) | ❌ | ❌ | ❌ |
| platform.posix | ❌ | ❌ | ✅ | ❌ | ⚠️ |
| Compose Multiplatform | ✅ | ✅ | ⚠️ (experimental) | ⚠️ (experimental) | ❌ |
Legend:
- ✅ Full support
- ⚠️ Limited/experimental
- ❌ Not available
---
## Future-Proofing Recommendations
### For Web Compatibility
**DO:**
- ✅ Use kotlinx.serialization instead of Jackson
- ✅ Use ktor-client instead of OkHttp
- ✅ Use kotlinx.datetime instead of java.time
- ✅ Use suspending functions (non-blocking)
- ✅ Keep business logic in commonMain
**DON'T:**
- ❌ Put JVM libraries in commonMain
- ❌ Use platform.posix for critical features
- ❌ Use blocking I/O
- ❌ Depend on threading (use coroutines)
**Example:**
```kotlin
// ❌ NOT web-compatible
// jvmAndroid
fun parseJson(json: String): Event {
val mapper = ObjectMapper() // Jackson - JVM only
return mapper.readValue(json, Event::class.java)
}
// ✅ Web-compatible
// commonMain
@Serializable
data class Event(...)
fun parseJson(json: String): Event {
return Json.decodeFromString<Event>(json) // Works everywhere
}
```
### Current Migration Priorities
**High priority:** (Needed for web)
1. Migrate Jackson → kotlinx.serialization
2. Migrate OkHttp → ktor-client
3. Move business logic to commonMain
**Medium priority:** (Nice to have)
1. Abstract date/time handling → kotlinx.datetime
2. Remove platform.posix usage where possible
3. Use suspending functions over blocking
**Low priority:** (Future optimization)
1. wasm-specific optimizations
2. Platform-specific performance tuning
---
## Platform-Specific Patterns
### Android vs iOS Differences
| Aspect | Android | iOS |
|--------|---------|-----|
| **Activity/ViewController** | Activity | UIViewController |
| **Navigation** | Compose Navigation | UINavigationController |
| **Lifecycle** | onCreate, onResume, etc. | viewDidLoad, viewWillAppear |
| **Permissions** | Runtime permissions | Info.plist + runtime |
| **Crypto** | secp256k1-android | Security framework |
| **Storage** | Room, SharedPreferences | Core Data, UserDefaults |
### Desktop vs Mobile Differences
| Aspect | Desktop | Mobile |
|--------|---------|--------|
| **Navigation** | Sidebar | Bottom nav |
| **Input** | Keyboard + mouse | Touch |
| **Screen** | Large, landscape | Small, portrait |
| **Windows** | Multi-window | Single app |
| **Shortcuts** | Keyboard shortcuts (Ctrl+N) | None |
| **Menus** | MenuBar | Bottom sheets |
---
## Testing Strategy
### Per-Target Testing
**Android:**
- Unit tests: androidTest
- Instrumented: androidInstrumentedTest
- Device/emulator testing
**Desktop:**
- Unit tests: jvmTest
- Manual desktop app testing
**iOS:**
- Unit tests: iosTest (iosArm64Test, etc.)
- Simulator/device testing
**Web (future):**
- Unit tests: jsTest
- Browser testing (Selenium, Playwright)
### Shared Testing
**commonTest:**
- Business logic tests
- Pure Kotlin code
- Works on all platforms
```kotlin
// commonTest
class EventParsingTest {
@Test
fun parseTextNoteEvent() {
// Tests run on all platforms
}
}
```
---
## Summary
**Current Focus:** Android, JVM/Desktop, iOS (active development)
**Future Considerations:** Web (requires migration from Jackson/OkHttp)
**Key Decision:** Prefer kotlinx.* libraries over JVM-specific libs for future web compatibility.

View File

@@ -0,0 +1,166 @@
#!/bin/bash
# Suggests KMP library alternatives for JVM-specific dependencies
set -e
PROJECT_ROOT="${1:-.}"
cd "$PROJECT_ROOT"
echo "=== KMP Dependency Suggestions ==="
echo
# Colors
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
SUGGESTIONS_FOUND=0
# Check for Jackson (suggest kotlinx.serialization)
echo "📦 Checking for Jackson JSON..."
if grep -r "jackson" */build.gradle.kts 2>/dev/null | grep -q "implementation\|api"; then
echo -e "${YELLOW}⚠ Found Jackson dependency${NC}"
echo " Current: Jackson (JVM-only)"
echo -e " ${GREEN}Suggest: kotlinx.serialization${NC} (works on all platforms)"
echo
echo " Migration:"
echo " // Remove:"
echo " api(libs.jackson.module.kotlin)"
echo
echo " // Add to commonMain:"
echo " implementation(libs.kotlinx.serialization.json)"
echo
echo " // Code change:"
echo " // Before (Jackson):"
echo " val mapper = ObjectMapper()"
echo " val event = mapper.readValue(json, Event::class.java)"
echo
echo " // After (kotlinx.serialization):"
echo " @Serializable"
echo " data class Event(...)"
echo " val event = Json.decodeFromString<Event>(json)"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using Jackson (or already using kotlinx.serialization)${NC}"
fi
# Check for OkHttp (suggest ktor)
echo
echo "📦 Checking for OkHttp..."
if grep -r "okhttp" */build.gradle.kts 2>/dev/null | grep -q "implementation\|api"; then
echo -e "${YELLOW}⚠ Found OkHttp dependency${NC}"
echo " Current: OkHttp (JVM-only)"
echo -e " ${GREEN}Suggest: ktor-client${NC} (works on all platforms)"
echo
echo " Migration:"
echo " // Remove:"
echo " implementation(libs.okhttp)"
echo
echo " // Add to commonMain:"
echo " implementation(libs.ktor.client.core)"
echo " // Platform-specific engines:"
echo " // androidMain: implementation(libs.ktor.client.android)"
echo " // jvmMain: implementation(libs.ktor.client.cio)"
echo " // iosMain: implementation(libs.ktor.client.darwin)"
echo
echo " // Code change:"
echo " // Before (OkHttp):"
echo " val client = OkHttpClient()"
echo " val request = Request.Builder().url(url).build()"
echo " val response = client.newCall(request).execute()"
echo
echo " // After (ktor):"
echo " val client = HttpClient()"
echo " val response: String = client.get(url)"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using OkHttp (or already using ktor)${NC}"
fi
# Check for java.time (suggest kotlinx.datetime)
echo
echo "📦 Checking for java.time usage..."
if find */src -name "*.kt" 2>/dev/null | xargs grep -l "import java.time\." >/dev/null 2>&1; then
echo -e "${YELLOW}⚠ Found java.time imports${NC}"
echo " Current: java.time (JVM-only)"
echo -e " ${GREEN}Suggest: kotlinx.datetime${NC} (works on all platforms)"
echo
echo " Migration:"
echo " // Add to commonMain:"
echo " implementation(libs.kotlinx.datetime)"
echo
echo " // Code change:"
echo " // Before (java.time):"
echo " import java.time.Instant"
echo " val now = Instant.now()"
echo
echo " // After (kotlinx.datetime):"
echo " import kotlinx.datetime.Clock"
echo " val now = Clock.System.now()"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using java.time (or already using kotlinx.datetime)${NC}"
fi
# Check for java.math.BigDecimal
echo
echo "📦 Checking for java.math.BigDecimal usage..."
if find */src -name "*.kt" 2>/dev/null | xargs grep -l "import java.math.BigDecimal" >/dev/null 2>&1; then
echo -e "${YELLOW}⚠ Found java.math.BigDecimal imports${NC}"
echo " Current: java.math.BigDecimal (JVM-only)"
echo -e " ${BLUE}Note:${NC} KMP BigDecimal not yet in stable kotlinx"
echo
echo " Options:"
echo " 1. Use expect/actual (current approach in quartz)"
echo " 2. Wait for kotlinx.decimal (proposal stage)"
echo " 3. Use third-party KMP library (e.g., bignum)"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using java.math.BigDecimal directly${NC}"
fi
# Check for platform.posix usage
echo
echo "📦 Checking for platform.posix usage..."
if find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "import platform.posix\." >/dev/null 2>&1; then
echo -e "${YELLOW}⚠ Found platform.posix in commonMain${NC}"
echo " Current: platform.posix (native platforms only, not web)"
echo -e " ${GREEN}Suggest:${NC} Abstract file I/O with expect/actual"
echo
echo " For web compatibility:"
echo " - iOS/Native: platform.posix"
echo " - Web: Use kotlinx-io or ktor file APIs"
echo " - Create expect/actual for file operations"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using platform.posix in commonMain${NC}"
fi
# Summary
echo
echo "=== Summary ==="
if [ "$SUGGESTIONS_FOUND" -eq 0 ]; then
echo -e "${GREEN}✓ No JVM-specific dependencies found!${NC}"
echo " Your code is ready for web/wasm targets."
else
echo -e "${YELLOW}Found $SUGGESTIONS_FOUND suggestion(s) for KMP alternatives${NC}"
echo
echo "Priority recommendations:"
echo " 1. ${GREEN}High:${NC} Jackson → kotlinx.serialization (enables web support)"
echo " 2. ${GREEN}High:${NC} OkHttp → ktor-client (enables web support)"
echo " 3. ${GREEN}Medium:${NC} java.time → kotlinx.datetime"
echo " 4. ${GREEN}Low:${NC} Consider web compatibility for platform.posix usage"
echo
echo "Resources:"
echo " - kotlinx.serialization: https://github.com/Kotlin/kotlinx.serialization"
echo " - ktor: https://ktor.io/docs/client.html"
echo " - kotlinx.datetime: https://github.com/Kotlin/kotlinx-datetime"
fi
exit 0

View File

@@ -0,0 +1,126 @@
#!/bin/bash
# Validates KMP source set structure and detects common issues
set -e
PROJECT_ROOT="${1:-.}"
cd "$PROJECT_ROOT"
echo "=== Validating KMP Structure ==="
echo
# Colors for output
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
ISSUES_FOUND=0
# Check 1: jvmAndroid defined before androidMain/jvmMain
echo "📋 Checking source set definition order..."
if [ -f "quartz/build.gradle.kts" ]; then
jvmandroid_line=$(grep -n "val jvmAndroid = create" quartz/build.gradle.kts | cut -d: -f1)
android_line=$(grep -n "androidMain {" quartz/build.gradle.kts | cut -d: -f1)
jvm_line=$(grep -n "jvmMain {" quartz/build.gradle.kts | cut -d: -f1)
if [ -n "$jvmandroid_line" ] && [ -n "$android_line" ] && [ -n "$jvm_line" ]; then
if [ "$jvmandroid_line" -lt "$android_line" ] && [ "$jvmandroid_line" -lt "$jvm_line" ]; then
echo -e "${GREEN}${NC} jvmAndroid defined before androidMain and jvmMain"
else
echo -e "${RED}${NC} jvmAndroid must be defined BEFORE androidMain and jvmMain"
ISSUES_FOUND=$((ISSUES_FOUND + 1))
fi
fi
fi
# Check 2: Platform code in commonMain (Android imports)
echo
echo "📋 Checking for platform code in commonMain..."
android_imports_in_common=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^import android\." || true)
if [ -n "$android_imports_in_common" ]; then
echo -e "${RED}${NC} Found Android imports in commonMain:"
echo "$android_imports_in_common" | sed 's/^/ /'
echo " Fix: Move to androidMain or create expect/actual"
ISSUES_FOUND=$((ISSUES_FOUND + 1))
else
echo -e "${GREEN}${NC} No Android imports in commonMain"
fi
# Check 3: JVM libraries in commonMain (Jackson, OkHttp)
echo
echo "📋 Checking for JVM libraries in commonMain..."
jvm_imports_in_common=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^import com.fasterxml.jackson\|^import okhttp3\." || true)
if [ -n "$jvm_imports_in_common" ]; then
echo -e "${RED}${NC} Found JVM library imports in commonMain:"
echo "$jvm_imports_in_common" | sed 's/^/ /'
echo " Fix: Move to jvmAndroid or migrate to kotlinx.serialization/ktor"
ISSUES_FOUND=$((ISSUES_FOUND + 1))
else
echo -e "${GREEN}${NC} No JVM library imports in commonMain"
fi
# Check 4: Unmatched expect/actual declarations
echo
echo "📋 Checking expect/actual pairs..."
expect_files=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^expect " || true)
if [ -n "$expect_files" ]; then
for file in $expect_files; do
# Extract declarations
expects=$(grep "^expect \(class\|object\|fun\|interface\)" "$file" | sed 's/expect //' | awk '{print $2}' | sed 's/[({].*$//')
# Check for actuals in platform source sets
for expect_name in $expects; do
actual_count=0
for platform in androidMain jvmMain iosMain; do
platform_dir=$(dirname "$file" | sed "s/commonMain/$platform/")
platform_file="${platform_dir}/$(basename "$file")"
if [ -f "$platform_file" ] && grep -q "actual.*$expect_name" "$platform_file"; then
actual_count=$((actual_count + 1))
fi
done
if [ "$actual_count" -eq 0 ]; then
echo -e "${YELLOW}${NC} No actual implementations found for: $expect_name in $file"
echo " Check: androidMain, jvmMain, iosMain"
ISSUES_FOUND=$((ISSUES_FOUND + 1))
fi
done
done
else
echo -e "${GREEN}${NC} No expect declarations to validate"
fi
# Check 5: Duplicated business logic across platforms
echo
echo "📋 Checking for potential code duplication..."
# This is a heuristic check - look for similar function names in different platform source sets
common_functions=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -h "^fun " | awk '{print $2}' | sed 's/[({<].*$//' | sort -u || true)
if [ -n "$common_functions" ]; then
for func in $common_functions; do
android_count=$(find */src/androidMain -name "*.kt" 2>/dev/null | xargs grep -l "^fun $func" | wc -l)
jvm_count=$(find */src/jvmMain -name "*.kt" 2>/dev/null | xargs grep -l "^fun $func" | wc -l)
if [ "$android_count" -gt 0 ] && [ "$jvm_count" -gt 0 ]; then
echo -e "${YELLOW}${NC} Function '$func' found in both androidMain and jvmMain"
echo " Consider: Move to commonMain or jvmAndroid if truly shared"
fi
done
fi
# Summary
echo
echo "=== Summary ==="
if [ "$ISSUES_FOUND" -eq 0 ]; then
echo -e "${GREEN}✓ All checks passed!${NC}"
exit 0
else
echo -e "${RED}✗ Found $ISSUES_FOUND issue(s)${NC}"
echo
echo "Common fixes:"
echo " 1. Platform code in commonMain → Move to androidMain or create expect/actual"
echo " 2. JVM libraries in commonMain → Move to jvmAndroid or migrate to kotlinx.*"
echo " 3. Missing actual implementations → Implement in all target platforms"
echo " 4. Duplicated logic → Move to commonMain or jvmAndroid"
exit 1
fi

View File

@@ -0,0 +1,551 @@
---
name: nostr-expert
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
---
# Nostr Protocol Expert (Quartz Implementation)
Practical patterns for working with Nostr in Quartz, AmethystMultiplatform's KMP Nostr library.
## When to Use This Skill
- Implementing Nostr event types (TextNote, Reaction, Zap, etc.)
- Creating/parsing events with TagArrayBuilder DSL
- Working with event kinds and tags
- Finding NIP implementations in quartz/ codebase
- Nostr cryptography (secp256k1 signing, NIP-44 encryption)
- Bech32 encoding/decoding (npub, nsec, note formats)
- Event validation and verification
**For NIP specifications** → Use `nostr-protocol` agent
**For Quartz implementation** → Use this skill
## Quartz Architecture
Quartz organizes code by NIP number:
```
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/
├── nip01Core/ # Core protocol (Event, Kind, Tags)
├── nip04Dm/ # Legacy DMs (deprecated)
├── nip10Notes/ # Text notes with threading
├── nip17Dm/ # Private DMs (gift wrap)
├── nip19Bech32/ # Bech32 encoding
├── nip44Encryption/ # Modern encryption (ChaCha20)
├── nip57Zaps/ # Lightning zaps
├── ... (57 NIPs total)
└── experimental/ # Draft NIPs
```
**Pattern**: `nip##<Name>/` directories contain event classes, tags, and utilities for that NIP.
**Find implementations**: Use `scripts/nip-lookup.sh <nip-number>` or see `references/nip-catalog.md`.
## Event Anatomy
### Core Structure
```kotlin
@Immutable
open class Event(
val id: HexKey, // SHA-256 hash of serialized event
val pubKey: HexKey, // Author's public key (32 bytes hex)
val createdAt: Long, // Unix timestamp
val kind: Kind, // Event kind (Int typealias)
val tags: TagArray, // Array of tag arrays
val content: String, // Event content
val sig: HexKey, // Schnorr signature (64 bytes hex)
) : IEvent
```
**Key insight**: `Event` is the base class. Specific event types (TextNoteEvent, ReactionEvent) extend it and add parsing/helper methods.
### Kind Classification
```kotlin
typealias Kind = Int
fun Kind.isEphemeral() = this in 20000..29999 // Not stored
fun Kind.isReplaceable() = this == 0 || this == 3 || this in 10000..19999
fun Kind.isAddressable() = this in 30000..39999 // Replaceable + has d-tag
fun Kind.isRegular() = this in 1000..9999 // Stored, not replaced
```
**Pattern**: Kind determines event lifecycle and replaceability.
## Creating Events
### EventTemplate Pattern
```kotlin
fun eventTemplate(
kind: Kind,
content: String,
tags: TagArray = emptyArray()
): EventTemplate
```
**Usage**:
```kotlin
val template = eventTemplate(
kind = 1, // Text note
content = "Hello Nostr!",
tags = tagArray {
add(arrayOf("subject", "Greeting"))
}
)
// Sign with a signer
val signedEvent = signer.sign(template)
```
**Why templates?** Separates event data from signing. Templates can be signed by different signers (local keys, remote signers, hardware wallets).
### TagArrayBuilder DSL
```kotlin
fun <T : Event> tagArray(
initializer: TagArrayBuilder<T>.() -> Unit
): TagArray
```
**Methods**:
- `add(tag)` - Append tag
- `addFirst(tag)` - Prepend tag (for ordering)
- `addUnique(tag)` - Replace all tags with this name
- `remove(tagName)` - Remove by name
- `addAll(tags)` - Bulk add
**Example**:
```kotlin
val tags = tagArray<TextNoteEvent> {
add(arrayOf("e", replyToEventId, "", "reply"))
add(arrayOf("p", authorPubkey))
addUnique(arrayOf("subject", "Re: Hello"))
add(arrayOf("content-warning", "spoilers"))
}
```
**Pattern**: Fluent DSL for building tag arrays with validation and deduplication.
## Common Event Types
### TextNoteEvent (kind 1)
```kotlin
class TextNoteEvent : BaseThreadedEvent
```
**Creating**:
```kotlin
val note = eventTemplate(
kind = 1,
content = "Hello world!",
tags = tagArray {
add(arrayOf("subject", "First post"))
}
)
```
**Parsing**:
```kotlin
val event: TextNoteEvent = ...
val subject = event.subject() // Extension from nip14Subject
val mentions = event.mentions() // List of p-tags
val quotedEvents = event.quotes() // List of q-tags
```
### ReactionEvent (kind 7)
```kotlin
fun createReaction(
targetEvent: Event,
emoji: String = "+"
): EventTemplate {
return eventTemplate(
kind = 7,
content = emoji,
tags = tagArray {
add(arrayOf("e", targetEvent.id))
add(arrayOf("p", targetEvent.pubKey))
}
)
}
```
### MetadataEvent (kind 0)
```kotlin
data class UserMetadata(
val name: String?,
val displayName: String?,
val picture: String?,
val banner: String?,
val about: String?,
// ... more fields
)
fun createMetadata(metadata: UserMetadata): EventTemplate {
return eventTemplate(
kind = 0,
content = metadata.toJson() // Serialize to JSON
)
}
```
### Addressable Events (kinds 30000-40000)
```kotlin
fun createArticle(
slug: String,
title: String,
content: String
): EventTemplate {
return eventTemplate(
kind = 30023,
content = content,
tags = tagArray {
addUnique(arrayOf("d", slug)) // Unique identifier
add(arrayOf("title", title))
add(arrayOf("published_at", "${TimeUtils.now()}"))
}
)
}
```
**Key**: `d-tag` makes it addressable. Events with same kind + pubkey + d-tag replace each other.
## Tag Patterns
Tags are `Array<String>` with pattern `[name, value, ...optionalParams]`.
### Core Tags
**e-tag** (event reference):
```kotlin
add(arrayOf("e", eventId, relayHint, marker))
// marker: "reply", "root", "mention"
```
**p-tag** (pubkey reference):
```kotlin
add(arrayOf("p", pubkey, relayHint))
```
**a-tag** (addressable event):
```kotlin
add(arrayOf("a", "$kind:$pubkey:$dtag", relayHint))
```
**d-tag** (identifier for addressable events):
```kotlin
addUnique(arrayOf("d", "unique-slug"))
```
### Tag Extensions
```kotlin
// Find tags
event.tags.tagValue("subject") // First subject tag value
event.tags.allTags("p") // All p-tags
event.tags.tagValues("e") // All e-tag values
// Parse structured tags
event.tags.mapNotNull(ETag::parse) // Parse as ETag objects
```
For comprehensive tag patterns, see `references/tag-patterns.md`.
## Threading (NIP-10)
```kotlin
fun createReply(
original: TextNoteEvent,
content: String
): EventTemplate {
return eventTemplate(
kind = 1,
content = content,
tags = tagArray {
// Reply marker
add(arrayOf("e", original.id, "", "reply"))
// Root marker (original's root, or original itself)
original.rootEvent()?.let {
add(arrayOf("e", it.id, "", "root"))
} ?: add(arrayOf("e", original.id, "", "root"))
// Tag author
add(arrayOf("p", original.pubKey))
// Tag all mentioned users
original.mentions().forEach {
add(arrayOf("p", it))
}
}
)
}
```
**Pattern**: `reply` and `root` markers establish thread hierarchy.
## Cryptography
### Signing (secp256k1)
```kotlin
interface ISigner {
suspend fun sign(template: EventTemplate): Event
}
// Local key signing
class LocalSigner(private val privateKey: ByteArray) : ISigner {
override suspend fun sign(template: EventTemplate): Event {
val id = template.generateId()
val sig = Secp256k1.sign(id, privateKey)
return Event(id, pubKey, createdAt, kind, tags, content, sig)
}
}
```
**Pattern**: Signers abstract key management. Can be local, remote (NIP-46), or hardware.
### Encryption (NIP-44)
```kotlin
// Modern encryption (ChaCha20-Poly1305)
object Nip44v2 {
fun encrypt(plaintext: String, privateKey: ByteArray, pubKey: HexKey): String
fun decrypt(ciphertext: String, privateKey: ByteArray, pubKey: HexKey): String
}
// Usage
val encrypted = Nip44v2.encrypt(
plaintext = "Secret message",
privateKey = myPrivateKey,
pubKey = recipientPubKey
)
val decrypted = Nip44v2.decrypt(
ciphertext = encrypted,
privateKey = myPrivateKey,
pubKey = senderPubKey
)
```
**Pattern**: Elliptic curve Diffie-Hellman + ChaCha20-Poly1305 AEAD.
### NIP-04 (Deprecated)
```kotlin
// Legacy encryption (NIP-04, deprecated for NIP-44)
object Nip04 {
fun encrypt(msg: String, privateKey: ByteArray, pubKey: HexKey): String
fun decrypt(msg: String, privateKey: ByteArray, pubKey: HexKey): String
}
```
**Note**: Use NIP-44 (Nip44v2) for new implementations. NIP-04 has security issues.
## Bech32 Encoding (NIP-19)
```kotlin
object Nip19 {
// Encode
fun npubEncode(pubkey: HexKey): String // npub1...
fun nsecEncode(privateKey: ByteArray): String // nsec1...
fun noteEncode(eventId: HexKey): String // note1...
fun neventEncode(eventId: HexKey, relays: List<String> = emptyList()): String
fun nprofileEncode(pubkey: HexKey, relays: List<String> = emptyList()): String
fun naddrEncode(kind: Int, pubkey: HexKey, dTag: String, relays: List<String> = emptyList()): String
// Decode
fun decode(bech32: String): Nip19Result
}
sealed class Nip19Result {
data class NPub(val hex: HexKey) : Nip19Result()
data class NSec(val hex: HexKey) : Nip19Result()
data class Note(val hex: HexKey) : Nip19Result()
data class NEvent(val hex: HexKey, val relays: List<String>) : Nip19Result()
data class NProfile(val hex: HexKey, val relays: List<String>) : Nip19Result()
data class NAddr(val kind: Int, val pubkey: HexKey, val dTag: String, val relays: List<String>) : Nip19Result()
}
```
**Usage**:
```kotlin
// Encode
val npub = Nip19.npubEncode(pubkeyHex)
// Output: "npub1..."
// Decode
when (val result = Nip19.decode(npub)) {
is Nip19Result.NPub -> println("Pubkey: ${result.hex}")
is Nip19Result.NEvent -> println("Event: ${result.hex}, relays: ${result.relays}")
else -> println("Other type")
}
```
## Event Validation
```kotlin
fun Event.verify(): Boolean {
// 1. Verify ID matches content hash
val computedId = generateId()
if (id != computedId) return false
// 2. Verify signature
return Secp256k1.verify(id, sig, pubKey)
}
fun Event.generateId(): HexKey {
val serialized = serializeForId() // JSON array format
return sha256(serialized)
}
```
**Pattern**: Always verify events from untrusted sources (relays).
## Common Workflows
### Publishing an Event
```kotlin
suspend fun publishNote(content: String, signer: ISigner, relays: List<String>) {
// 1. Create template
val template = eventTemplate(kind = 1, content = content)
// 2. Sign
val event = signer.sign(template)
// 3. Verify (optional but recommended)
require(event.verify()) { "Signature verification failed" }
// 4. Publish to relays
relays.forEach { relay ->
relayClient.send(relay, event)
}
}
```
### Querying Events
```kotlin
// Subscription filter
data class Filter(
val ids: List<HexKey>? = null,
val authors: List<HexKey>? = null,
val kinds: List<Kind>? = null,
val since: Long? = null,
val until: Long? = null,
val limit: Int? = null,
val tags: Map<String, List<String>>? = null // e.g., {"#e": [eventId], "#p": [pubkey]}
)
// Usage
val filter = Filter(
authors = listOf(userPubkey),
kinds = listOf(1), // Text notes only
limit = 50
)
relayClient.subscribe(relay, filter) { event ->
// Handle incoming events
}
```
### Creating a Zap (NIP-57)
```kotlin
fun createZapRequest(
targetEvent: Event,
amountSats: Long,
comment: String = ""
): EventTemplate {
return eventTemplate(
kind = 9734, // Zap request
content = comment,
tags = tagArray {
add(arrayOf("e", targetEvent.id))
add(arrayOf("p", targetEvent.pubKey))
add(arrayOf("amount", "${amountSats * 1000}")) // millisats
add(arrayOf("relays", "wss://relay1.com", "wss://relay2.com"))
}
)
}
```
### Gift-Wrapped DMs (NIP-17)
```kotlin
fun createGiftWrappedDM(
recipientPubkey: HexKey,
message: String,
signer: ISigner
): Event {
// 1. Create sealed gossip (kind 14)
val sealedGossip = createSealedGossip(message, recipientPubkey, signer)
// 2. Wrap in gift wrap (kind 1059)
return createGiftWrap(sealedGossip, recipientPubkey, signer)
}
```
**Pattern**: Double encryption + random ephemeral keys for metadata protection.
## Finding NIPs
Use the bundled script:
```bash
# Find by NIP number
scripts/nip-lookup.sh 44
# Search by term
scripts/nip-lookup.sh encryption
scripts/nip-lookup.sh "gift wrap"
```
Or see `references/nip-catalog.md` for complete catalog.
## Bundled Resources
- **references/nip-catalog.md** - All 57 NIPs with package locations and key files
- **references/event-hierarchy.md** - Event class hierarchy, kind classifications, common types
- **references/tag-patterns.md** - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns
- **scripts/nip-lookup.sh** - Find NIP implementations by number or search term
## Quick Reference
| Task | Pattern | Location |
|------|---------|----------|
| Create event | `eventTemplate(kind, content, tags)` | nip01Core/signers/ |
| Build tags | `tagArray { add(...) }` | nip01Core/core/ |
| Sign event | `signer.sign(template)` | nip01Core/signers/ |
| Verify signature | `event.verify()` | nip01Core/core/ |
| Encrypt (NIP-44) | `Nip44v2.encrypt(...)` | nip44Encryption/ |
| Bech32 encode | `Nip19.npubEncode(...)` | nip19Bech32/ |
| Find NIP | `scripts/nip-lookup.sh <number>` | - |
## Common Event Kinds
| Kind | Type | NIP | Package |
|------|------|-----|---------|
| 0 | Metadata | 01 | nip01Core/ |
| 1 | Text note | 01, 10 | nip10Notes/ |
| 3 | Contact list | 02 | nip02FollowList/ |
| 5 | Deletion | 09 | nip09Deletions/ |
| 7 | Reaction | 25 | nip25Reactions/ |
| 1059 | Gift wrap | 59 | nip59Giftwrap/ |
| 9734 | Zap request | 57 | nip57Zaps/ |
| 9735 | Zap receipt | 57 | nip57Zaps/ |
| 10002 | Relay list | 65 | nip65RelayList/ |
| 30023 | Long-form content | 23 | nip23LongContent/ |
## Related Skills
- **nostr-protocol** - NIP specifications and protocol details
- **kotlin-expert** - Kotlin patterns (@Immutable, sealed classes, DSLs)
- **kotlin-coroutines** - Async patterns for relay communication
- **kotlin-multiplatform** - KMP patterns, expect/actual in Quartz

View File

@@ -0,0 +1,293 @@
# Event Hierarchy & Structure
## Core Hierarchy
```
IEvent (empty interface)
└── Event (@Immutable base class)
├── BaseAddressableEvent (replaceable + addressable, has d-tag)
│ ├── BaseReplaceableEvent (kinds 10000-20000, FIXED_D_TAG = "")
│ └── [Specific addressable events - 30000-40000]
└── [Specific event implementations - all other kinds]
```
## Event Base Class
**Location**: `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt`
```kotlin
@Immutable
open class Event(
val id: HexKey, // SHA-256 hash of serialized event
val pubKey: HexKey, // Author's public key (32 bytes hex)
val createdAt: Long, // Unix timestamp
val kind: Kind, // Event kind (Int typealias)
val tags: TagArray, // Array of tag arrays
val content: String, // Event content
val sig: HexKey, // schnorr signature (64 bytes hex)
) : IEvent, OptimizedSerializable
```
## Kind Classification
```kotlin
typealias Kind = Int
fun Kind.isEphemeral() = this in 20000..29999
fun Kind.isReplaceable() = this == 0 || this == 3 || this in 10000..19999
fun Kind.isAddressable() = this in 30000..39999
fun Kind.isRegular() = this in 1000..9999
```
## Common Event Types
### Text Note (kind 1)
```kotlin
class TextNoteEvent(...) : BaseThreadedEvent(...),
EventHintProvider, AddressHintProvider, PubKeyHintProvider, SearchableEvent
// Threading support via markers: reply, root, mention
fun replyTo(): List<Note> // Direct reply targets
fun root(): Note? // Root of thread
```
### Metadata (kind 0)
```kotlin
class MetadataEvent(...) : BaseAddressableEvent(...)
// Replaceable: newest version overwrites old
// d-tag automatically set to "" for kind 0
fun name(): String?
fun displayName(): String?
fun picture(): String?
fun about(): String?
fun lnAddress(): String?
```
### Reaction (kind 7)
```kotlin
class ReactionEvent(...) : Event(...)
companion object {
const val LIKE = "+"
const val DISLIKE = "-"
fun like(reactedTo: EventHintBundle<Event>, ...)
fun dislike(reactedTo: EventHintBundle<Event>, ...)
}
```
### Zap Request/Receipt (kinds 9734, 9735)
```kotlin
class LnZapRequestEvent(...) : Event(...)
// Created by client, sent to Lightning Address
class LnZapEvent(...) : Event(...)
// Receipt from LSP, contains bolt11 + embedded zap request
val zapRequest: LnZapRequestEvent? by lazy { containedPost() }
val amount: BigDecimal? by lazy { /* parse from bolt11 */ }
```
### Long-Form Content (kind 30023)
```kotlin
class LongTextNoteEvent(...) : BaseAddressableEvent(...)
// Blog posts, articles
// Addressable via kind:pubkey:d-tag
```
### Lists (kinds 10000-30004)
```kotlin
sealed class PeopleListEvent : BaseAddressableEvent {
object MuteList : PeopleListEvent(10000)
object PinList : PeopleListEvent(10001)
object BookmarkList : PeopleListEvent(10003)
// ... 18 list types total
}
```
## Event Interfaces
### Hint Providers
Events can implement interfaces to optimize relay queries:
```kotlin
interface EventHintProvider {
fun taggedEventIds(): Set<HexKey>
fun taggedEventRelays(): Map<HexKey, Set<NormalizedRelayUrl>>
}
interface PubKeyHintProvider {
fun taggedPubKeys(): Set<HexKey>
fun taggedPubKeyRelays(): Map<HexKey, Set<NormalizedRelayUrl>>
}
interface AddressHintProvider {
fun taggedAddresses(): Set<Address>
fun taggedAddressRelays(): Map<Address, Set<NormalizedRelayUrl>>
}
interface SearchableEvent {
fun subject(): String?
fun isContentEncoded(): Boolean
}
```
## Event Building Pattern
### DSL Builder
```kotlin
TextNoteEvent.build(
note = "Hello Nostr",
replyingTo = eventBundle,
createdAt = TimeUtils.now()
) {
pTag(pubKey, relayHint) // Tag person
eTag(eventId, relayHint, "reply") // Tag event with marker
hashtag("nostr") // Add hashtag
alt("A short note") // Alt text
}
```
### Event Template (Low-level)
```kotlin
suspend fun eventTemplate(
kind: Kind,
content: String,
createdAt: Long,
initializer: TagArrayBuilder.() -> Unit
): EventTemplate {
val tags = TagArrayBuilder().apply(initializer).build()
return EventTemplate(kind, tags, content, createdAt)
}
// Sign with signer
val template = eventTemplate(1, "Hello", now()) { pTag(pubkey) }
val signedEvent = signer.sign(template)
```
## Addressable vs Regular Events
| Feature | Regular Event | Addressable Event |
|---------|---------------|-------------------|
| **Identifier** | Event ID (SHA-256 hash) | Address (kind:pubkey:d-tag) |
| **Replaceability** | Immutable | Newest replaces old |
| **d-tag** | Optional | Required |
| **Lookup** | By event ID | By address |
| **Example** | Text note (kind 1) | Metadata (kind 0), Long-form (kind 30023) |
```kotlin
// Regular event address
note = LocalCache.getNoteIfExists(eventId)
// Addressable event address
address = Address(kind = 30023, pubkey = authorHex, dTag = "my-article")
note = LocalCache.getAddressableNoteIfExists(address)
```
## Event Validation
```kotlin
// Verify event ID matches computed hash
fun Event.verifyId(): Boolean =
EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content)
// Verify signature
fun Event.verifySignature(): Boolean =
Nip01.verify(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
// Complete verification
fun Event.checkSignature() {
if (!verifyId()) throw Exception("ID mismatch")
if (!verifySignature()) throw Exception("Bad signature!")
}
```
## Event Serialization
```kotlin
// To JSON (for transmission/signing)
fun Event.toJson(): String = OptimizedJsonMapper.toJson(this)
// From JSON
fun Event.fromJson(json: String): Event = OptimizedJsonMapper.fromJson(json)
// Event ID generation (SHA-256 of canonical JSON)
fun EventHasher.hashId(
pubKey: HexKey,
createdAt: Long,
kind: Kind,
tags: TagArray,
content: String
): HexKey {
val serialized = """[0,"$pubKey",$createdAt,$kind,${tags.toJson()},"$content"]"""
return sha256(serialized.encodeToByteArray()).toHexKey()
}
```
## Event Lifecycle in LocalCache
```
Event received from relay
LocalCache.consume(event, relay, wasVerified)
getOrCreateNote(event.id) or getOrCreateAddressableNote(address)
justVerify(event) → checkSignature()
note.loadEvent(event, author, replyTo)
Update indices (replies, reactions, boosts)
refreshNewNoteObservers(note) → emit to SharedFlow
UI updates
```
## Common Event Patterns
### Reply Threading
```kotlin
// Root event (top of thread)
val rootEvent = TextNoteEvent.build("Thread root") { }
// Reply to root
val reply1 = TextNoteEvent.build("First reply", replyingTo = rootEvent) {
// Automatically adds:
// ["e", <root_id>, <relay>, "root"]
// ["e", <root_id>, <relay>, "reply"]
}
// Reply to reply (nested)
val reply2 = TextNoteEvent.build("Nested reply", replyingTo = reply1) {
// Automatically adds:
// ["e", <root_id>, <relay>, "root"]
// ["e", <reply1_id>, <relay>, "reply"]
}
```
### Replaceable Events
```kotlin
// Metadata update (kind 0) - newest wins
val metadata1 = MetadataEvent.createNew(name = "Alice", picture = "url1")
Thread.sleep(1000)
val metadata2 = MetadataEvent.createNew(name = "Alice Updated", picture = "url2")
// LocalCache keeps only metadata2 (higher createdAt)
```
### Event Deletion
```kotlin
// Delete events
val deletion = DeletionEvent.create(
deleteEvents = listOf(eventId1, eventId2),
reason = "Spam",
signer = signer
)
// LocalCache marks events as deleted, but doesn't remove (for verification)
```
## 63+ Event Classes
Full list at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip*/` - one class per event type across 60+ NIP implementations.

View File

@@ -0,0 +1,179 @@
# NIP Catalog: 60 Standard + 8 Experimental NIPs in Quartz
## Standard NIPs by Category
### Core/Basic Protocol
| NIP | Directory | Key Files | Description |
|-----|-----------|-----------|-------------|
| 01 | `nip01Core/` | Event.kt, Kind.kt, Tag.kt | Core protocol, event structure, kinds, tags |
| 02 | `nip02FollowList/` | ContactListEvent.kt | Follow/contact lists (kind 3) |
| 03 | `nip03Timestamp/` | OpenTimestampsAttestation.kt | Timestamps |
| 04 | `nip04Dm/` | EncryptedDmEvent.kt | Legacy encrypted DMs (deprecated for NIP-17) |
| 05 | `nip05DnsIdentifiers/` | Nip05Verifier.kt | DNS-based verification |
| 06 | `nip06KeyDerivation/` | Mnemonic-related | BIP-39 key derivation |
| 09 | `nip09Deletions/` | DeletionEvent.kt | Event deletion requests (kind 5) |
| 11 | `nip11RelayInfo/` | RelayInformation.kt | Relay metadata |
| 13 | `nip13Pow/` | ProofOfWork.kt | Proof of work |
| 14 | `nip14Subject/` | Subject tags | Subject tags for text notes |
| 17 | `nip17Dm/` | GiftWrapEvent.kt, SealedGossipEvent.kt | Private DMs (replacem
ent for NIP-04) |
| 21 | `nip21UriScheme/` | URI scheme (`nostr:`) | URI scheme parsing |
| 42 | `nip42RelayAuth/` | RelayAuthEvent.kt | Relay authentication (kind 22242) |
| 44 | `nip44Encryption/` | Nip44.kt, Nip44v2.kt | Modern encryption (ChaCha20) |
| 49 | `nip49PrivKeyEnc/` | NIP-49Ncryptsec.kt | Private key encryption format |
### Content Types
| NIP | Directory | Key Files | Description |
|-----|-----------|-----------|-------------|
| 10 | `nip10Notes/` | TextNoteEvent.kt | Text notes with threading (kind 1) |
| 18 | `nip18Reposts/` | RepostEvent.kt, GenericRepostEvent.kt | Reposts (kind 6, 16) |
| 22 | `nip22Comments/` | CommentEvent.kt | Comments (kind 1111) |
| 23 | `nip23LongContent/` | LongTextNoteEvent.kt | Long-form content (kind 30023) |
| 25 | `nip25Reactions/` | ReactionEvent.kt | Reactions (kind 7) |
| 31 | `nip31Alts/` | Alt tags | Alt description tags |
| 36 | `nip36SensitiveContent/` | Content warnings | Content warning tags |
| 37 | `nip37Drafts/` | DraftEvent.kt | Drafts (kind 31234) |
| 50 | `nip50Search/` | Search filters | Full-text search |
### Encoding & Standards
| NIP | Directory | Key Files | Description |
|-----|-----------|-----------|-------------|
| 19 | `nip19Bech32/` | Nip19.kt | Bech32 encoding (npub, nsec, note, nevent, nprofile, naddr) |
| 40 | `nip40Expiration/` | Expiration tags | Event expiration |
| 48 | `nip48ProxyTags/` | Proxy tags | Proxy tags for delegation |
| 62 | `nip62RequestToVanish/` | RequestToVanishEvent.kt | Request to vanish (kind 12) |
| 98 | `nip98HttpAuth/` | HTTP authorization | HTTP auth header |
### Lists & Management
| NIP | Directory | Key Files | Description |
|-----|-----------|-----------|-------------|
| 51 | `nip51Lists/` | 18 list types | Named lists (mute, bookmarks, pins, communities, etc.) (kinds 10000-30004) |
| 65 | `nip65RelayList/` | AdvertisedRelayListEvent.kt | Relay lists (kind 10002) |
### Social & Identity
| NIP | Directory | Key Files | Description |
|-----|-----------|-----------|-------------|
| 39 | `nip39ExtIdentities/` | External identities | External identity claims |
| 46 | `nip46RemoteSigner/` | NostrConnectEvent.kt | Remote signer protocol (bunker) |
| 47 | `nip47WalletConnect/` | Nostr Wallet Connect | Wallet connection protocol |
| 56 | `nip56Reports/` | ReportEvent.kt | Reports (kind 1984) |
| 57 | `nip57Zaps/` | LnZapEvent.kt, LnZapRequestEvent.kt | Lightning zaps (kinds 9734, 9735) |
| 58 | `nip58Badges/` | Badge events | Badge definitions & awards (kinds 30009, 8) |
| 59 | `nip59Giftwrap/` | GiftWrapEvent.kt | Gift-wrapped events for privacy |
| 75 | `nip75ZapGoals/` | ZapGoalEvent.kt | Zap goals (kind 9041) |
### Specialized Content
| NIP | Directory | Key Files | Description |
|-----|-----------|-----------|-------------|
| 28 | `nip28PublicChat/` | ChannelCreateEvent.kt, ChannelMessageEvent.kt | Public chat channels (kinds 40-44) |
| 30 | `nip30CustomEmoji/` | EmojiUrl.kt | Custom emoji |
| 34 | `nip34Git/` | Git patch/issue events | Git repository tracking (kinds 30617, 30618, 1617, 1621, 1622, 1630, 1633) |
| 35 | `nip35Torrents/` | Torrent events | Torrent tracking |
| 52 | `nip52Calendar/` | Calendar events | Calendar time-based/date-based (kinds 31922-31925) |
| 53 | `nip53LiveActivities/` | LiveActivitiesEvent.kt | Live events/streaming (kind 30311) |
| 54 | `nip54Wiki/` | WikiNoteEvent.kt | Wiki pages (kind 30818) |
| 68 | `nip68Picture/` | Picture metadata | Picture metadata |
| 71 | `nip71Video/` | 7 video event types | Video events (kinds 34235, 35235, 1234, 1235) |
| 72 | `nip72ModCommunities/` | Community events | Moderated communities (kinds 34550, 34551, 9041) |
| 84 | `nip84Highlights/` | HighlightEvent.kt | Highlights (kind 9802) |
| 89 | `nip89AppHandlers/` | AppDefinitionEvent.kt | App recommendations (kinds 31990, 31989) |
| 90 | `nip90Dvms/` | DVM job events | Data Vending Machines (DVMs) (kinds 5000-7000) |
| 92 | `nip92IMeta/` | IMeta tags | Image metadata tags |
| 94 | `nip94FileMetadata/` | FileHeaderEvent.kt, FileStorageEvent.kt | File metadata (kind 1063) |
| 96 | `nip96FileStorage/` | HTTP file storage | HTTP-based file storage |
| 99 | `nip99Classifieds/` | ClassifiedsEvent.kt | Classifieds/marketplace (kind 30402) |
| A0 | `nipA0VoiceMessages/` | Voice messages | Voice message events |
| B7 | `nipB7Blossom/` | Blossom server URLs | Blossom file storage |
### Web/Storage/Other
| NIP | Directory | Key Files | Description |
|-----|-----------|-----------|-------------|
| 38 | `nip38UserStatus/` | StatusEvent.kt | User status (kind 30315) |
| 60 | `nip60Payment/` | Wallet events | Wallet info (kind 13194) |
| 61 | `nip61PaymentRequest/` | Nut zaps | Cashu payment requests |
| 64 | `nip64Chess/` | Chess moves | Chess move events |
| 66 | `nip66Monitoring/` | Relay monitor events | Relay monitoring |
| 67 | `nip67Invoices/` | Invoice tags | Lightning invoice tags |
| 69 | `nip69Offers/` | BOLT-12 offers | BOLT-12 offer tags |
| 70 | `nip70ProtectedEvts/` | Protected events | Protected event types |
| 73 | `nip73ExternalIds/` | External content IDs | External content identifiers |
| 78 | `nip78AppData/` | AppDataEvent.kt | Application data (kind 30078) |
| 79 | `nip79Labels/` | Label events | Labeling (kinds 1985, 1986) |
| 80-88 | Various | Various protocols | Relationship, preferences, polls, surveys, social graphs, etc. |
| 91 | `nip91Feed/` | Feed display events | Feed definitions |
| 93 | `nip93Gallery/` | Gallery events | Gallery collections |
| 95 | `nip95Storage/` | Storage event tags | Storage events |
| 97 | `nip97Nests/` | Audio rooms | Audio room events |
## Experimental NIPs (18 packages)
Located at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/`:
| Package | Description |
|---------|-------------|
| `audio/` | Audio content, track events |
| `bounties/` | Bounty/funding events |
| `decoupling/` | Decoupling setup |
| `edits/` | Event edit tracking |
| `ephemChat/` | Ephemeral encrypted chat |
| `forks/` | Fork tracking |
| `inlineMetadata/` | Inline metadata |
| `interactiveStories/` | Interactive story events |
| `limits/` | Limit enforcement |
| `medical/` | Medical data |
| `nip95/` | File storage support |
| `nipA3/` | A3 protocol extension |
| `nns/` | Nostr Name System |
| `profileGallery/` | Profile gallery lists |
| `publicMessages/` | Public message lists |
| `relationshipStatus/` | Relationship status events |
| `trustedAssertions/` | Trust/assertion events |
| `zapPolls/` | Zap-based polling |
## Quick Lookup by Kind
| Kind | Event Type | NIP |
|------|------------|-----|
| 0 | Metadata | 01 |
| 1 | Text Note | 01, 10 |
| 3 | Follow List | 02 |
| 4 | Encrypted DM (legacy) | 04 |
| 5 | Deletion | 09 |
| 6 | Repost | 18 |
| 7 | Reaction | 25 |
| 8 | Badge Award | 58 |
| 16 | Generic Repost | 18 |
| 40-44 | Channel Events | 28 |
| 1063 | File Metadata | 94 |
| 1111 | Comment | 22 |
| 1617, 1621, 1622, 1630, 1633 | Git | 34 |
| 1984 | Report | 56 |
| 1985, 1986 | Label | 79 |
| 9734 | Zap Request | 57 |
| 9735 | Zap Receipt | 57 |
| 9802 | Highlight | 84 |
| 10000-20000 | Replaceable Lists | 51 |
| 10002 | Relay List | 65 |
| 13194 | Wallet Info | 60 |
| 22242 | Relay Auth | 42 |
| 23194, 23195 | NWC Payment | 47 |
| 30000-40000 | Addressable Events | Various |
| 30009 | Badge Definition | 58 |
| 30023 | Long-Form Content | 23 |
| 30078 | App Data | 78 |
| 30311 | Live Event | 53 |
| 30315 | User Status | 38 |
| 30402 | Classifieds | 99 |
| 30818 | Wiki | 54 |
| 31234 | Draft | 37 |
| 31922-31925 | Calendar | 52 |
| 31989, 31990 | App Handlers | 89 |
| 34235, 34550-34551 | Video/Communities | 71, 72 |
| 5000-7000 | DVM Jobs | 90 |
## File Location Pattern
All NIPs located at: `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip<NN><Name>/`
Example: NIP-57 → `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/`

View File

@@ -0,0 +1,336 @@
# Tag Patterns in Quartz
Tags are the primary way events reference other events, users, and metadata in Nostr.
## Tag Structure
```kotlin
typealias Tag = Array<String> // ["tag_name", "value", "optional_param", ...]
typealias TagArray = Array<Tag>
```
**Pattern**: `[name, value, ...optionalParams]`
## TagArrayBuilder DSL
```kotlin
fun tagArray(initializer: TagArrayBuilder<T>.() -> Unit): TagArray
```
**Methods**:
- `add(tag)` - Append tag
- `addFirst(tag)` - Prepend tag
- `addUnique(tag)` - Replace all tags with this name
- `remove(tagName)` - Remove all tags with name
- `removeIf(predicate, toCompare)` - Conditional removal
**Example**:
```kotlin
val tags = tagArray<TextNoteEvent> {
add(arrayOf("e", eventId, relayHint, "reply"))
add(arrayOf("p", pubkey))
addUnique(arrayOf("subject", "Hello"))
}
```
## Core Tag Types (NIP-01)
### e-tag (Event Reference)
```kotlin
// ["e", <event-id>, <relay-hint>, <marker>]
arrayOf("e", eventId, "wss://relay.damus.io", "reply")
```
**Markers** (NIP-10):
- `root` - Root of thread
- `reply` - Direct reply target
- `mention` - Mentioned event (not reply)
**Extensions**:
```kotlin
// nip01Core/tags/
fun TagArrayBuilder.eTag(eventId: HexKey, relay: String? = null, marker: String? = null)
```
### p-tag (Pubkey Reference)
```kotlin
// ["p", <pubkey>, <relay-hint>]
arrayOf("p", pubkey, "wss://relay.damus.io")
```
**Usage**: Tag users, indicate recipients
**Extensions**:
```kotlin
fun TagArrayBuilder.pTag(pubkey: HexKey, relay: String? = null)
```
### a-tag (Addressable Event Reference)
```kotlin
// ["a", <kind>:<pubkey>:<d-tag>, <relay-hint>]
arrayOf("a", "30023:${authorPubkey}:${dtag}", "wss://relay.damus.io")
```
**Usage**: Reference replaceable/addressable events (kinds 10000-20000, 30000-40000)
**Extensions**:
```kotlin
fun TagArrayBuilder.aTag(kind: Int, pubkey: HexKey, dTag: String, relay: String? = null)
```
### d-tag (Identifier)
```kotlin
// ["d", <identifier>]
arrayOf("d", "my-article-slug")
```
**Usage**: Unique identifier for addressable events
## Common Tag Extensions
### Subject (NIP-14)
```kotlin
// nip14Subject/
fun Event.subject(): String?
fun TagArrayBuilder.subject(text: String)
```
### Content Warning (NIP-36)
```kotlin
// nip36SensitiveContent/
fun Event.contentWarning(): String?
fun TagArrayBuilder.contentWarning(reason: String = "")
```
### Expiration (NIP-40)
```kotlin
// nip40Expiration/
fun Event.expiration(): Long?
fun TagArrayBuilder.expiration(unixTimestamp: Long)
```
### Alt Description (NIP-31)
```kotlin
// nip31Alts/
fun Event.alt(): String?
fun TagArrayBuilder.alt(description: String)
```
## Specialized Tags
### Zap Tags (NIP-57)
```kotlin
// nip57Zaps/tags/
class BoltTag(val bolt11: String, val preimage: String?)
class DescriptionTag(val zapRequestJson: String)
```
### Imeta Tags (NIP-92)
```kotlin
// nip92IMeta/
class IMetaTag(val url: String, val metadata: Map<String, String>)
// Usage: Image metadata
IMetaTag("https://example.com/image.jpg", mapOf(
"m" to "image/jpeg",
"dim" to "1920x1080",
"blurhash" to "..."
))
```
### Relay Tags (NIP-65)
```kotlin
// nip65RelayList/
class RelayTag(val url: String, val type: RelayType)
enum class RelayType { READ, WRITE, BOTH }
```
## Tag Query Patterns
### Finding Tags
```kotlin
// Extension functions on TagArray
fun TagArray.firstTag(name: String): Tag?
fun TagArray.allTags(name: String): List<Tag>
fun TagArray.tagValue(name: String): String?
fun TagArray.tagValues(name: String): List<String>
```
**Example**:
```kotlin
val event: TextNoteEvent = ...
val subject = event.tags.tagValue("subject")
val mentions = event.tags.allTags("p").mapNotNull { it.getOrNull(1) }
```
### Parsing Tags
```kotlin
// Pattern: Companion object with parse methods
object ETag {
fun parse(tag: Tag): ETag? {
if (tag.getOrNull(0) != "e") return null
return ETag(
eventId = tag.getOrNull(1) ?: return null,
relay = tag.getOrNull(2),
marker = tag.getOrNull(3)
)
}
}
// Usage
val eTags = event.tags.mapNotNull(ETag::parse)
```
## Event Builder Pattern
Combining TagArrayBuilder with event creation:
```kotlin
fun createTextNote(content: String, replyTo: Event?): EventTemplate {
return eventTemplate(
kind = 1,
content = content,
tags = tagArray {
replyTo?.let {
eTag(it.id, marker = "reply")
pTag(it.pubKey)
it.rootEvent()?.let { root ->
eTag(root.id, marker = "root")
}
}
}
)
}
```
## Hint System
Tags can provide "hints" - optional relay URLs for fetching referenced content:
```kotlin
// Event references
["e", eventId, "wss://relay.example.com"] // relay hint
// Pubkey references
["p", pubkey, "wss://relay.example.com"] // relay hint
// Addressable references
["a", "30023:pubkey:dtag", "wss://relay.example.com"] // relay hint
```
**Pattern**: Third parameter (index 2) is always the relay hint
## Tag Validation
```kotlin
// Common validations
fun validateETag(tag: Tag): Boolean {
return tag.getOrNull(0) == "e" && tag.getOrNull(1)?.isValidHex() == true
}
fun validatePTag(tag: Tag): Boolean {
return tag.getOrNull(0) == "p" && tag.getOrNull(1)?.isValidHex() == true
}
```
## Performance Patterns
### Tag Indexing
```kotlin
// TagArrayBuilder keeps an index by tag name
private val tagList = mutableMapOf<String, MutableList<Tag>>()
// Fast lookup by name
fun remove(tagName: String) {
tagList.remove(tagName)
}
```
### Lazy Parsing
```kotlin
// Don't parse all tags upfront
class TextNoteEvent(...) {
private val _mentions by lazy {
tags.mapNotNull(PTag::parse)
}
fun mentions() = _mentions
}
```
## Common Workflows
### Creating a Reply
```kotlin
fun replyTo(original: TextNoteEvent, content: String): EventTemplate {
return eventTemplate(
kind = 1,
content = content,
tags = tagArray {
// Reply to this event
eTag(original.id, marker = "reply")
// Copy root marker if exists, or mark original as root
original.rootEvent()?.let {
eTag(it.id, marker = "root")
} ?: eTag(original.id, marker = "root")
// Tag author
pTag(original.pubKey)
// Tag all mentioned users
original.mentions().forEach { pTag(it) }
}
)
}
```
### Creating a Reaction
```kotlin
fun createReaction(targetEvent: Event, emoji: String): EventTemplate {
return eventTemplate(
kind = 7,
content = emoji,
tags = tagArray {
eTag(targetEvent.id)
pTag(targetEvent.pubKey)
}
)
}
```
### Creating an Addressable Event
```kotlin
fun createArticle(title: String, content: String, slug: String): EventTemplate {
return eventTemplate(
kind = 30023,
content = content,
tags = tagArray {
addUnique(arrayOf("d", slug)) // Unique identifier
add(arrayOf("title", title))
add(arrayOf("published_at", "${TimeUtils.now()}"))
}
)
}
```
## Quick Reference
| Tag | NIP | Usage | Example |
|-----|-----|-------|---------|
| e | 01 | Event reference | `["e", eventId, relay, marker]` |
| p | 01 | Pubkey reference | `["p", pubkey, relay]` |
| a | 01 | Addressable event | `["a", "kind:pubkey:d"]` |
| d | 01 | Identifier | `["d", "unique-id"]` |
| subject | 14 | Subject line | `["subject", "Hello"]` |
| content-warning | 36 | Content warning | `["content-warning", "nsfw"]` |
| expiration | 40 | Expiration time | `["expiration", "1234567890"]` |
| bolt11 | 57 | Lightning invoice | `["bolt11", "lnbc..."]` |
| imeta | 92 | Media metadata | `["imeta", "url", "m", "image/jpeg"]` |
| relay | 65 | User relays | `["relay", "wss://...", "read"]` |
## Resources
- Tag builders: `quartz/src/commonMain/.../nip01Core/tags/`
- Tag extensions: Look for `TagArrayExt.kt`, `TagArrayBuilderExt.kt` in each NIP package
- Event parsing: Each event class has tag parsing methods

View File

@@ -0,0 +1,58 @@
#!/bin/bash
# Find NIP implementation files by NIP number or search term
set -e
QUARTZ_PATH="${QUARTZ_PATH:-./quartz/src/commonMain/kotlin/com/vitorpamplona/quartz}"
if [ $# -eq 0 ]; then
echo "Usage: $0 <nip-number|search-term>"
echo ""
echo "Examples:"
echo " $0 01 # Find NIP-01 files"
echo " $0 44 # Find NIP-44 files"
echo " $0 encryption # Search for 'encryption' in NIP packages"
echo ""
echo "Set QUARTZ_PATH to override default quartz location"
exit 1
fi
SEARCH_TERM="$1"
# Check if it's a number (NIP number)
if [[ "$SEARCH_TERM" =~ ^[0-9]+$ ]]; then
# Pad to 2 digits
NIP_NUM=$(printf "%02d" "$SEARCH_TERM")
echo "Searching for NIP-$NIP_NUM implementation..."
echo "================================================"
echo ""
# Find directories matching nip##*
find "$QUARTZ_PATH" -type d -name "nip${NIP_NUM}*" | while read -r dir; do
echo "📁 $(basename "$dir")/"
find "$dir" -name "*.kt" -type f | while read -r file; do
rel_path="${file#$QUARTZ_PATH/}"
echo " └─ $rel_path"
done
echo ""
done
else
# Text search
echo "Searching for '$SEARCH_TERM' in NIP packages..."
echo "================================================"
echo ""
find "$QUARTZ_PATH" -type d -name "nip*" | while read -r dir; do
if grep -r -l -i "$SEARCH_TERM" "$dir" --include="*.kt" 2>/dev/null | head -1 > /dev/null; then
echo "📁 $(basename "$dir")/"
grep -r -l -i "$SEARCH_TERM" "$dir" --include="*.kt" 2>/dev/null | while read -r file; do
rel_path="${file#$QUARTZ_PATH/}"
matches=$(grep -c -i "$SEARCH_TERM" "$file" 2>/dev/null || echo "0")
echo " └─ $rel_path ($matches matches)"
done
echo ""
fi
done
fi
echo "Done."

View File

@@ -0,0 +1,671 @@
---
name: quartz-integration
description: Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects.
---
# Quartz Integration Guide
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.1` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
---
## 1. Gradle Setup
### Version Catalog (`libs.versions.toml`)
```toml
[versions]
quartz = "1.06.1"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
```
### `build.gradle.kts` (KMP project)
```kotlin
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.quartz)
}
}
}
```
### Android-only project
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.06.1")
}
```
### Transitive dependencies pulled in automatically
Quartz exposes these as `api` (you get them transitively):
| Dependency | Used for |
|-----------|----------|
| `fr.acinq.secp256k1:secp256k1-kmp-*` | Schnorr signing |
| `com.github.anthonynsimon:rfc3986-normalizer` | Relay URL normalization |
| `com.fasterxml.jackson.module:jackson-module-kotlin` | Event JSON parsing |
For Android, add to `build.gradle.kts`:
```kotlin
android {
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
```
---
## 2. Key Concepts
### Core Types
```kotlin
typealias HexKey = String // 64-char hex string (pubkey, event id, sig)
typealias Kind = Int // Event kind number
typealias TagArray = Array<Array<String>>
```
### Event Anatomy
```kotlin
@Immutable
open class Event(
val id: HexKey, // SHA-256 of canonical JSON (64 hex chars)
val pubKey: HexKey, // Author public key (64 hex chars)
val createdAt: Long, // Unix timestamp (seconds)
val kind: Kind, // Event type
val tags: TagArray, // [["e","eventid"], ["p","pubkey"], ...]
val content: String,
val sig: HexKey, // Schnorr signature (128 hex chars)
)
```
### Kind Classification
```kotlin
// Regular events — stored by relays forever
val isRegular = kind in 1..9999
// Replaceable events — relay keeps only latest per (pubkey, kind)
val isReplaceable = kind == 0 || kind == 3 || kind in 10000..19999
// Addressable events — relay keeps latest per (pubkey, kind, d-tag)
val isAddressable = kind in 30000..39999
// Ephemeral events — relays don't persist
val isEphemeral = kind in 20000..29999
```
---
## 3. Key Management
### Generate a new keypair
```kotlin
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
// Generate fresh random keys
val keyPair = KeyPair()
// From existing private key bytes
val keyPair = KeyPair(privKey = myPrivKeyBytes)
// Read-only (public key only, cannot sign)
val keyPair = KeyPair(pubKey = myPubKeyBytes)
// Access
val pubKeyHex: String = keyPair.pubKey.toHexKey()
val privKeyHex: String? = keyPair.privKey?.toHexKey()
```
### Convert between formats
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
// ByteArray → hex
val hex = byteArray.toHexKey()
// hex → ByteArray
val bytes = HexKey.decodeHex(hex)
// Bech32 import (npub, nsec)
val parsed = Nip19Parser.uriToRoute("npub1abc...")
// or
val parsed = Nip19Parser.uriToRoute("nsec1abc...")
```
---
## 4. Signing Events
### `NostrSignerInternal` (local key, JVM + Android)
```kotlin
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
val keyPair = KeyPair()
val signer = NostrSignerInternal(keyPair)
// Sign any EventTemplate
val template = TextNoteEvent.build("Hello Nostr!")
val signedEvent: TextNoteEvent = signer.sign(template)
```
### `NostrSignerSync` (synchronous, for testing)
```kotlin
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
val signerSync = NostrSignerSync(keyPair)
val event = signerSync.sign<TextNoteEvent>(
createdAt = TimeUtils.now(),
kind = 1,
tags = emptyArray(),
content = "Hello!"
)
```
### NostrSigner interface (for custom signers)
```kotlin
abstract class NostrSigner(val pubKey: HexKey) {
abstract fun isWriteable(): Boolean
abstract suspend fun <T : Event> sign(createdAt: Long, kind: Int, tags: Array<Array<String>>, content: String): T
abstract suspend fun nip04Encrypt(plaintext: String, toPublicKey: HexKey): String
abstract suspend fun nip04Decrypt(ciphertext: String, fromPublicKey: HexKey): String
abstract suspend fun nip44Encrypt(plaintext: String, toPublicKey: HexKey): String
abstract suspend fun nip44Decrypt(ciphertext: String, fromPublicKey: HexKey): String
abstract suspend fun deriveKey(nonce: HexKey): HexKey
abstract fun hasForegroundSupport(): Boolean
// Convenience: auto-detects NIP-04 vs NIP-44 by ciphertext format
suspend fun decrypt(encryptedContent: String, fromPublicKey: HexKey): String
}
```
---
## 5. Creating Events
### Using typed event builders (recommended)
```kotlin
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
// Kind 1 — Text note
val template = TextNoteEvent.build("Hello Nostr!")
val event: TextNoteEvent = signer.sign(template)
// Kind 1 — Reply
val replyTemplate = TextNoteEvent.build(
note = "Interesting thread!",
replyingTo = originalEventHintBundle
)
// Kind 7 — Reaction
val reactionTemplate = ReactionEvent.build(
content = "+", // "+" = like, "-" = dislike, emoji = custom
originalNote = targetEvent
)
```
### Using low-level `Event.build` DSL
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.Event
val template = Event.build(
kind = 1,
content = "Hello world",
createdAt = TimeUtils.now()
) {
// TagArrayBuilder DSL
add(arrayOf("p", mentionedPubKey))
add(arrayOf("t", "nostr"))
add(arrayOf("subject", "Greeting"))
}
val event: Event = signer.sign(template)
```
### TagArrayBuilder DSL methods
```kotlin
// In the DSL lambda:
add(arrayOf("tagname", "value")) // append
addFirst(arrayOf("tagname", "value")) // prepend
addUnique(arrayOf("d", "my-slug")) // replace all tags with same name
addAll(listOf(arrayOf("t", "tag1"), ...)) // bulk add
remove("tagname") // remove all with this name
```
## 6. Relay Client Setup (JVM / Android)
The relay client requires an OkHttp WebSocket builder (available on JVM + Android).
### Minimal setup
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import okhttp3.OkHttpClient
// Build the WebSocket factory
val okHttpClient = OkHttpClient.Builder().build()
val wsBuilder = BasicOkHttpWebSocket.Builder { _ -> okHttpClient }
// Create client (manages its own CoroutineScope internally)
val nostrClient = NostrClient(websocketBuilder = wsBuilder)
nostrClient.connect()
```
### With custom OkHttpClient per relay
```kotlin
val wsBuilder = BasicOkHttpWebSocket.Builder { normalizedUrl ->
if (normalizedUrl.url.contains(".onion")) {
torEnabledOkHttpClient // Tor proxy for .onion relays
} else {
regularOkHttpClient
}
}
```
### With custom CoroutineScope
```kotlin
val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val nostrClient = NostrClient(wsBuilder, scope = appScope)
```
---
## 7. Subscribing to Events
### Normalize relay URLs first
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
// Returns NormalizedRelayUrl (wrapper with validated wss:// URL)
val relayUrl = RelayUrlNormalizer.normalize("wss://relay.damus.io")
val relayUrlOrNull = RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io")
// Handles common fixes: https:// → wss://, strips whitespace, etc.
```
### Build a Filter
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
// Fetch a user's notes
val filter = Filter(
authors = listOf(pubKeyHex),
kinds = listOf(1),
limit = 50
)
// Since a timestamp
val filter = Filter(
kinds = listOf(1, 6),
since = System.currentTimeMillis() / 1000 - 3600 // last hour
)
// By event tags
val filter = Filter(
kinds = listOf(7),
tags = mapOf("e" to listOf(eventId)) // reactions to an event
)
// AND tag filter (NIP-91)
val filter = Filter(
kinds = listOf(1),
tagsAll = mapOf(
"t" to listOf("nostr"),
"p" to listOf(specificPubKey)
)
)
// Full-text search (NIP-50)
val filter = Filter(
kinds = listOf(1),
search = "bitcoin lightning"
)
```
### Open a subscription
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
val relay = RelayUrlNormalizer.normalize("wss://relay.damus.io")
val subId = "my-sub-${System.currentTimeMillis()}"
val filtersMap = mapOf(relay to listOf(filter))
nostrClient.openReqSubscription(
subId = subId,
filters = filtersMap,
listener = object : IRequestListener {
override fun onEvent(subId: String, event: Event, relay: IRelayClient) {
println("Got event: ${event.id}")
}
override fun onEOSE(subId: String, relay: IRelayClient) {
println("End of stored events from ${relay.url}")
}
}
)
// Close when done
nostrClient.close(subId)
```
### Global relay listener
```kotlin
nostrClient.subscribe(object : IRelayClientListener {
override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) {
when (msg) {
is EventMessage -> handleEvent(msg.subscriptionId, msg.event)
is EoseMessage -> handleEose(msg.subscriptionId)
else -> {}
}
}
override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) {
println("Connected to ${relay.url} in ${pingMillis}ms")
}
override fun onDisconnected(relay: IRelayClient) {
println("Disconnected from ${relay.url}")
}
// other callbacks: onConnecting, onSent, onCannotConnect
})
```
---
## 8. Publishing Events
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
val relaySet = setOf(
RelayUrlNormalizer.normalize("wss://relay.damus.io"),
RelayUrlNormalizer.normalize("wss://nos.lol"),
)
// Sign the event
val template = TextNoteEvent.build("Hello Nostr!")
val event: TextNoteEvent = signer.sign(template)
// Send to relays (handles retry + reconnect automatically)
nostrClient.send(event, relaySet)
```
---
## 9. Event Serialization
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.Event
// Serialize to JSON string
val json: String = event.toJson()
// Parse from JSON string
val event: Event = Event.fromJson(json)
// Null-safe parse
val event: Event? = Event.fromJsonOrNull(json)
// Specific typed parse (returns base Event, cast if needed)
val textNote = Event.fromJson(json) as? TextNoteEvent
```
---
## 10. Bech32 Encoding / Decoding (NIP-19)
```kotlin
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
// Decode any bech32 entity
val result = Nip19Parser.uriToRoute("npub1abc...")
// Returns: NPub | NSec | Note | NEvent | NProfile | NAddr | null
when (val r = Nip19Parser.uriToRoute(input)) {
is Nip19Parser.Return.NPub -> println("pubkey: ${r.hex}")
is Nip19Parser.Return.Note -> println("event id: ${r.hex}")
is Nip19Parser.Return.NEvent -> println("event: ${r.hex}, relays: ${r.relays}")
is Nip19Parser.Return.NProfile -> println("profile: ${r.hex}")
is Nip19Parser.Return.NAddr -> println("address: ${r.kind}:${r.pubKey}:${r.dTag}")
null -> println("not a valid bech32 entity")
else -> {}
}
// The parser also handles nostr: URI scheme
val result = Nip19Parser.uriToRoute("nostr:npub1abc...")
```
---
## 11. Encryption
### NIP-44 (modern, recommended)
```kotlin
// Via signer (preferred)
val encrypted = signer.nip44Encrypt(
plaintext = "Secret message",
toPublicKey = recipientPubKeyHex
)
val decrypted = signer.nip44Decrypt(
ciphertext = encrypted,
fromPublicKey = senderPubKeyHex
)
// Auto-detect format (NIP-04 or NIP-44)
val plaintext = signer.decrypt(encryptedContent, fromPublicKeyHex)
```
### NIP-04 (legacy, avoid for new code)
```kotlin
val encrypted = signer.nip04Encrypt(plaintext, recipientPubKeyHex)
val decrypted = signer.nip04Decrypt(ciphertext, senderPubKeyHex)
```
---
## 12. Common NIP Event Builders
### NIP-02 — Follow list (kind 3)
```kotlin
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
val template = ContactListEvent.build(
follows = listOf(
ContactListEvent.Contact(pubKey = alicePubKey, relayUrl = "wss://relay.damus.io", petname = "alice"),
ContactListEvent.Contact(pubKey = bobPubKey)
)
)
```
### NIP-25 — Reaction (kind 7)
```kotlin
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
val like = ReactionEvent.build("+", targetEvent)
val dislike = ReactionEvent.build("-", targetEvent)
val custom = ReactionEvent.build("🤙", targetEvent)
```
### NIP-57 — Zap request (kind 9734)
```kotlin
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
val template = LnZapRequestEvent.build(
message = "Great post!",
relays = listOf("wss://relay.damus.io"),
target = targetEvent,
zapType = LnZapRequestEvent.ZapType.PUBLIC
)
val zapRequest: LnZapRequestEvent = signer.sign(template)
```
### NIP-59 — Gift wrap / sealed DM (kind 1059 + 14)
```kotlin
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
// Creates sealed rumor + gift wrap pair
val (dmEvent, giftWrap) = NIP17Factory.create(
msg = "Private message",
fromSigner = senderSigner,
toUsers = listOf(recipientPubKey),
relayList = listOf("wss://relay.damus.io")
)
```
### NIP-23 — Long-form article (kind 30023)
```kotlin
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
val template = LongTextNoteEvent.build(
body = markdownContent,
title = "My Article",
image = "https://example.com/cover.jpg",
summary = "A brief summary",
slug = "my-article" // d-tag
)
```
---
## 13. Platform-Specific Notes
### JVM / Desktop
```kotlin
// jvmMain dependencies needed in consuming project:
// secp256k1-kmp-jni-jvm and lazysodium-java are transitive from quartz
// But you need JNA on the classpath for libsodium:
implementation("net.java.dev.jna:jna:5.18.1")
```
### Android
```kotlin
// androidMain dependencies (transitive from quartz):
// secp256k1-kmp-jni-android, lazysodium-android, jna (aar)
// No extra setup needed beyond the maven dependency.
// For NIP-55 (Android external signer apps):
import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher
```
### iOS
The library produces an XCFramework named `quartz-kmpKit`.
```bash
# Build XCFramework
./gradlew :quartz:assembleQuartz-kmpKitReleaseXCFramework
# Output: quartz/build/XCFrameworks/release/quartz-kmpKit.xcframework
```
In Xcode: drag & drop the `.xcframework` into your project, then use from Swift via Kotlin/Native interop.
---
## 14. Event Store (Android only)
SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-62):
```kotlin
import com.vitorpamplona.quartz.nip01Core.store.EventStore
import android.content.Context
val store = EventStore()
// Insert
store.insert(event)
// Query
val events = store.query(
Filter(authors = listOf(pubKey), kinds = listOf(1), limit = 50)
)
// Count (NIP-45)
val count = store.count(Filter(kinds = listOf(1)))
// Full-text search (NIP-50)
val results = store.query(Filter(search = "bitcoin"))
```
---
## 15. Quick Reference
| Task | API | Package |
|------|-----|---------|
| Generate keys | `KeyPair()` | `nip01Core.crypto` |
| Create signer | `NostrSignerInternal(keyPair)` | `nip01Core.signers` |
| Build event | `TextNoteEvent.build(...)` or `Event.build(kind, content) { tags }` | `nip10Notes`, `nip01Core.core` |
| Sign event | `signer.sign(template)` | `nip01Core.signers` |
| Serialize | `event.toJson()` | `nip01Core.core` |
| Parse | `Event.fromJson(json)` | `nip01Core.core` |
| Normalize relay URL | `RelayUrlNormalizer.normalize("wss://...")` | `nip01Core.relay.normalizer` |
| Setup relay client | `NostrClient(BasicOkHttpWebSocket.Builder { okhttp })` | `nip01Core.relay.client` |
| Subscribe | `client.openReqSubscription(subId, mapOf(relay to filters), listener)` | `nip01Core.relay.client` |
| Publish | `client.send(event, setOf(relayUrl))` | `nip01Core.relay.client` |
| NIP-44 encrypt | `signer.nip44Encrypt(text, recipientPubKey)` | `nip01Core.signers` |
| Bech32 decode | `Nip19Parser.uriToRoute("npub1...")` | `nip19Bech32` |
| Bech32 encode | `Nip19Bech32.createNPub(pubKeyHex)` | `nip19Bech32` |
## Common Event Kinds
| Kind | Event Type | NIP | Quartz class |
|------|-----------|-----|-------------|
| 0 | User metadata | 01 | `MetadataEvent` |
| 1 | Text note | 10 | `TextNoteEvent` |
| 3 | Follow list | 02 | `ContactListEvent` |
| 4 | Legacy DM | 04 | `PrivateDmEvent` |
| 5 | Deletion | 09 | `DeletionEvent` |
| 6 | Repost | 18 | `RepostEvent` |
| 7 | Reaction | 25 | `ReactionEvent` |
| 14 | Chat message (sealed) | 17 | `NIP17GroupMessage` |
| 1059 | Gift wrap | 59 | `GiftWrapEvent` |
| 9734 | Zap request | 57 | `LnZapRequestEvent` |
| 9735 | Zap receipt | 57 | `LnZapEvent` |
| 10002 | Relay list | 65 | `AdvertisedRelayListEvent` |
| 30023 | Long-form content | 23 | `LongTextNoteEvent` |
## Related Skills
- **nostr-expert** — Internal Quartz patterns for Amethyst development
- **kotlin-multiplatform** — KMP source sets, expect/actual patterns
- **kotlin-coroutines** — Flow patterns for relay event streams

View File

@@ -0,0 +1,142 @@
# Quartz Gradle Dependency Setup
## Current version
```
com.vitorpamplona.quartz:quartz:1.06.1
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
---
## KMP Project Setup
### `gradle/libs.versions.toml`
```toml
[versions]
quartz = "1.06.1"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
```
### `build.gradle.kts` (library/app module)
```kotlin
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary) // or androidLibrary/androidApplication
}
kotlin {
jvm()
androidLibrary {
namespace = "com.example.myapp"
compileSdk = 35
minSdk = 21
}
// optional: iOS targets
sourceSets {
commonMain.dependencies {
implementation(libs.quartz)
}
// No platform-specific deps needed — Quartz provides them transitively
}
}
```
---
## Android-only Project
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.06.1")
}
```
---
## JVM (Desktop) standalone
```kotlin
// build.gradle.kts
plugins {
kotlin("jvm")
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.06.1")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
```
---
## Transitive Dependencies (what you get automatically)
### All platforms (`commonMain`)
- `org.jetbrains.kotlin:kotlin-stdlib`
- `org.jetbrains.kotlinx:kotlinx-coroutines-core`
- `org.jetbrains.kotlinx:kotlinx-collections-immutable`
- `org.jetbrains.kotlinx:kotlinx-serialization-json`
- `androidx.collection:collection` (LruCache)
- `androidx.compose.runtime:runtime-annotation` (@Immutable/@Stable)
- `fr.acinq.secp256k1:secp256k1-kmp` (Schnorr crypto — common)
### JVM + Android (`jvmAndroid`)
- `com.github.anthonynsimon:rfc3986-normalizer` (URL normalization)
- `com.fasterxml.jackson.module:jackson-module-kotlin` (JSON)
- `com.squareup.okhttp3:okhttp` (WebSocket)
- `ru.gildor.coroutines:kotlin-coroutines-okhttp`
- `nl.bommber:kchesslib` (NIP-64 chess, version pinned to 1.0.0)
### JVM only
- `fr.acinq.secp256k1:secp256k1-kmp-jni-jvm`
- `com.goterl:lazysodium-java` (NIP-44 encryption)
- `net.java.dev.jna:jna`
### Android only
- `fr.acinq.secp256k1:secp256k1-kmp-jni-android`
- `com.goterl:lazysodium-android`
- `net.java.dev.jna:jna` (aar)
- `androidx.core:core-ktx`
---
## Packaging / ProGuard
Quartz ships consumer ProGuard rules automatically (`publish = true` in the library).
You don't need to add manual keep rules for Quartz classes in your app.
For Android release builds, these classes are preserved:
- All `com.vitorpamplona.quartz.**` event and model classes
- Jackson serialization annotations
---
## Common Build Errors
### `Duplicate class kotlin.collections.jdk8`
Add to `gradle.properties`:
```properties
android.useFullClasspathForDexingTransform=true
```
### `Could not find lazysodium-java` on JVM
Ensure JNA is on the classpath:
```kotlin
implementation("net.java.dev.jna:jna:5.18.1")
```
### iOS: `Framework not found quartz-kmpKit`
Build the XCFramework first:
```bash
./gradlew :quartz:assembleQuartz-kmpKitReleaseXCFramework
```
Then drag `quartz/build/XCFrameworks/release/quartz-kmpKit.xcframework` into Xcode.

View File

@@ -0,0 +1,23 @@
# Quartz KMP (Legacy Skill — Migration Complete)
> The KMP migration of Quartz is **complete**. This file is kept for historical reference.
>
> For integrating Quartz into external projects, use the **`quartz-integration`** skill instead.
> For working with Quartz internals within Amethyst, use the **`nostr-expert`** skill.
## What was migrated
The Quartz library was successfully converted from Android-only to full KMP supporting:
- **commonMain** — All Nostr protocol logic, events, filters, tags
- **jvmAndroid** — OkHttp WebSocket, Jackson JSON, relay serializers
- **androidMain** — SQLite event store, NIP-55 Android signer
- **jvmMain** — Desktop JVM crypto (lazysodium-java, secp256k1-jni-jvm)
- **iosMain** — iOS targets (XCFramework `quartz-kmpKit`)
## Current artifact
```
com.vitorpamplona.quartz:quartz:1.06.1
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.

View File

@@ -8,7 +8,8 @@ trim_trailing_whitespace = true
max_line_length = 5000
ij_kotlin_allow_trailing_comma=true
ij_kotlin_allow_trailing_comma_on_call_site=true
ktlint_function_naming_ignore_when_annotated_with=Composable, Test
ktlint_function_naming_ignore_when_annotated_with=Composable
ktlint_standard_no-unused-imports=enabled
[*.gradle]
indent_size = 4

View File

@@ -12,7 +12,7 @@ echo "$JAVA_HOME"
echo "$(java -version)"
echo "Running test... "
./gradlew test
./gradlew test --quiet
status=$?

View File

@@ -1,4 +1,4 @@
name: Test/Build Android
name: Test/Build
on:
pull_request:
@@ -6,81 +6,190 @@ on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Linter (gradle)
run: ./gradlew spotlessCheck
test:
needs: lint
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Test (gradle)
run: ./gradlew test --no-daemon
- name: Android Test Report
uses: asadmansr/android-test-report-action@v1.2.0
if: ${{ always() }} # IMPORTANT: run Android Test Report regardless
if: ${{ always() && matrix.os == 'ubuntu-latest' }}
- name: Upload Test Results
uses: actions/upload-artifact@v6
if: ${{ always() && matrix.os == 'ubuntu-latest' }}
with:
name: Test Reports
path: amethyst/build/reports
build-android:
needs: test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build APK (gradle)
run: ./gradlew assembleDebug
- name: Upload Play APK
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Play Debug APK
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
- name: Upload FDroid APK
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: FDroid Debug APK
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk
- name: Build APK (gradle)
- name: Build Benchmark APK (gradle)
run: ./gradlew assembleBenchmark
- name: Upload Play APK Benchmark
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Play Benchmark APK
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-benchmark.apk
path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk
- name: Upload FDroid APK Benchmark
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: FDroid Benchmark APK
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-benchmark.apk
path: amethyst/build/outputs/apk/fdroid/benchmark/amethyst-fdroid-universal-benchmark.apk
- name: Upload Compose Reports
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Compose Reports
path: amethyst/build/compose_compiler
- name: Tests
run: ./gradlew test --no-daemon
build-desktop:
needs: test
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
task: packageDeb
artifact-name: Desktop Linux DEB
artifact-path: desktopApp/build/compose/binaries/main/deb/*.deb
- os: macos-latest
task: packageDmg
artifact-name: Desktop macOS DMG
artifact-path: desktopApp/build/compose/binaries/main/dmg/*.dmg
- os: windows-latest
task: packageMsi
artifact-name: Desktop Windows MSI
artifact-path: desktopApp/build/compose/binaries/main/msi/*.msi
runs-on: ${{ matrix.os }}
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Upload Test Results
uses: actions/upload-artifact@v4
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
name: Test Reports
path: amethyst/build/reports
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build Desktop Distribution
run: ./gradlew :desktopApp:${{ matrix.task }}
- name: Upload Desktop Distribution
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.artifact-name }}
path: ${{ matrix.artifact-path }}

View File

@@ -6,23 +6,42 @@ on:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
jobs:
deploy:
create-release:
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: true
deploy-android:
needs: create-release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
@@ -38,7 +57,6 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0"
- name: Sign AAB (F-Droid)
@@ -50,7 +68,6 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0"
- name: Build APK
@@ -65,7 +82,6 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0"
- name: Sign APK (F-Droid)
@@ -77,20 +93,8 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
# override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0"
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: true
# Google Play APK
- name: Upload Play APK Universal Asset
id: upload-release-asset-play-universal-apk
@@ -98,7 +102,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-universal-release-unsigned-signed.apk
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -109,7 +113,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -120,7 +124,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86_64-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -131,7 +135,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-arm64-v8a-release-unsigned-signed.apk
asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -142,7 +146,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-armeabi-v7a-release-unsigned-signed.apk
asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -154,7 +158,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-universal-release-unsigned-signed.apk
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -165,7 +169,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -176,7 +180,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86_64-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -187,7 +191,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned-signed.apk
asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -198,13 +202,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-armeabi-v7a-release-unsigned-signed.apk
asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk
asset_content_type: application/zip
# Google Play AAB
- name: Upload Google Play AAB Asset
id: upload-release-asset-play-aab
@@ -212,7 +214,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
asset_content_type: application/zip
@@ -224,7 +226,77 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
asset_content_type: application/zip
- name: Publish Quartz Lib
run: ./gradlew publishAllPublicationsToMavenCentral --no-configuration-cache
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
deploy-desktop:
needs: create-release
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
task: packageDeb
format: deb
platform: linux
- os: macos-latest
task: packageDmg
format: dmg
platform: macos
- os: windows-latest
task: packageMsi
format: msi
platform: windows
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build Desktop Distribution
run: ./gradlew :desktopApp:${{ matrix.task }}
- name: Find distribution file
id: find-dist
run: |
DIST_FILE=$(find desktopApp/build/compose/binaries/main/${{ matrix.format }} -type f \( -name "*.deb" -o -name "*.dmg" -o -name "*.msi" \) | head -1)
echo "path=$DIST_FILE" >> $GITHUB_OUTPUT
echo "name=$(basename $DIST_FILE)" >> $GITHUB_OUTPUT
- name: Upload Desktop Distribution to Release
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: ${{ steps.find-dist.outputs.path }}
asset_name: amethyst-desktop-${{ matrix.platform }}-${{ github.ref_name }}.${{ matrix.format }}
asset_content_type: application/octet-stream

View File

@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: crowdin action
uses: crowdin/github-action@v2

26
.gitignore vendored
View File

@@ -17,11 +17,26 @@
/.idea/ChatHistory_schema_v2.xml
/.idea/artifacts/*
/.idea/kotlinNotebook.xml
/.idea/ChatHistory_schema_v3.xml
/.idea/markdown.xml
/.idea/AndroidProjectSystem.xml
/.idea/deviceManager.xml
/.idea/inspectionProfiles/
/.idea/migrations.xml
/commons/.idea/gradle.xml
/commons/.idea/misc.xml
/commons/.idea/workspace.xml
/quartz/.idea/modules.xml
/quartz/.idea/workspace.xml
.DS_Store
/build
/captures
.cxx
# superpowers skill
.superpowers
docs/brainstorms
docs/superpowers
# Built application files
*.apk
@@ -136,3 +151,14 @@ lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Local task tracking
TASKS.md
# Claude Code local settings
.claude/settings.local.json
# Downloaded VLC binaries (vlc-setup plugin)
desktopApp/src/jvmMain/appResources/linux/
desktopApp/src/jvmMain/appResources/macos/
desktopApp/src/jvmMain/appResources/windows/

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

View File

@@ -1,77 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinNotebookOptionsProvider">
<option name="shouldAddProjectLibrariesToClasspath" value="true" />
</component>
</project>

5
.idea/kotlinc.xml generated
View File

@@ -4,9 +4,10 @@
<option name="moduleKind" value="plain" />
</component>
<component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="1.8" />
<option name="jvmTarget" value="21" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.1.0" />
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.3.20" />
</component>
</project>

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of

7080
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

220
README.md
View File

@@ -42,13 +42,13 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Events / Relay Subscriptions (NIP-01)
- [x] Follow List (NIP-02)
- [x] OpenTimestamps Attestations (NIP-03)
- [x] Private Messages (NIP-04)
- [x] Private Messages (NIP-04 -- to be removed)
- [x] DNS Address (NIP-05)
- [x] Mnemonic seed phrase (NIP-06)
- [ ] WebBrowser Signer (NIP-07, Not applicable)
- [x] Old-style mentions (NIP-08)
- [x] Event Deletion (NIP-09)
- [x] Replies, mentions, Threads, and Notifications (NIP-10)
- [x] Text Notes and Threads (NIP-10)
- [x] Relay Information Document (NIP-11)
- [x] Generic Tag Queries (NIP-12)
- [x] Proof of Work Display (NIP-13)
@@ -57,9 +57,9 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [ ] Marketplace (NIP-15)
- [x] Event Treatment (NIP-16)
- [x] Private Direct Messages (NIP-17)
- [x] Image/Video/Url/LnInvoice Previews
- [x] Image/Video/Url/LnInvoice/Cashu Previews
- [x] Reposts, Quotes, Generic Reposts (NIP-18)
- [x] Bech Encoding support (NIP-19)
- [x] Bech32 Encoding support (NIP-19)
- [x] Command Results (NIP-20)
- [x] URI Support (NIP-21)
- [x] Long-form Content (NIP-23) (view only)
@@ -70,45 +70,59 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Public Chats (NIP-28)
- [ ] Relay-based Groups (NIP-29)
- [x] Custom Emoji (NIP-30)
- [x] Event kind summaries (NIP-31)
- [x] Event alt descriptors (NIP-31)
- [ ] Labeling (NIP-32)
- [x] Parameterized Replaceable Events (NIP-33)
- [x] Git Stuff (NIP-34)
- [ ] Torrents (NIP-35)
- [x] Torrents (NIP-35)
- [x] Sensitive Content (NIP-36)
- [x] Edits (NIP-37/Draft)
- [x] Drafts (NIP-37)
- [x] User Status Event (NIP-38)
- [x] External Identities (NIP-39)
- [x] Expiration Support (NIP-40)
- [x] Relay Authentication (NIP-42)
- [ ] Relay Access Metadata and Requests (NIP-43)
- [x] Versioned Encrypted Payloads (NIP-44)
- [ ] Event Counts (NIP-45, Will not implement)
- [ ] Nostr Connect (NIP-46)
- [x] Wallet Connect API (NIP-47)
- [x] Event Counts (NIP-45)
- [o] Nostr Connect (NIP-46)
- [o] Wallet Connect API (NIP-47)
- [ ] Proxy Tags (NIP-48, Not applicable)
- [x] Private key encryption for import/export (NIP-49)
- [x] Online Relay Search (NIP-50)
- [x] Encryption for import/export (NIP-49)
- [x] Relay Search (NIP-50)
- [x] Lists (NIP-51)
- [ ] Calendar Events (NIP-52)
- [o] Calendar Events (NIP-52)
- [x] Live Activities & Live Chats (NIP-53)
- [x] Wiki (NIP-54)
- [x] Inline Metadata (NIP-55 - Draft)
- [x] Android Signer (NIP-55)
- [x] Reporting (NIP-56)
- [x] Lightning Tips
- [x] Zaps (NIP-57)
- [x] Private Zaps
- [x] Zap Splits (NIP-57)
- [x] Private Zaps (NIP-57 / Draft)
- [x] Zapraiser (NIP-57 / Draft)
- [x] Badges (NIP-58)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Zapraiser (NIP-TBD)
- [ ] Relay List Metadata (NIP-65)
- [x] Polls (NIP-69)
- [ ] Cashu Wallets (NIP-60)
- [ ] Nutzaps (NIP-61)
- [x] Request to Vanish (NIP-62)
- [x] Chess (NIP-64)
- [x] Relay List Metadata (NIP-65)
- [x] Relay Discovery and Monitoring (NIP-66)
- [x] Picture-first feeds (NIP-68)
- [ ] Peer-to-peer Orders (NIP-69)
- [o] Protected Events (NIP-70)
- [x] Video Events (NIP-71)
- [x] Moderated Communities (NIP-72)
- [x] External Content IDs (NIP-73)
- [ ] Zap Goals (NIP-75)
- [ ] Negentropy Syncing (NIP-77)
- [x] Arbitrary Custom App Data (NIP-78)
- [ ] NIP-29 Threads (NIP-7D)
- [x] Highlights (NIP-84)
- [x] Notify Request (NIP-88/Draft)
- [x] Trusted Assertions (NIP-85)
- [ ] Relay Management API (NIP-86)
- [ ] ECash Mint Discoverability (NIP-87)
- [x] Polls (NIP-88)
- [x] Relay Notify Request
- [x] Recommended Application Handlers (NIP-89)
- [x] Data Vending Machine (NIP-90)
- [x] Inline Metadata (NIP-92)
@@ -117,6 +131,14 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] HTTP File Storage Integration (NIP-96)
- [x] HTTP Auth (NIP-98)
- [x] Classifieds (NIP-99)
- [x] Voice Messages (NIP-A0)
- [x] Public Messages (NIP-A4)
- [ ] Web Bookmarks
- [x] Blossom (NIP-B7)
- [ ] Nostr BLE Communications Protocol (NIP-BE)
- [x] Code Snippets (NIP-C0)
- [ ] NIP-29 Chats (NIP-C7)
- [ ] MLS Protocol (NIP-EE)
- [x] Audio Tracks (zapstr.live) (kind:31337)
- [x] Push Notifications (Google and Unified Push)
- [x] In-Device Automatic Translations
@@ -128,18 +150,15 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Markdown Support
- [x] Medical Data (NIP-xx/Draft)
- [x] Embed events (NIP-xx/Draft)
- [x] Draft Events (NIP-xx/Draft)
- [ ] Event Sets (NIP-xx/Draft)
- [ ] Topical Notes (NIP-xx/Draft)
- [x] Edit Short Notes (NIP-xx/Draft)
- [x] NIP Events (NIP-xx/Draft)
- [ ] Relationship Status (NIP-xx/Draft)
- [ ] Signed Filters (NIP-xx/Draft)
- [ ] Key Migration (NIP-xx/Draft)
- [ ] Time-based Sync (NIP-xx/Draft)
- [x] Image Capture in the app
- [ ] Video Capture in the app
- [x] Video Capture in the app
- [ ] Local Database
- [ ] Workspaces
- [ ] Infinity Scroll
## Privacy and Information Permanence
@@ -153,9 +172,11 @@ Information shared on Nostr can be re-broadcasted to other servers and should be
# Development Overview
This repository is split between Amethyst and Quartz:
- Amethyst is a native Android app made with Kotlin and Jetpack Compose.
- Quartz is our own Nostr-commons library to host classes that are of interest to other Nostr Clients.
This repository is split between Amethyst, Quartz, Commons, and DesktopApp:
- **Amethyst** - Native Android app with Kotlin and Jetpack Compose
- **Quartz** - Nostr-commons KMP library for protocol classes shared across platforms
- **Commons** - Kotlin Multiplatform module with shared UI components (icons, robohash, blurhash, composables)
- **DesktopApp** - Compose Multiplatform Desktop application reusing commons and quartz
The app architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph.
@@ -187,11 +208,24 @@ git clone https://github.com/vitorpamplona/amethyst.git
Use an Android Studio build action to install and run the app on your device or a simulator.
## Building
Build the app:
Build the Android app:
```bash
./gradlew assembleDebug
```
Build and run the Desktop app (requires Java 21+):
```bash
./gradlew :desktopApp:run
```
Full build (including tests)
```bash
./gradlew build
```
Requirements:
- Xcode and iOS simulator
- libsodium installed (e.g. via brew: `brew install libsodium`
## Testing
```bash
./gradlew test
@@ -237,53 +271,63 @@ openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.j
## Using the Quartz library
Setup [JitPack.io](https://jitpack.io/#vitorpamplona/amethyst/v0.84.3) to your build file
### Installing
Add `maven { url 'https://jitpack.io' }` to settings.gradle at the end of repositories:
Add Maven Central and Google Maven to your repositories:
```gradle
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
}
google()
}
```
Add the dependency by using one of the versions [here](https://jitpack.io/#vitorpamplona/amethyst/)
Add the following line to your `commonMain` dependencies:
```gradle
implementation('com.github.vitorpamplona.amethyst:quartz:<tag, commit, -SNAPSHOT>')
implementation('com.vitorpamplona.quartz:quartz:1:05.0')
```
Variations to each platform are also available:
```gradle
implementation('com.vitorpamplona.quartz:quartz-android:1:05.0')
implementation('com.vitorpamplona.quartz:quartz-jvm:1:05.0')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1:05.0')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1:05.0')
```
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
### How to use
Manage logged in users with the `KeyPair` class
```kt
val keys = KeyPair() // creates a random key
val keys = KeyPair("hex...".hexToByteArray())
val keys = KeyPair("nsec1...".bechToBytes())
val keys = KeyPair(Nip06().privateKeyFromMnemonic("<mnemonic>"))
val keyPair = KeyPair() // creates a random key
val keyPair = KeyPair("hex...".hexToByteArray())
val keyPair = KeyPair("nsec1...".bechToBytes())
val keyPair = KeyPair(Nip06().privateKeyFromMnemonic("<mnemonic>"))
val readOnly = KeyPair(pubKey = "hex...".hexToByteArray())
val readOnly = KeyPair(pubKey = "npub1...".bechToBytes())
```
Create signers that can be internal, when you have the private key or when it is a read-only user
or external, when it is controlled by Amber in NIP-55
Create signers that can be Internal, when you have the private key or a read-only public key,
or External, when it is controlled by Amber in NIP-55.
the `NostrSignerInternal` and `NostrSignerExternal` classes.
Use either the `NostrSignerInternal` or `NostrSignerExternal` class:
```kt
val signer = NostrSignerInternal(keyPair)
val amberSigner = NostrSignerExternal(
pubKey = keyPair.pubKey.toHexKey(),
packageName = signerPackageName,
packageName = signerPackageName, // Amber package name
contentResolver = appContext.contentResolver,
)
```
Create a single NostrClient for the entire application and control which relays it will access by
registering subscriptions and sending events. The pool will automatically changed based on filters +
Create a single `NostrClient` for the entire application and control which relays it will access by
registering subscriptions and sending events. The pool will automatically change based on filters +
outbox events.
You will need a coroutine scope to process events and if you are using OKHttp, we offer a basic
@@ -300,37 +344,87 @@ val client = NostrClient(socketBuilder, appScope)
If you want to auth, given a logged-in `signer`:
```kt
val authCoordinator = RelayAuthenticator(client, applicationIOScope) { challenge, relay ->
val authedEvent = RelayAuthEvent.create(relayUrl, challenge, signer)
client.sendIfExists(authedEvent, relay.url)
val authCoordinator = RelayAuthenticator(client, appScope) { authTemplate ->
listOf(
// for each signed-in user, return an event
signer.sign(authTemplate)
)
}
```
To manage subscriptions, the suggested approach is to use subscriptions in the Application class.
To make a request subscription simply do:
```kt
val metadataSub = RelayClientSubscription(
client = client,
filter = {
val metadataSub = client.req(
relay = "wss://nos.lol",
filter = Filter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(signer.pubkey)
)
) { event ->
/* consume event */
}
```
The client will add the relay to the pool, connect to it and start receiving events. The
`metadataSub` will be active until you call `metadataSub.close()`. If the client disconnects
and reconnects, the sub will be active again.
To manage subscriptions that change over time, the simplest approach is to build mutable
subscriptions with a filters lambda that you can change at will.
```kt
val metadataSub = client.req(
filters = {
// Let's say you have a list of users that need to be rendered
val users = pubkeysSeeingInTheScreen()
// And a cache repository with their outbox relays
val outboxRelays = outboxRelays(users)
val filters = listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(signer.pubkey)
authors = users
)
)
val signerOutboxRelays = listOfNotNull(
RelayUrlNormalizer.normalizeOrNull("wss://relay1.com"),
RelayUrlNormalizer.normalizeOrNull("wss://relay2.com")
)
signerOutboxRelays.associateWith { filters }
outboxRelays.associateWith { filters }
}
) { event ->
/* consume event */
}
```
In that way, you can simply call `metadataSub.updateFilter()` when you need to update
subscriptions to all relays. Or call `metadataSub.close()` to stop the sub
without deleting it.
When your app goes to the background, you can use NostrClient's `connect` and `disconnect`
methods to stop all communication to relays. Add the `connect` to your `onResume` and `disconnect`
to `onPause` methods.
### Feature Parity Table
| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes |
| :--- | :--- | :---: | :---: | :--- |
| **Cryptography** | Secp256k1 (Schnorr, Keys) | Full | No | Core Nostr signing/verification is missing on iOS. |
| | LibSodium (ChaCha20, Poly1305) | Full | No | AEAD and stream ciphers are unimplemented. |
| | AES Encryption (CBC & GCM) | Full | No | `AESCBC` and `AESGCM` are stubs on iOS. |
| | Hashing (SHA-256, etc.) | Full | No | `DigestInstance` is unimplemented. |
| | MAC (HmacSHA256, etc.) | Full | No | `MacInstance` is unimplemented. |
| **Data & Serialization** | JSON Mapping (Optimized) | Full | No | `OptimizedJsonMapper` is a stub; cannot parse/serialize Events. |
| | GZip Compression | Full | No | `GZip` implementation is missing. |
| | BitSet | Full | No | `BitSet` utility is unimplemented. |
| | LargeCache | Full | No | `LargeCache` methods (get, keys, size, etc.) are stubs. |
| **NIP Support** | NIP-96 (File Storage Info) | Full | No | `ServerInfoParser` is unimplemented. |
| | NIP-46 (Remote Signer) | Full | Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. |
| | NIP-03 (OTS / Timestamps) | Full | No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. |
| **Utilities** | URL Encoding / Decoding | Full | No | `UrlEncoder` and `URLs.ios.kt` are unimplemented. |
| | Unicode Normalization | Full | No | `UnicodeNormalizer` is a stub. |
| | Platform Logging | Full | Full | iOS uses `NSLog`, Android uses standard Log. |
| | Current Time | Full | Full | Implemented using `NSDate` on iOS. |
## Contributing
Issues can be logged on: [https://gitworkshop.dev/repo/amethyst](https://gitworkshop.dev/repo/amethyst)

230
SKILL.md Normal file
View File

@@ -0,0 +1,230 @@
# Amethyst Builder Skill
Build customized Amethyst Nostr clients for Android. Fork, rebrand, customize, and distribute your own version.
## Overview
[Amethyst](https://github.com/vitorpamplona/amethyst) is the premier Nostr client for Android. This skill enables you to:
- Create rebranded versions (custom name, package, icons)
- Build F-Droid-compatible releases (no Google Play dependencies)
- Add or modify features
- Sign and distribute APKs
## Prerequisites
### Required Tools
1. **Java 21** (via SDKMAN)
```bash
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
sdk install java 21.0.5-tem
sdk use java 21.0.5-tem
```
2. **Android SDK**
- Command-line tools from https://developer.android.com/studio#command-line-tools-only
- Required components: build-tools, platform-tools, platforms;android-35
3. **Git** for cloning the repository
### Environment Setup
```bash
export ANDROID_HOME=$HOME/Android/Sdk
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin
export PATH=$PATH:$ANDROID_HOME/platform-tools
```
## Build Workflow
### 1. Clone the Repository
```bash
mkdir -p ~/projects/your-app-name
cd ~/projects/your-app-name
git clone https://github.com/vitorpamplona/amethyst.git .
```
### 2. Create Signing Key
```bash
keytool -genkeypair -v \
-keystore ./release-key.jks \
-alias your-app \
-keyalg RSA -keysize 2048 \
-validity 10000
```
Create `keystore.properties` in project root:
```properties
storeFile=release-key.jks
storePassword=your-password
keyAlias=your-app
keyPassword=your-password
```
### 3. Configure Signing
Add to `amethyst/build.gradle` inside the `android {}` block:
```gradle
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
signingConfigs {
release {
if (keystorePropertiesFile.exists()) {
storeFile rootProject.file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
}
}
}
```
Update the release buildType to use the signing config:
```gradle
buildTypes {
release {
signingConfig signingConfigs.release
// ... existing config
}
}
```
### 4. Disable Google Services (Required for F-Droid)
**⚠️ CRITICAL:** The Google Services plugin fails when you change the package name. For F-Droid builds, disable it.
Edit `amethyst/build.gradle`, comment out the plugin:
```gradle
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
// alias(libs.plugins.googleServices) // DISABLED for F-Droid
alias(libs.plugins.jetbrainsComposeCompiler)
}
```
### 5. Build
```bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
sdk use java 21.0.5-tem
./gradlew assembleFdroidRelease
```
**Build time:** ~9 minutes first build, faster on subsequent builds.
### 6. Locate APKs
Output directory: `amethyst/build/outputs/apk/fdroid/release/`
Files generated:
- `amethyst-fdroid-arm64-v8a-release.apk` - ARM64 (most phones)
- `amethyst-fdroid-universal-release.apk` - All architectures (larger)
## Customizations
### Change App Name
Edit `amethyst/src/main/res/values/strings.xml`:
```xml
<string name="app_name" translatable="false">YourAppName</string>
<string name="app_name_debug" translatable="false">YourAppName Debug</string>
```
### Change Package ID
Edit `amethyst/build.gradle`:
```gradle
android {
defaultConfig {
applicationId = "com.yourcompany.yourapp"
}
}
```
### Change Project Name
Edit `settings.gradle`:
```gradle
rootProject.name = "YourAppName"
```
### Change App Icon
Replace icon files in:
- `amethyst/src/main/res/mipmap-*/ic_launcher.webp`
- `amethyst/src/main/res/mipmap-*/ic_launcher_round.webp`
### Add Client Tag to Posts
Make your app identify itself on posts with `["client", "YourAppName"]`.
**1. Create tag builder extension:**
Create `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/clientTag/TagArrayBuilderExt.kt`:
```kotlin
package com.vitorpamplona.quartz.nip01Core.tags.clientTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun <T : Event> TagArrayBuilder<T>.client(clientName: String) =
addUnique(arrayOf(ClientTag.TAG_NAME, clientName))
```
**2. Add to TextNoteEvent:**
Edit `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt`:
Add import:
```kotlin
import com.vitorpamplona.quartz.nip01Core.tags.clientTag.client
```
In both `build()` functions, add after `alt(...)`:
```kotlin
client("YourAppName")
```
### Modify Default Relays
Edit relay configuration in `quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/` or the UI settings files.
## Troubleshooting
### google-services.json error
Disable the Google Services plugin (see step 4).
### Java version error
```bash
sdk use java 21.0.5-tem
java -version # Must show 21.x
```
### Out of memory
Edit `gradle.properties`:
```properties
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m
```
### Clean build
```bash
./gradlew --stop
./gradlew clean
./gradlew assembleFdroidRelease
```
## Distribution
Deploy APKs via:
- **Surge.sh:** `surge ./releases your-app.surge.sh`
- **Zapstore:** Submit to zapstore.dev
- **Direct download:** Host on any web server

View File

@@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
@@ -36,6 +35,17 @@ def generateVersionName(String baseVersion) {
}
}
// Workaround: stability.analyzer plugin doesn't declare task dependencies properly for Gradle 9.x
afterEvaluate {
def stabilityNames = tasks.names.findAll { it.contains("StabilityCheck") }
def compileNames = tasks.names.findAll { it.matches("compile.*UnitTestKotlin") }
stabilityNames.each { scName ->
compileNames.each { ctName ->
tasks.named(scName).configure { mustRunAfter(tasks.named(ctName)) }
}
}
}
android {
namespace = 'com.vitorpamplona.amethyst'
compileSdk = libs.versions.android.compileSdk.get().toInteger()
@@ -44,9 +54,9 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 426
versionName = generateVersionName("1.02.0")
buildConfigField "String", "RELEASE_NOTES_ID", "\"08abe267baf5d7ce14db7975866f929e2794cc23484171aef0816c60a2416597\""
versionCode = 433
versionName = generateVersionName("1.06.1")
buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -152,6 +162,15 @@ android {
}
}
// TODO: remove this when lightcompressor uses one MP4 parser only
packaging {
resources {
resources.pickFirsts.add('builddef.lst')
resources.pickFirsts.add('META-INF/LICENSE.md')
resources.pickFirsts.add('META-INF/LICENSE-notice.md')
}
}
flavorDimensions = ["channel"]
productFlavors {
@@ -182,6 +201,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
resValues = true
}
packagingOptions {
@@ -270,7 +290,7 @@ dependencies {
// view videos
implementation libs.androidx.media3.exoplayer
implementation libs.androidx.media3.exoplayer.hls
implementation libs.androidx.media3.ui
implementation libs.androidx.media3.ui.compose.material3
implementation libs.androidx.media3.session
// important for proxy / tor
@@ -284,6 +304,10 @@ dependencies {
implementation libs.coil.svg
// enables network for coil
implementation libs.coil.okhttp
// loads thumbnails for media3
// TODO: Replace this to the FrameExtractor in media 3
// when FrameExtractor accepts custom data sources.
implementation(libs.coil.video)
// Permission to upload pictures:
implementation libs.accompanist.permissions
@@ -304,6 +328,8 @@ dependencies {
// Language picker and Theme chooser
implementation libs.androidx.appcompat
// Dynamically adjust between phone and tablet UI
implementation libs.androidx.window.core.android
// Local model for language identification
@@ -320,9 +346,7 @@ dependencies {
fdroidImplementation libs.unifiedpush
// Charts
implementation libs.vico.charts.core
implementation libs.vico.charts.compose
implementation libs.vico.charts.views
implementation libs.vico.charts.m3
// GeoHash
@@ -332,13 +356,22 @@ dependencies {
implementation libs.audiowaveform
// Video compression lib
implementation libs.abedElazizShe.image.compressor
implementation libs.abedElazizShe.video.compressor.fork
// Image compression lib
implementation libs.zelory.video.compressor
implementation libs.zelory.image.compressor
// EXIF metadata stripping
implementation libs.androidx.exifinterface
// Voice anonymization DSP
implementation libs.tarsosdsp
// Cbor for cashuB format
implementation libs.kotlinx.serialization.cbor
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
implementation(libs.kotlinx.serialization.json)
implementation libs.tor.android
implementation libs.jtorctl
@@ -350,6 +383,8 @@ dependencies {
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.junit.ktx
androidTestImplementation libs.androidx.espresso.core
androidTestImplementation libs.androidx.ui.test.junit4
androidTestImplementation libs.mockk.android
debugImplementation platform(libs.androidx.compose.bom)
debugImplementation libs.androidx.ui.tooling

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -24,8 +24,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.cashu.CashuParser
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import junit.framework.TestCase.assertEquals
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -38,7 +39,7 @@ class CashuBTest {
@Test()
fun parseCashuA() {
runBlocking {
val parsed = (CashuParser().parse(cashuTokenA) as GenericLoadable.Loaded<List<CashuToken>>).loaded[0]
val parsed = (CashuParser().parse(cashuTokenA) as GenericLoadable.Loaded<ImmutableList<CashuToken>>).loaded[0]
assertEquals(cashuTokenA, parsed.token)
assertEquals("https://8333.space:3338", parsed.mint)
@@ -59,7 +60,7 @@ class CashuBTest {
@Test()
fun parseCashuB() =
runBlocking {
val parsed = (CashuParser().parse(cashuTokenB1) as GenericLoadable.Loaded<List<CashuToken>>).loaded
val parsed = (CashuParser().parse(cashuTokenB1) as GenericLoadable.Loaded<ImmutableList<CashuToken>>).loaded
assertEquals(cashuTokenB1, parsed[0].token)
assertEquals("http://localhost:3338", parsed[0].mint)
@@ -84,7 +85,7 @@ class CashuBTest {
@Test()
fun parseCashuB2() =
runBlocking {
val parsed = (CashuParser().parse(cashuTokenB2) as GenericLoadable.Loaded<List<CashuToken>>).loaded
val parsed = (CashuParser().parse(cashuTokenB2) as GenericLoadable.Loaded<ImmutableList<CashuToken>>).loaded
assertEquals(cashuTokenB2, parsed[0].token)
assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint)

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -24,10 +24,10 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.okhttp.EncryptedBlobInterceptor
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM
import junit.framework.TestCase.assertEquals
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -41,7 +41,6 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.utils.sha256.sha256
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -49,9 +48,9 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.Request.method
import org.junit.runner.RunWith
import java.io.ByteArrayOutputStream
import kotlin.random.Random
@@ -81,7 +80,7 @@ class ImageUploadTesting {
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/${BuildConfig.VERSION_NAME}"))
.build()
private suspend fun getBitmap(): ByteArray {
private fun getBitmap(): ByteArray {
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
for (x in 0 until bitmap.width) {
for (y in 0 until bitmap.height) {
@@ -111,7 +110,7 @@ class ImageUploadTesting {
.upload(
inputStream = inputStream,
hash = initialHash,
length = paylod.size,
length = paylod.size.toLong(),
baseFileName = "filename.png",
contentType = "image/png",
alt = null,
@@ -122,20 +121,20 @@ class ImageUploadTesting {
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
assertEquals("image/png", result.type)
assertEquals(paylod.size.toLong(), result.size)
assertEquals(initialHash, result.sha256)
assertEquals("${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png"))
assertEquals(server.baseUrl, "image/png", result.type)
assertEquals(server.baseUrl, paylod.size.toLong(), result.size)
assertEquals(server.baseUrl, initialHash, result.sha256)
// assertEquals(server.baseUrl, "${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png"))
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(result.url!!, { client })?.bytes
ImageDownloader().waitAndGetImage(result.url!!) { client }?.bytes
?: run {
fail("${server.name}: Should not be null")
return
}
val downloadedHash = sha256(imageData).toHexKey()
assertEquals(initialHash, downloadedHash)
assertEquals(server.baseUrl, initialHash, downloadedHash)
}
private suspend fun testNip96(server: ServerName) {
@@ -143,8 +142,7 @@ class ImageUploadTesting {
ServerInfoRetriever()
.loadInfo(
server.baseUrl,
{ client },
)
) { client }
val payload = getBitmap()
val inputStream = payload.inputStream()
@@ -173,7 +171,7 @@ class ImageUploadTesting {
Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http"))
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(url, { client })?.bytes
ImageDownloader().waitAndGetImage(url) { client }?.bytes
?: run {
fail("${server.name}: Should not be null")
return
@@ -208,8 +206,13 @@ class ImageUploadTesting {
runBlocking {
DEFAULT_MEDIA_SERVERS.forEach {
// skip paid servers and primal server is buggy.
if (!it.name.contains("Paid") && !it.name.contains("Primal")) {
testBase(it)
try {
if (!it.name.contains("Paid") && !it.name.contains("Primal")) {
testBase(it)
}
} catch (e: Exception) {
e.printStackTrace()
fail("Could not upload to: ${it.baseUrl}: ${e.message}")
}
}
}
@@ -227,14 +230,6 @@ class ImageUploadTesting {
testBase(ServerName("nostrage", "https://nostrage.com", ServerType.NIP96))
}
@Test()
@Ignore("Not Working anymore")
fun testSove() =
runBlocking {
testBase(ServerName("sove", "https://sove.rent", ServerType.NIP96))
}
@Ignore("Not Working anymore")
@Test()
fun testNostrBuild() =
runBlocking {
@@ -242,17 +237,10 @@ class ImageUploadTesting {
}
@Test()
@Ignore("Not Working anymore")
@Ignore("Returns invalid hash")
fun testSovbit() =
runBlocking {
testBase(ServerName("sovbit", "https://files.sovbit.host", ServerType.NIP96))
}
@Test()
@Ignore("Not Working anymore")
fun testVoidCat() =
runBlocking {
testBase(ServerName("void.cat", "https://void.cat", ServerType.NIP96))
testBase(ServerName("sovbit", "https://cdn.sovbit.host", ServerType.Blossom))
}
@Test()
@@ -268,13 +256,6 @@ class ImageUploadTesting {
testBase(ServerName("sprovoost.nl", "https://img.sprovoost.nl/", ServerType.NIP96))
}
@Test()
@Ignore("Not Working anymore")
fun testNostrOnch() =
runBlocking {
testBase(ServerName("nostr.onch.services", "https://nostr.onch.services", ServerType.NIP96))
}
@Ignore("Changes sha256")
fun testPrimalBlossom() =
runBlocking {

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -21,17 +21,15 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OkHttpBitcoinExplorer
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OkHttpCalendarBuilder
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OtsBlockHeightCache
import junit.framework.TestCase.assertEquals
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpOtsResolverBuilder
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -45,51 +43,50 @@ class OkHttpOtsTest {
val otsCache = OtsBlockHeightCache()
val resolver =
OtsResolver(
OkHttpBitcoinExplorer(
baseAPI = OkHttpBitcoinExplorer.MEMPOOL_API_URL,
client = OkHttpClient.Builder().build(),
cache = otsCache,
),
OkHttpCalendarBuilder { OkHttpClient.Builder().build() },
)
OkHttpOtsResolverBuilder(
otsCache,
) { OkHttpClient.Builder().build() }.build()
@Test
fun verifyNostrEvent() {
val ots = JsonMapper.fromJson(otsEvent) as OtsEvent
println(resolver.info(ots.otsByteArray()))
assertEquals(1707688818L, ots.verify(resolver))
}
fun verifyNostrEvent() =
runBlocking {
val ots = OptimizedJsonMapper.fromJson(otsEvent) as OtsEvent
println(resolver.info(ots.otsByteArray()))
assertEquals(1707688818L, ots.verify(resolver))
}
@Test
fun verifyNostrEvent2() {
val ots = JsonMapper.fromJson(otsEvent2) as OtsEvent
println(resolver.info(ots.otsByteArray()))
assertEquals(1706322179L, ots.verify(resolver))
}
fun verifyNostrEvent2() =
runBlocking {
val ots = OptimizedJsonMapper.fromJson(otsEvent2) as OtsEvent
println(resolver.info(ots.otsByteArray()))
assertEquals(1706322179L, ots.verify(resolver))
}
@Test
fun verifyNostrPendingEvent() {
val ots = JsonMapper.fromJson(otsPendingEvent) as OtsEvent
println(resolver.info(ots.otsByteArray()))
assertEquals(null, ots.verify(resolver))
}
fun verifyNostrPendingEvent() =
runBlocking {
val ots = OptimizedJsonMapper.fromJson(otsPendingEvent) as OtsEvent
println(resolver.info(ots.otsByteArray()))
assertEquals(null, ots.verify(resolver))
}
@Test
fun createOTSEventAndVerify() {
val signer = NostrSignerInternal(KeyPair())
fun createOTSEventAndVerify() =
runBlocking {
val signer = NostrSignerInternal(KeyPair())
val otsFile = OtsEvent.stamp(otsEvent2Digest, resolver)
val otsFile = OtsEvent.stamp(otsEvent2Digest, resolver)
val ots =
runBlocking {
signer.sign(OtsEvent.build(otsEvent2Digest, otsFile))
}
val ots =
runBlocking {
signer.sign(OtsEvent.build(otsEvent2Digest, otsFile))
}
println(ots.toJson())
println(resolver.info(ots.otsByteArray()))
println(ots.toJson())
println(resolver.info(ots.otsByteArray()))
// Should not be valid because we need to wait for confirmations
assertEquals(null, ots.verify(resolver))
}
// Should not be valid because we need to wait for confirmations
assertEquals(null, ots.verify(resolver))
}
}

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -22,27 +22,29 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.ThreadFeedFilter
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.verify
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -50,24 +52,30 @@ import org.junit.runner.RunWith
class ThreadDualAxisChartAssemblerTest {
companion object {
val keyPair = KeyPair()
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val client =
NostrClient(
OkHttpWebSocket.Builder {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
},
scope,
)
val account =
Account(
settings = AccountSettings(keyPair = keyPair),
signer = NostrSignerInternal(keyPair),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
geolocationFlow = MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading),
nwcFilterAssembler = NWCPaymentFilterAssembler(client),
otsResolverBuilder = EmptyOtsResolverBuilder,
cache = LocalCache,
client =
NostrClient(
OkHttpWebSocket.Builder {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
},
CoroutineScope(Dispatchers.IO + SupervisorJob()),
),
client = client,
scope = scope,
)
val db =
@@ -149,7 +157,7 @@ class ThreadDualAxisChartAssemblerTest {
fun threadOrderTest() =
runBlocking {
val eventArray =
JsonMapper.mapper.readValue<ArrayList<Event>>(db) as List<Event> + Event.fromJson(header)
JacksonMapper.mapper.readValue<List<Event>>(db) + Event.fromJson(header)
var counter = 0
eventArray.forEach {
@@ -166,10 +174,10 @@ class ThreadDualAxisChartAssemblerTest {
null,
)
val filter = ThreadFeedFilter(account, naddr.toTag())
val filter = ThreadFeedFilter(account, naddr.toTag(), LocalCache)
val calculatedFeed = filter.feed()
val expecteedOrder =
val expectedOrder =
listOf(
"30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:1680612926599",
"e15b386824fbfdcbf1b50b8860f03062cef534a3ea5339cc837536fb2a58465e",
@@ -211,8 +219,8 @@ class ThreadDualAxisChartAssemblerTest {
"7a18dda355525d468b31bba4fa947cba98cc19048d4a3099d5e9ba045d878c26",
)
for (i in expecteedOrder.indices) {
assertEquals(expecteedOrder[i], calculatedFeed[i].idHex)
for (i in expectedOrder.indices) {
assertEquals(expectedOrder[i], calculatedFeed[i].idHex)
}
}
}

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import junit.framework.TestCase.assertEquals
import org.junit.Assert.assertEquals
import org.junit.Test
import java.net.URI

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -28,6 +28,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
import org.junit.Assert.assertEquals
@@ -77,8 +78,19 @@ class UrlUserTagTransformationTest {
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val original = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
@@ -185,8 +197,19 @@ class UrlUserTagTransformationTest {
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
@@ -224,8 +247,20 @@ class UrlUserTagTransformationTest {
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val transformedText =
buildAnnotatedStringWithUrlHighlighting(

View File

@@ -0,0 +1,112 @@
/*
* 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.ui.screen.loggedIn.relays.eventsync
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class EventSyncTest {
companion object {
val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl()
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val rootClient =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
.build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
}
@Test
fun testSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = {
listOf(Constants.mom, Constants.nos)
},
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
NostrClient(socketBuilder, appScope)
},
scope = appScope,
)
sync.runSync()
}
@Test
fun testFiatjafSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = { listOf(fiatjaf) },
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
val newClient = NostrClient(socketBuilder, appScope)
val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false)
val signer = NostrSignerInternal(KeyPair())
// Authenticates with relays.
val auth =
RelayAuthenticator(
newClient,
appScope,
signWithAllLoggedInUsers = { authTemplate ->
listOf(signer.sign(authTemplate))
},
)
newClient
},
scope = appScope,
)
sync.runSync()
}
}

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.service.notifications
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.utils.Log
import org.unifiedpush.android.connector.UnifiedPush
interface PushDistributorActions {
@@ -53,7 +53,7 @@ object PushDistributorHandler : PushDistributorActions {
endpointInternal = ""
}
fun appContext(): Context = Amethyst.instance.applicationContext
fun appContext(): Context = Amethyst.instance.appContext
override fun getSavedDistributor(): String = unifiedPush.getSavedDistributor(appContext()) ?: ""

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -22,15 +22,13 @@ package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import android.util.LruCache
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateDMChannel
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -44,7 +42,7 @@ class PushMessageReceiver : MessagingReceiver() {
private val TAG = "Amethyst-OSSPushReceiver"
}
private val appContext = Amethyst.instance.applicationContext
private val appContext = Amethyst.instance.appContext
private val scope = Amethyst.instance.applicationIOScope
private val eventCache = LruCache<String, String>(100)
private val pushHandler = PushDistributorHandler
@@ -95,8 +93,8 @@ class PushMessageReceiver : MessagingReceiver() {
PushNotificationUtils.checkAndInit(sanitizedEndpoint, LocalPreferences.allSavedAccounts()) {
Amethyst.instance.okHttpClients.getHttpClient(Amethyst.instance.torManager.isSocksReady())
}
notificationManager().getOrCreateZapChannel(appContext)
notificationManager().getOrCreateDMChannel(appContext)
NotificationUtils.getOrCreateZapChannel(appContext)
NotificationUtils.getOrCreateDMChannel(appContext)
}
} else {
Log.d(TAG, "Same endpoint provided:- $endpoint for Instance: $instance $sanitizedEndpoint")

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -20,7 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.Manifest
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -36,6 +37,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
@@ -44,33 +46,52 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
import com.halilibo.richtext.commonmark.MarkdownParseOptions
import com.halilibo.richtext.markdown.BasicMarkdown
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.RichText
import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.checkifItNeedsToRequestNotificationPermission
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val notificationPermissionState =
checkifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
val notificationPermissionGranted =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
if (notificationPermissionState.status.isGranted) {
if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) {
if (!notificationPermissionState.status.isGranted) {
val dontAskForNotificationPermissions by sharedPrefs.dontAskForNotificationPermissions.collectAsStateWithLifecycle()
if (!dontAskForNotificationPermissions) {
sharedPrefs.dontAskForNotificationPermissions()
// This will pause the APP, including the connection with relays.
LaunchedEffect(notificationPermissionState) {
notificationPermissionState.launchPermissionRequest()
}
}
}
notificationPermissionState.status.isGranted
} else {
true
}
if (notificationPermissionGranted) {
val pushNotificationSelector by sharedPrefs.dontShowPushNotificationSelector.collectAsStateWithLifecycle()
if (!pushNotificationSelector) {
val context = LocalContext.current
var distributorPresent by remember {
mutableStateOf(PushDistributorHandler.savedDistributorExists())
@@ -84,8 +105,8 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
onSelect = { index ->
if (list[index] == "None") {
PushDistributorHandler.forceRemoveDistributor(context)
sharedPreferencesViewModel.dontAskForNotificationPermissions()
sharedPreferencesViewModel.dontShowPushNotificationSelector()
sharedPrefs.dontAskForNotificationPermissions()
sharedPrefs.dontShowPushNotificationSelector()
} else {
val fullDistributorName = list[index]
PushDistributorHandler.saveDistributor(fullDistributorName)
@@ -107,7 +128,7 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
val astNode =
remember {
CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content)
CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content)
}
RichText(
@@ -125,7 +146,7 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
TextButton(
onClick = {
distributorPresent = true
sharedPreferencesViewModel.dontShowPushNotificationSelector()
sharedPrefs.dontShowPushNotificationSelector()
},
) {
Text(stringRes(R.string.quick_action_dont_show_again_button))
@@ -187,19 +208,19 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, Immuta
}
@Composable
fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) {
fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {
val context = LocalContext.current
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
SettingsRow(
R.string.push_server_title,
R.string.push_server_explainer,
selectedItens = readableListWithExplainer,
selectedItems = readableListWithExplainer,
selectedIndex = list.indexOf(currentDistributor),
) { index ->
if (list[index] == "None") {
sharedPreferencesViewModel.dontAskForNotificationPermissions()
sharedPreferencesViewModel.dontShowPushNotificationSelector()
sharedPrefs.dontAskForNotificationPermissions()
sharedPrefs.dontShowPushNotificationSelector()
PushDistributorHandler.forceRemoveDistributor(context)
} else {
PushDistributorHandler.saveDistributor(list[index])

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -26,9 +26,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp
@Composable
fun TranslatableRichTextViewer(
@@ -59,6 +60,7 @@ fun TranslatableRichTextViewer(
fun TranslatableRichTextViewer(
content: String,
id: String,
translationMessageModifier: Modifier = MaxWidthPaddingTop5dp,
accountViewModel: AccountViewModel,
displayText: @Composable (String) -> Unit,
) = displayText(content)

View File

@@ -8,8 +8,12 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrsigner" />
</intent>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>
<!-- Doesn't require a camera -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
@@ -72,7 +76,6 @@
android:launchMode="singleInstance"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout"
android:taskAffinity=".service.playback.pip.PipVideoActivity"
android:theme="@style/Theme.Amethyst">
<intent-filter android:label="Amethyst">
@@ -85,6 +88,7 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostr" />
<data android:scheme="amethyst" />
</intent-filter>
<intent-filter android:label="Amethyst">
@@ -99,6 +103,7 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrwalletconnect" />
<data android:scheme="nostr+walletconnect" />
<data android:scheme="amethyst+walletconnect" />
</intent-filter>
<intent-filter android:label="New Post">
@@ -127,6 +132,42 @@
<data android:host="njump.me" />
</intent-filter>
<intent-filter android:label="njump.to">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="njump.to" />
</intent-filter>
<intent-filter android:label="iris.to">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="iris.to" />
</intent-filter>
<intent-filter android:label="zap.stream">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="zap.stream" />
<data android:pathPrefix="/p/" />
<data android:pathPrefix="/" />
</intent-filter>
<intent-filter android:label="shosho.live">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="shosho.live" />
<data android:pathPrefix="/profile/" />
<data android:pathPrefix="/live/" />
</intent-filter>
<intent-filter android:label="Primal">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -199,6 +240,11 @@
<action android:name="com.shared.NOSTR" />
</intent-filter>
</receiver>
<receiver
android:name=".service.notifications.NotificationReplyReceiver"
android:exported="false" />
</application>

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -21,198 +21,37 @@
package com.vitorpamplona.amethyst
import android.app.Application
import android.content.ContentResolver
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
import com.vitorpamplona.amethyst.service.crashreports.UnexpectedCrashSaver
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.okhttp.ProxySettingsAnchor
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OtsBlockHeightCache
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.io.File
import com.vitorpamplona.quartz.utils.Log
class Amethyst : Application() {
val appAgent = "Amethyst/${BuildConfig.VERSION_NAME}"
init {
Log.d("AmethystApp", "Creating App $this")
}
// Exists to avoid exceptions stopping the coroutine
val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable)
}
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
// Key cache service to download and decrypt encrypted files before caching them.
val keyCache = EncryptionKeyCache()
// App services that should be run as soon as there are subscribers to their flows
val locationManager = LocationState(this, applicationIOScope)
val connManager = ConnectivityManager(this, applicationIOScope)
val torManager = TorManager(this, applicationIOScope)
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
// manages all the other connections separately from relays.
val okHttpClients =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
keyCache = keyCache,
scope = applicationIOScope,
)
// manages all relay connections
val okHttpClientForRelays =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
keyCache = keyCache,
scope = applicationIOScope,
)
// manages all relay connections
val okHttpClientForRelaysForDms =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
keyCache = keyCache,
scope = applicationIOScope,
)
val torProxySettingsAnchor = ProxySettingsAnchor()
// Connects the NostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder { url ->
val useTor = torProxySettingsAnchor.useProxy(url)
if (torProxySettingsAnchor.isDM(url)) {
okHttpClientForRelaysForDms.getHttpClient(useTor)
} else {
okHttpClientForRelays.getHttpClient(useTor)
}
}
// Caches all events in Memory
val cache: LocalCache = LocalCache
// Organizes cache clearing
val trimmingService = MemoryTrimmingService(cache)
// Provides a relay pool
val client: NostrClient = NostrClient(websocketBuilder, applicationIOScope)
// Watches for changes on Tor and Relay List Settings
val relayProxyClientConnector = RelayProxyClientConnector(torProxySettingsAnchor, okHttpClients, connManager, client, torManager, applicationIOScope)
// Verifies and inserts in the cache from all relays, all subscriptions
val cacheClientConnector = CacheClientConnector(client, cache)
// Show messages from the Relay and controls their dismissal
val notifyCoordinator = NotifyCoordinator(client)
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope)
val logger = if (isDebug) RelaySpeedLogger(client) else null
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationIOScope)
// saves the .content of NIP-95 blobs in disk to save memory
val nip95cache: File by lazy { Nip95CacheFactory.new(this) }
// local video cache with disk + memory
val videoCache: VideoCache by lazy { VideoCacheFactory.new(this) }
// image cache in disk for coil
val diskCache: DiskCache by lazy { ImageCacheFactory.newDisk(this) }
// image cache in memory for coil
val memoryCache: MemoryCache by lazy { ImageCacheFactory.newMemory(this) }
val crashReportCache: CrashReportCache by lazy { CrashReportCache(this) }
// Application-wide ots verification cache
val otsVerifCache by lazy { VerificationStateCache() }
// Application-wide block height request cache
val otsBlockHeightCache by lazy { OtsBlockHeightCache() }
companion object {
lateinit var instance: AppModules
private set
}
override fun onCreate() {
super.onCreate()
Log.d("AmethystApp", "onCreate $this")
Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope))
instance = this
instance = AppModules(this)
if (isDebug) {
Logging.setup()
}
// initializes diskcache on an IO thread.
applicationIOScope.launch {
diskCache
videoCache
}
// registers to receive events
pokeyReceiver.register(this)
instance.initiate(this)
}
override fun onTerminate() {
super.onTerminate()
Log.d("AmethystApp", "onTerminate $this")
pokeyReceiver.unregister(this)
applicationIOScope.cancel("Application onTerminate $this")
instance.terminate(this)
}
fun contentResolverFn(): ContentResolver = contentResolver
fun setImageLoader(shouldUseTor: (String) -> Boolean?) {
ImageLoaderSetup.setup(this, diskCache, memoryCache) { url ->
shouldUseTor(url)?.let { okHttpClients.getHttpClient(it) } ?: okHttpClients.getHttpClient(false)
}
}
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(instance, npub)
/**
* Release memory when the UI becomes hidden or when system resources become low.
*
@@ -221,13 +60,6 @@ class Amethyst : Application() {
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
Log.d("AmethystApp", "onTrimMemory $level")
applicationIOScope.launch(Dispatchers.Default) {
trimmingService.run(null, LocalPreferences.allSavedAccounts())
}
}
companion object {
lateinit var instance: Amethyst
private set
instance.trim()
}
}

View File

@@ -0,0 +1,421 @@
/*
* 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
import android.content.ContentResolver
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
import com.vitorpamplona.amethyst.service.crashreports.UnexpectedCrashSaver
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import java.io.File
class AppModules(
val appContext: Context,
) {
val appAgent = "Amethyst/${BuildConfig.VERSION_NAME}"
// Exists to avoid exceptions stopping the coroutine
val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable)
}
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
// Blocking load of UI Preferences to avoid theme/language blinking
val uiPrefs by lazy {
UiSharedPreferences(appContext, applicationIOScope)
}
// Blocking load of Tor Settings to avoid connection leaks
val torPrefs by lazy {
TorSharedPreferences(appContext, applicationIOScope)
}
// Namecoin ElectrumX server preferences (global, like Tor settings)
val namecoinPrefs by lazy {
NamecoinSharedPreferences(appContext, applicationIOScope)
}
// OTS blockchain explorer preferences (global, like Tor settings)
val otsPrefs by lazy {
OtsSharedPreferences(appContext, applicationIOScope)
}
// App services that should be run as soon as there are subscribers to their flows
val locationManager = LocationState(appContext, applicationIOScope)
val connManager = ConnectivityManager(appContext, applicationIOScope)
val uiState by lazy {
UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope)
}
val torManager = TorManager(torPrefs, appContext, applicationIOScope)
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
// Key cache service to download and decrypt encrypted files before caching them.
val keyCache = EncryptionKeyCache()
// manages all the other connections separately from relays.
val okHttpClients =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
keyCache = keyCache,
scope = applicationIOScope,
)
// Offers easy methods to know when connections are happening through Tor or not
val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value)
// Custom fetcher that considers tor settings and avoids forwarding.
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
val namecoinResolver =
NamecoinNameResolver(
electrumxClient =
ElectrumXClient(
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
),
serverListProvider = {
// User-configured custom servers take priority
namecoinPrefs.customServersOrNull
?: if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
TOR_ELECTRUMX_SERVERS
} else {
DEFAULT_ELECTRUMX_SERVERS
}
},
)
val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver)
// Application-wide block height request cache
val otsBlockHeightCache by lazy { OtsBlockHeightCache() }
val otsResolverBuilder: TorAwareOkHttpOtsResolverBuilder =
TorAwareOkHttpOtsResolverBuilder(
roleBasedHttpClientBuilder::okHttpClientForMoney,
roleBasedHttpClientBuilder::shouldUseTorForMoneyOperations,
otsBlockHeightCache,
customExplorerUrl = { otsPrefs.current.normalizedUrl() },
)
// Application-wide ots verification cache
val otsVerifCache by lazy { VerificationStateCache(otsResolverBuilder) }
val torEvaluatorFlow =
TorRelayState(
okHttpClients,
torPrefs.value,
applicationIOScope,
)
// manages all relay connections
val okHttpClientForRelays =
DualHttpClientManagerForRelays(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
scope = applicationIOScope,
)
// Connects the NostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder { url ->
val useTor = torEvaluatorFlow.flow.value.useTor(url)
okHttpClientForRelays.getHttpClient(useTor)
}
// Caches all events in Memory
val cache: LocalCache = LocalCache
// Provides a relay pool
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope)
// Watches for changes on Tor and Relay List Settings
val relayProxyClientConnector =
RelayProxyClientConnector(
torEvaluatorFlow.flow,
okHttpClientForRelays,
connManager,
torManager,
client,
applicationIOScope,
)
// Verifies and inserts in the cache from all relays, all subscriptions
val cacheClientConnector = CacheClientConnector(client, cache)
// Show messages from the Relay and controls their dismissal
val notifyCoordinator = NotifyCoordinator(client)
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope)
// Tries to verify new OTS events when they arrive.
val otsEventVerifier = IncomingOtsEventVerifier(otsVerifCache, cache, applicationIOScope)
// Tracks if it is possible to connect to relays.
val failureTracker = RelayOfflineTracker(client)
// Captures statistics about relays
val relayStats = RelayStats(client)
// Logs debug messages when needed
val detailedLogger = if (isDebug) RelayLogger(client, debugSending = false, debugReceiving = false) else null
val relayReqStats = if (isDebug) RelayReqStats(client) else null
val logger = if (isDebug) RelaySpeedLogger(client) else null
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator =
RelaySubscriptionsCoordinator(
LocalCache,
client,
authCoordinator.receiver,
failureTracker,
applicationIOScope,
)
// keeps all accounts live
val accountsCache =
AccountCacheState(
geolocationFlow = locationManager.geohashStateFlow,
nwcFilterAssembler = sources.nwc,
contentResolverFn = ::contentResolverFn,
otsResolverBuilder = otsResolverBuilder,
cache = cache,
client = client,
)
val sessionManager =
AccountSessionManager(
accountsCache = accountsCache,
nip05Client = nip05Client,
client = client,
localPreferences = LocalPreferences,
scope = applicationIOScope,
)
fun subscribedFlow(
address: Address,
account: Account,
): Flow<NoteState> {
val note = cache.getOrCreateAddressableNote(address)
val userSub = UserFinderQueryState(note.author ?: cache.getOrCreateUser(address.pubKeyHex), account)
val noteSub = EventFinderQueryState(note, account)
return note
.flow()
.metadata.stateFlow
.onStart {
sources.userFinder.subscribe(userSub)
sources.eventFinder.subscribe(noteSub)
}.onCompletion {
sources.eventFinder.unsubscribe(noteSub)
sources.userFinder.unsubscribe(userSub)
}
}
val blossomResolver =
BlossomServerResolver(
loggedInUsers = { listOfNotNull(sessionManager.loggedInAccount()?.pubKey) },
blossomServers = { addressesToSubscribe ->
val account = sessionManager.loggedInAccount() ?: return@BlossomServerResolver listOf()
addressesToSubscribe.map { address ->
subscribedFlow(address, account).transform {
val event = it.note.event as? BlossomServersEvent
if (event != null) {
emit(event)
}
}
}
},
httpClientBuilder = roleBasedHttpClientBuilder,
)
// Organizes cache clearing
val trimmingService = MemoryTrimmingService(cache)
// as new accounts are loaded, updates the state of the TorRelaySettings, which produces new TorRelayEvaluator
// and reconnects relays if the configuration has been changed.
val accountsTorStateConnector = AccountsTorStateConnector(accountsCache, torEvaluatorFlow, applicationIOScope)
// saves the .content of NIP-95 blobs in disk to save memory
val nip95cache: File by lazy { Nip95CacheFactory.new(appContext) }
// local video cache with disk + memory
val videoCache: VideoCache by lazy { VideoCacheFactory.new(appContext) }
// image cache in disk for coil
val diskCache: DiskCache by lazy { ImageCacheFactory.newDisk(appContext) }
// image cache in memory for coil
val memoryCache: MemoryCache by lazy { ImageCacheFactory.newMemory(appContext) }
// crash report storage
val crashReportCache: CrashReportCache by lazy { CrashReportCache(appContext) }
// cache for NIP-11 documents
val nip11Cache: Nip11CachedRetriever by lazy {
Nip11CachedRetriever(torEvaluatorFlow::okHttpClientForRelay)
}
fun contentResolverFn(): ContentResolver = appContext.contentResolver
fun setImageLoader() {
ImageLoaderSetup.setup(
app = appContext,
diskCache = { diskCache },
memoryCache = { memoryCache },
blossomServerResolver = blossomResolver,
) { url ->
okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(url))
}
}
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(appContext, npub)
fun initiate(appContext: Context) {
Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope))
applicationIOScope.launch {
// loads main account quickly.
LocalPreferences.loadAccountConfigFromEncryptedStorage()
sessionManager.loginWithDefaultAccountIfLoggedOff()
}
// forces initialization of uiPrefs in the main thread to avoid blinking themes
uiPrefs
// initializes diskcache on an IO thread.
applicationIOScope.launch {
// preloads tor preferences
torPrefs
}
// initializes diskcache on an IO thread.
applicationIOScope.launch {
// Sets Coil - Tor - OkHttp link
setImageLoader()
}
// registers to receive events
pokeyReceiver.register(appContext)
// initializes diskcache on an IO thread.
applicationIOScope.launch {
// Sets Coil - Tor - OkHttp link
delay(3000)
videoCache
}
applicationIOScope.launch {
// Eagerly initialize OtsSharedPreferences off the main thread
otsPrefs
}
}
fun terminate(appContext: Context) {
pokeyReceiver.unregister(appContext)
applicationIOScope.cancel("Application onTerminate $appContext")
accountsCache.clear()
}
fun trim() {
applicationIOScope.launch {
val loggedIn = accountsCache.accounts.value.values
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts())
}
}
}

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -24,63 +24,61 @@ import android.app.ActivityManager
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Debug
import android.util.Log
import androidx.core.content.getSystemService
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlin.time.DurationUnit
import kotlin.time.measureTimedValue
@Suppress("SENSELESS_COMPARISON")
val isDebug = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark"
fun debugState(context: Context) {
// Amethyst.instance.client
// .allSubscriptions()
// .forEach { Log.d("STATE DUMP", "${it.key} ${it.value.filters.joinToString { it.filter.toJson() }}") }
private const val STATE_DUMP_TAG = "STATE DUMP"
fun debugState(context: Context) {
val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024)
val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024)
val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024)
val jvmHeapAllocatedMb = totalMemoryMb - freeMemoryMb
Log.d("STATE DUMP", "Total Heap Allocated: $jvmHeapAllocatedMb/$maxMemoryMb MB")
Log.d(STATE_DUMP_TAG, "Total Heap Allocated: $jvmHeapAllocatedMb/$maxMemoryMb MB")
val nativeHeap = Debug.getNativeHeapAllocatedSize() / (1024 * 1024)
val maxNative = Debug.getNativeHeapSize() / (1024 * 1024)
Log.d("STATE DUMP", "Total Native Heap Allocated: $nativeHeap/$maxNative MB")
Log.d(STATE_DUMP_TAG, "Total Native Heap Allocated: $nativeHeap/$maxNative MB")
val activityManager: ActivityManager? = context.getSystemService()
if (activityManager != null) {
val isLargeHeap = (context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP) != 0
val memClass = if (isLargeHeap) activityManager.largeMemoryClass else activityManager.memoryClass
Log.d("STATE DUMP", "Memory Class $memClass MB (largeHeap $isLargeHeap)")
Log.d(STATE_DUMP_TAG, "Memory Class $memClass MB (largeHeap $isLargeHeap)")
}
Log.d(
"STATE DUMP",
"Connected Relays: " +
Amethyst.instance.client
.relayStatusFlow()
.value.connected.size + "/" +
Amethyst.instance.client
.relayStatusFlow()
.value.available.size,
STATE_DUMP_TAG,
"Indexed Relays: " +
Amethyst.instance.cache.relayHints.relayDB
.size() + "/" + normalizedUrls.size(),
)
Log.d(
"STATE DUMP",
STATE_DUMP_TAG,
"Image Disk Cache ${(Amethyst.instance.diskCache.size) / (1024 * 1024)}/${(Amethyst.instance.diskCache.maxSize) / (1024 * 1024)} MB",
)
Log.d(
"STATE DUMP",
STATE_DUMP_TAG,
"Image Memory Cache ${(Amethyst.instance.memoryCache.size) / (1024 * 1024)}/${(Amethyst.instance.memoryCache.maxSize) / (1024 * 1024)} MB",
)
Log.d(
"STATE DUMP",
STATE_DUMP_TAG,
"Notes: " +
LocalCache.notes.filter { _, it -> it.flowSet != null }.size +
" / " +
@@ -89,7 +87,7 @@ fun debugState(context: Context) {
LocalCache.notes.size(),
)
Log.d(
"STATE DUMP",
STATE_DUMP_TAG,
"Addressables: " +
LocalCache.addressables.filter { _, it -> it.flowSet != null }.size +
" / " +
@@ -98,37 +96,69 @@ fun debugState(context: Context) {
LocalCache.addressables.size(),
)
Log.d(
"STATE DUMP",
STATE_DUMP_TAG,
"Users: " +
LocalCache.users.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.users.filter { _, it -> it.latestMetadata != null }.size +
LocalCache.users.filter { _, it -> it.metadataOrNull() != null }.size +
" / " +
LocalCache.users.size(),
)
Log.d(
"STATE DUMP",
STATE_DUMP_TAG,
"Public Chat Channels: " +
LocalCache.publicChatChannels.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.publicChatChannels.size() +
" / " +
LocalCache.publicChatChannels.values().sumOf { it.notes.size() },
)
Log.d(
STATE_DUMP_TAG,
"Live Chat Channels: " +
LocalCache.liveChatChannels.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.liveChatChannels.size() +
" / " +
LocalCache.liveChatChannels.values().sumOf { it.notes.size() },
)
Log.d(
STATE_DUMP_TAG,
"Ephemeral Chat Channels: " +
LocalCache.ephemeralChannels.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.ephemeralChannels.size() +
" / " +
LocalCache.ephemeralChannels.values().sumOf { it.notes.size() },
)
LocalCache.chatroomList.forEach { key, room ->
Log.d(
STATE_DUMP_TAG,
"Private Chats $key: " +
room.rooms.size() +
" / " +
room.rooms.sumOf { key, value -> value.messages.size },
)
}
Log.d(
STATE_DUMP_TAG,
"Deletion Events: " +
LocalCache.deletionIndex.size(),
)
Log.d(
"STATE DUMP",
"Observable Events: " +
LocalCache.observablesByKindAndETag.size +
" / " +
LocalCache.observablesByKindAndAuthor.size,
STATE_DUMP_TAG,
"Observables: " +
LocalCache.observables.size,
)
Log.d(
"STATE DUMP",
STATE_DUMP_TAG,
"Spam: " +
LocalCache.antiSpam.spamMessages.size() + " / " + LocalCache.antiSpam.recentEventIds.size() + " / " + LocalCache.antiSpam.recentAddressables.size(),
)
Log.d(
"STATE DUMP",
STATE_DUMP_TAG,
"Memory used by Events: " +
LocalCache.notes.sumOfLong { _, note -> note.event?.countMemory() ?: 0L } / (1024 * 1024) +
LocalCache.notes.sumOf { _, note -> note.event?.countMemory() ?: 0 } / (1024 * 1024) +
" MB",
)
@@ -137,16 +167,16 @@ fun debugState(context: Context) {
val bytesNotes =
LocalCache.notes
.sumByGroup(groupMap = { _, it -> it.event?.kind }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
.sumByGroup(groupMap = { _, it -> it.event?.kind }, sumOf = { _, it -> it.event?.countMemory()?.toLong() ?: 0L })
val bytesAddressables =
LocalCache.addressables
.sumByGroup(groupMap = { _, it -> it.event?.kind }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
.sumByGroup(groupMap = { _, it -> it.event?.kind }, sumOf = { _, it -> it.event?.countMemory()?.toLong() ?: 0L })
qttNotes.toList().sortedByDescending { bytesNotes[it.first] }.forEach { (kind, qtt) ->
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes[kind]?.div((1024 * 1024))}MB ")
Log.d(STATE_DUMP_TAG, "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes[kind]?.div((1024 * 1024))}MB ")
}
qttAddressables.toList().sortedByDescending { bytesNotes[it.first] }.forEach { (kind, qtt) ->
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables[kind]?.div((1024 * 1024))}MB ")
Log.d(STATE_DUMP_TAG, "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables[kind]?.div((1024 * 1024))}MB ")
}
}
@@ -197,3 +227,12 @@ inline fun debug(
Log.d(tag, debugMessage())
}
}
fun Event.countMemory(): Int =
7 * pointerSizeInBytes + // 7 fields, 4 bytes each reference (32bit)
12 + // createdAt + kind
id.bytesUsedInMemory() +
pubKey.bytesUsedInMemory() +
tags.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } +
content.bytesUsedInMemory() +
sig.bytesUsedInMemory()

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -23,26 +23,24 @@ package com.vitorpamplona.amethyst
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.ALL_FOLLOWS
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
@@ -55,16 +53,20 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import java.io.File
// Release mode (!BuildConfig.DEBUG) always uses encrypted preferences
@@ -74,11 +76,12 @@ private const val DEBUG_PLAINTEXT_PREFERENCES = false
private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
@Immutable
@Serializable
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
val loggedInWithExternalSigner: Boolean,
val isTransient: Boolean,
val hasPrivKey: Boolean = false,
val loggedInWithExternalSigner: Boolean = false,
val isTransient: Boolean = false,
)
private object PrefKeys {
@@ -88,6 +91,7 @@ private object PrefKeys {
const val NOSTR_PUBKEY = "nostr_pubkey"
const val LOCAL_RELAY_SERVERS = "localRelayServers"
const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
@@ -98,6 +102,8 @@ private object PrefKeys {
const val LATEST_DM_RELAY_LIST = "latestDMRelayList"
const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList"
const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList"
const val LATEST_INDEX_RELAY_LIST = "latestIndexRelayList"
const val LATEST_RELAY_FEEDS_LIST = "latestRelayFeedsList"
const val LATEST_BLOCKED_RELAY_LIST = "latestBlockedRelayList"
const val LATEST_TRUSTED_RELAY_LIST = "latestTrustedRelayList"
const val LATEST_MUTE_LIST = "latestMuteList"
@@ -108,6 +114,7 @@ private object PrefKeys {
const val LATEST_HASHTAG_LIST = "latestHashtagList"
const val LATEST_GEOHASH_LIST = "latestGeohashList"
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
@@ -122,6 +129,7 @@ private object PrefKeys {
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
const val SHARED_SETTINGS = "shared_settings"
const val LATEST_PAYMENT_TARGETS = "latestPaymentTargets"
}
object LocalPreferences {
@@ -163,7 +171,7 @@ object LocalPreferences {
with(encryptedPreferences()) {
val newSystemOfAccounts =
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
JsonMapper.mapper.readValue<List<AccountInfo>>(it)
JsonMapper.fromJson<List<AccountInfo>>(it)
}
if (!newSystemOfAccounts.isNullOrEmpty()) {
@@ -183,7 +191,9 @@ object LocalPreferences {
savedAccounts.emit(migrated)
edit { putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.mapper.writeValueAsString(savedAccounts.value)) }
edit {
putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.toJson(migrated))
}
}
}
}
@@ -203,14 +213,14 @@ object LocalPreferences {
.edit {
putString(
PrefKeys.ALL_ACCOUNT_INFO,
JsonMapper.mapper.writeValueAsString(accounts.filter { !it.isTransient }),
JsonMapper.toJson(accounts.filter { !it.isTransient }),
)
}
}
}
private val prefsDirPath: String
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
get() = "${Amethyst.instance.appContext.filesDir.parent}/shared_prefs/"
private suspend fun addAccount(accInfo: AccountInfo) {
val accounts = savedAccounts().filter { it.npub != accInfo.npub }.plus(accInfo)
@@ -242,8 +252,8 @@ object LocalPreferences {
withContext(Dispatchers.IO) {
val prefsDir = File(prefsDirPath)
prefsDir.list()?.forEach {
if (it.contains(npub)) {
File(prefsDir, it).delete()
if (it.contains(npub) && !File(prefsDir, it).delete()) {
Log.w("LocalPreferences", "Failed to delete preference file: $it")
}
}
}
@@ -255,7 +265,7 @@ object LocalPreferences {
return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
val preferenceFile =
if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
Amethyst.instance.appContext.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
} else {
Amethyst.instance.encryptedStorage(npub)
}
@@ -310,84 +320,28 @@ object LocalPreferences {
putString(
PrefKeys.DEFAULT_FILE_SERVER,
JsonMapper.mapper.writeValueAsString(settings.defaultFileServer),
JsonMapper.toJson(settings.defaultFileServer),
)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value)
putString(
PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST,
settings.defaultNotificationFollowList.value,
)
putString(
PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST,
settings.defaultDiscoveryFollowList.value,
)
putString(
PrefKeys.ZAP_PAYMENT_REQUEST_SERVER,
JsonMapper.mapper.writeValueAsString(settings.zapPaymentRequest.value?.denormalize()),
)
if (settings.backupContactList != null) {
putString(
PrefKeys.LATEST_CONTACT_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupContactList),
)
} else {
remove(PrefKeys.LATEST_CONTACT_LIST)
}
if (settings.backupUserMetadata != null) {
putString(
PrefKeys.LATEST_USER_METADATA,
JsonMapper.mapper.writeValueAsString(settings.backupUserMetadata),
)
} else {
remove(PrefKeys.LATEST_USER_METADATA)
}
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
if (settings.backupDMRelayList != null) {
putString(
PrefKeys.LATEST_DM_RELAY_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupDMRelayList),
)
} else {
remove(PrefKeys.LATEST_DM_RELAY_LIST)
}
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value))
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value))
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNotificationFollowList.value))
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, JsonMapper.toJson(settings.defaultDiscoveryFollowList.value))
if (settings.backupNIP65RelayList != null) {
putString(
PrefKeys.LATEST_NIP65_RELAY_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupNIP65RelayList),
)
} else {
remove(PrefKeys.LATEST_NIP65_RELAY_LIST)
}
putOrRemove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, settings.zapPaymentRequest.value?.denormalize())
if (settings.backupSearchRelayList != null) {
putString(
PrefKeys.LATEST_SEARCH_RELAY_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupSearchRelayList),
)
} else {
remove(PrefKeys.LATEST_SEARCH_RELAY_LIST)
}
putOrRemove(PrefKeys.LATEST_CONTACT_LIST, settings.backupContactList)
if (settings.backupBlockedRelayList != null) {
putString(
PrefKeys.LATEST_BLOCKED_RELAY_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupBlockedRelayList),
)
} else {
remove(PrefKeys.LATEST_BLOCKED_RELAY_LIST)
}
if (settings.backupTrustedRelayList != null) {
putString(
PrefKeys.LATEST_TRUSTED_RELAY_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupTrustedRelayList),
)
} else {
remove(PrefKeys.LATEST_TRUSTED_RELAY_LIST)
}
putOrRemove(PrefKeys.LATEST_USER_METADATA, settings.backupUserMetadata)
putOrRemove(PrefKeys.LATEST_DM_RELAY_LIST, settings.backupDMRelayList)
putOrRemove(PrefKeys.LATEST_NIP65_RELAY_LIST, settings.backupNIP65RelayList)
putOrRemove(PrefKeys.LATEST_SEARCH_RELAY_LIST, settings.backupSearchRelayList)
putOrRemove(PrefKeys.LATEST_INDEX_RELAY_LIST, settings.backupIndexRelayList)
putOrRemove(PrefKeys.LATEST_RELAY_FEEDS_LIST, settings.backupRelayFeedsList)
putOrRemove(PrefKeys.LATEST_BLOCKED_RELAY_LIST, settings.backupBlockedRelayList)
putOrRemove(PrefKeys.LATEST_TRUSTED_RELAY_LIST, settings.backupTrustedRelayList)
if (settings.localRelayServers.value.isNotEmpty()) {
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers.value)
@@ -395,77 +349,17 @@ object LocalPreferences {
remove(PrefKeys.LOCAL_RELAY_SERVERS)
}
if (settings.backupMuteList != null) {
putString(
PrefKeys.LATEST_MUTE_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupMuteList),
)
} else {
remove(PrefKeys.LATEST_MUTE_LIST)
}
putOrRemove(PrefKeys.LATEST_MUTE_LIST, settings.backupMuteList)
putOrRemove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST, settings.backupPrivateHomeRelayList)
putOrRemove(PrefKeys.LATEST_APP_SPECIFIC_DATA, settings.backupAppSpecificData)
if (settings.backupPrivateHomeRelayList != null) {
putString(
PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupPrivateHomeRelayList),
)
} else {
remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
}
if (settings.backupAppSpecificData != null) {
putString(
PrefKeys.LATEST_APP_SPECIFIC_DATA,
JsonMapper.mapper.writeValueAsString(settings.backupAppSpecificData),
)
} else {
remove(PrefKeys.LATEST_APP_SPECIFIC_DATA)
}
if (settings.backupChannelList != null) {
putString(
PrefKeys.LATEST_CHANNEL_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupChannelList),
)
} else {
remove(PrefKeys.LATEST_CHANNEL_LIST)
}
if (settings.backupCommunityList != null) {
putString(
PrefKeys.LATEST_COMMUNITY_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupCommunityList),
)
} else {
remove(PrefKeys.LATEST_COMMUNITY_LIST)
}
if (settings.backupHashtagList != null) {
putString(
PrefKeys.LATEST_HASHTAG_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupHashtagList),
)
} else {
remove(PrefKeys.LATEST_HASHTAG_LIST)
}
if (settings.backupGeohashList != null) {
putString(
PrefKeys.LATEST_GEOHASH_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupGeohashList),
)
} else {
remove(PrefKeys.LATEST_GEOHASH_LIST)
}
if (settings.backupEphemeralChatList != null) {
putString(
PrefKeys.LATEST_EPHEMERAL_LIST,
JsonMapper.mapper.writeValueAsString(settings.backupEphemeralChatList),
)
} else {
remove(PrefKeys.LATEST_EPHEMERAL_LIST)
}
putOrRemove(PrefKeys.LATEST_CHANNEL_LIST, settings.backupChannelList)
putOrRemove(PrefKeys.LATEST_COMMUNITY_LIST, settings.backupCommunityList)
putOrRemove(PrefKeys.LATEST_HASHTAG_LIST, settings.backupHashtagList)
putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList)
putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList)
putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList)
putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets)
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
@@ -475,8 +369,6 @@ object LocalPreferences {
remove(PrefKeys.USE_PROXY)
remove(PrefKeys.PROXY_PORT)
putString(PrefKeys.TOR_SETTINGS, JsonMapper.mapper.writeValueAsString(settings.torSettings.toSettings()))
val regularMap =
settings.lastReadPerRoute.value.mapValues {
it.value.value
@@ -484,13 +376,13 @@ object LocalPreferences {
putString(
PrefKeys.LAST_READ_PER_ROUTE,
JsonMapper.mapper.writeValueAsString(regularMap),
JsonMapper.toJson(regularMap),
)
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
putString(
PrefKeys.PENDING_ATTESTATIONS,
JsonMapper.mapper.writeValueAsString(settings.pendingAttestations.value),
JsonMapper.toJson(settings.pendingAttestations.value),
)
}
}
@@ -498,23 +390,25 @@ object LocalPreferences {
Log.d("LocalPreferences", "Saved to encrypted storage")
}
suspend fun loadCurrentAccountFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
suspend fun loadAccountConfigFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadAccountConfigFromEncryptedStorage(it) }
suspend fun saveSharedSettings(
sharedSettings: Settings,
fun saveSharedSettings(
sharedSettings: UiSettings,
prefs: SharedPreferences = encryptedPreferences(),
) {
Log.d("LocalPreferences", "Saving to shared settings")
prefs.edit {
putString(PrefKeys.SHARED_SETTINGS, JsonMapper.mapper.writeValueAsString(sharedSettings))
putString(PrefKeys.SHARED_SETTINGS, JsonMapper.toJson(sharedSettings))
}
}
suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): UiSettings? {
Log.d("LocalPreferences", "Load shared settings")
with(prefs) {
return try {
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { JsonMapper.mapper.readValue<Settings>(it) }
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let {
JsonMapper.fromJson<UiSettings>(it)
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
@@ -529,7 +423,7 @@ object LocalPreferences {
val mutex = Mutex()
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): AccountSettings? {
suspend fun loadAccountConfigFromEncryptedStorage(npub: String): AccountSettings? {
// if already loaded, return right away
if (cachedAccounts.containsKey(npub)) {
return cachedAccounts[npub]
@@ -563,17 +457,14 @@ object LocalPreferences {
getString(PrefKeys.SIGNER_PACKAGE_NAME, null)
?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null
val defaultHomeFollowList =
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: ALL_FOLLOWS
val defaultStoriesFollowList =
getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultNotificationFollowList =
getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultDiscoveryFollowList =
getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultHomeFollowList = parseOrNull<TopFilter>(PrefKeys.DEFAULT_HOME_FOLLOW_LIST) ?: TopFilter.AllFollows
val defaultStoriesFollowList = parseOrNull<TopFilter>(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST) ?: TopFilter.Global
val defaultNotificationFollowList = parseOrNull<TopFilter>(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST) ?: TopFilter.Global
val defaultDiscoveryFollowList = parseOrNull<TopFilter>(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST) ?: TopFilter.Global
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0]
val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
@@ -583,6 +474,8 @@ object LocalPreferences {
val latestDmRelayList = parseEventOrNull<ChatMessageRelayListEvent>(PrefKeys.LATEST_DM_RELAY_LIST)
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST)
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
val latestIndexRelayList = parseEventOrNull<IndexerRelayListEvent>(PrefKeys.LATEST_INDEX_RELAY_LIST)
val latestRelayFeedsList = parseEventOrNull<RelayFeedsListEvent>(PrefKeys.LATEST_RELAY_FEEDS_LIST)
val latestBlockedRelayList = parseEventOrNull<BlockedRelayListEvent>(PrefKeys.LATEST_BLOCKED_RELAY_LIST)
val latestTrustedRelayList = parseEventOrNull<TrustedRelayListEvent>(PrefKeys.LATEST_TRUSTED_RELAY_LIST)
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
@@ -593,13 +486,13 @@ object LocalPreferences {
val latestHashtagList = parseEventOrNull<HashtagListEvent>(PrefKeys.LATEST_HASHTAG_LIST)
val latestGeohashList = parseEventOrNull<GeohashListEvent>(PrefKeys.LATEST_GEOHASH_LIST)
val latestEphemeralList = parseEventOrNull<EphemeralChatListEvent>(PrefKeys.LATEST_EPHEMERAL_LIST)
val latestTrustProviderList = parseEventOrNull<TrustProviderListEvent>(PrefKeys.LATEST_TRUST_PROVIDER_LIST)
val latestPaymentTargets = parseEventOrNull<PaymentTargetsEvent>(PrefKeys.LATEST_PAYMENT_TARGETS)
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val torSettings = parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
val lastReadPerRoute =
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
MutableStateFlow(it.value)
@@ -614,6 +507,7 @@ object LocalPreferences {
externalSignerPackageName = externalSignerPackageName,
localRelayServers = MutableStateFlow(localRelayServers),
defaultFileServer = defaultFileServer,
stripLocationOnUpload = stripLocationOnUpload,
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
@@ -627,6 +521,8 @@ object LocalPreferences {
backupNIP65RelayList = latestNip65RelayList,
backupDMRelayList = latestDmRelayList,
backupSearchRelayList = latestSearchRelayList,
backupIndexRelayList = latestIndexRelayList,
backupRelayFeedsList = latestRelayFeedsList,
backupBlockedRelayList = latestBlockedRelayList,
backupTrustedRelayList = latestTrustedRelayList,
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
@@ -637,10 +533,11 @@ object LocalPreferences {
backupHashtagList = latestHashtagList,
backupGeohashList = latestGeohashList,
backupEphemeralChatList = latestEphemeralList,
torSettings = TorSettingsFlow.build(torSettings),
backupTrustProviderList = latestTrustProviderList,
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
pendingAttestations = MutableStateFlow(pendingAttestations),
backupNipA3PaymentTargets = latestPaymentTargets,
)
}
}
@@ -648,7 +545,7 @@ object LocalPreferences {
return result
}
private inline fun <reified T> SharedPreferences.parseOrNull(key: String): T? {
private inline fun <reified T : Any> SharedPreferences.parseOrNull(key: String): T? {
val value = getString(key, null)
if (value.isNullOrEmpty() || value == "null") {
return null
@@ -657,7 +554,7 @@ object LocalPreferences {
if (T::class.java.isInstance(Event::class.java)) {
Event.fromJson(value) as T?
} else {
JsonMapper.mapper.readValue<T?>(value)
JsonMapper.fromJson<T>(value)
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
@@ -679,4 +576,37 @@ object LocalPreferences {
null
}
}
fun SharedPreferences.Editor.putOrRemove(
key: String,
event: Any?,
) {
if (event != null) {
putString(key, JsonMapper.toJson(event))
} else {
remove(key)
}
}
fun SharedPreferences.Editor.putOrRemove(
key: String,
event: Event?,
) {
if (event != null) {
putString(key, OptimizedJsonMapper.toJson(event))
} else {
remove(key)
}
}
fun SharedPreferences.Editor.putOrRemove(
key: String,
nwc: Nip47WalletConnect.Nip47URI?,
) {
if (nwc != null) {
putString(key, JsonMapper.toJson(nwc))
} else {
remove(key)
}
}
}

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -20,19 +20,19 @@
*/
package com.vitorpamplona.amethyst.model
import android.content.ContentResolver
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
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.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
@@ -46,10 +46,11 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
@@ -61,7 +62,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import java.util.Locale
import kotlinx.serialization.Serializable
val DefaultChannels =
listOf(
@@ -80,11 +81,13 @@ val DefaultNIP65List =
AdvertisedRelayInfo(Constants.bitcoiner, AdvertisedRelayType.BOTH),
)
val DefaultGlobalRelays = listOf(Constants.wine, Constants.news)
val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos)
val DefaultSearchRelayList = setOf(Constants.band, Constants.wine, Constants.where, Constants.nostoday)
val DefaultSearchRelayList = setOf(Constants.wine, Constants.where, Constants.nostoday, Constants.antiprimal, Constants.ditto)
val DefaultIndexerRelayList = setOf(Constants.purplepages, Constants.coracle, Constants.userkinds)
val DefaultIndexerRelayList = setOf(Constants.purplepages, Constants.coracle, Constants.userkinds, Constants.yabu, Constants.nostr1)
val DefaultSignerPermissions =
listOf(
@@ -97,14 +100,58 @@ val DefaultSignerPermissions =
Permission(CommandType.DECRYPT_ZAP_EVENT),
)
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val GLOBAL_FOLLOWS = " Global "
@Serializable
sealed class TopFilter(
val code: String,
) {
@Serializable
object Global : TopFilter(" Global ")
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val ALL_FOLLOWS = " All Follows "
@Serializable
object AllFollows : TopFilter(" All Follows ")
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
val AROUND_ME = " Around Me "
@Serializable
object AllUserFollows : TopFilter(" All User Follows ")
@Serializable
object DefaultFollows : TopFilter(" Main User Follows ")
@Serializable
object AroundMe : TopFilter(" Around Me ")
@Serializable
object Chess : TopFilter(" Chess ")
@Serializable
class PeopleList(
val address: Address,
) : TopFilter(address.toValue())
@Serializable
class MuteList(
val address: Address,
) : TopFilter(address.toValue())
@Serializable
class Community(
val address: Address,
) : TopFilter("Community/${address.toValue()}")
@Serializable
class Hashtag(
val tag: String,
) : TopFilter("Hashtag/$tag")
@Serializable
class Geohash(
val tag: String,
) : TopFilter("Geohash/$tag")
@Serializable
class Relay(
val url: String,
) : TopFilter("Relay/$url")
}
@Stable
class AccountSettings(
@@ -113,11 +160,12 @@ class AccountSettings(
var externalSignerPackageName: String? = null,
var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(ALL_FOLLOWS),
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
val defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
var zapPaymentRequest: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?> = MutableStateFlow(null),
var stripLocationOnUpload: Boolean = true,
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val zapPaymentRequest: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?> = MutableStateFlow(null),
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var hideNIP17WarningDialog: Boolean = false,
@@ -126,6 +174,8 @@ class AccountSettings(
var backupDMRelayList: ChatMessageRelayListEvent? = null,
var backupNIP65RelayList: AdvertisedRelayListEvent? = null,
var backupSearchRelayList: SearchRelayListEvent? = null,
var backupIndexRelayList: IndexerRelayListEvent? = null,
var backupRelayFeedsList: RelayFeedsListEvent? = null,
var backupBlockedRelayList: BlockedRelayListEvent? = null,
var backupTrustedRelayList: TrustedRelayListEvent? = null,
var backupMuteList: MuteListEvent? = null,
@@ -136,11 +186,13 @@ class AccountSettings(
var backupHashtagList: HashtagListEvent? = null,
var backupGeohashList: GeohashListEvent? = null,
var backupEphemeralChatList: EphemeralChatListEvent? = null,
val torSettings: TorSettingsFlow = TorSettingsFlow(),
var backupTrustProviderList: TrustProviderListEvent? = null,
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
) {
val hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
) : EphemeralChatRepository,
PublicChatListRepository {
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal())
@@ -154,21 +206,6 @@ class AccountSettings(
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
fun createSigner(contentResolver: ContentResolver) =
if (keyPair.privKey != null) {
NostrSignerInternal(keyPair)
} else {
when (val packageName = externalSignerPackageName) {
null -> NostrSignerInternal(keyPair)
else ->
NostrSignerExternal(
pubKey = keyPair.pubKey.toHexKey(),
packageName = packageName,
contentResolver = contentResolver,
)
}
}
// ---
// Zaps and Reactions
// ---
@@ -200,6 +237,15 @@ class AccountSettings(
return false
}
fun changeReactionRowItems(newItems: List<ReactionRowItem>): Boolean {
if (syncedSettings.reactions.reactionRowItems.value != newItems) {
syncedSettings.reactions.reactionRowItems.tryEmit(newItems.toImmutableList())
saveAccountSettings()
return true
}
return false
}
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URINorm?): Boolean {
if (zapPaymentRequest.value != newServer) {
zapPaymentRequest.tryEmit(newServer)
@@ -220,6 +266,13 @@ class AccountSettings(
}
}
fun changeStripLocationOnUpload(strip: Boolean) {
if (stripLocationOnUpload != strip) {
stripLocationOnUpload = strip
saveAccountSettings()
}
}
// ---
// list names
// ---
@@ -228,7 +281,7 @@ class AccountSettings(
changeDefaultHomeFollowList(name.code)
}
fun changeDefaultHomeFollowList(name: String) {
fun changeDefaultHomeFollowList(name: TopFilter) {
if (defaultHomeFollowList.value != name) {
defaultHomeFollowList.tryEmit(name)
saveAccountSettings()
@@ -239,7 +292,7 @@ class AccountSettings(
changeDefaultStoriesFollowList(name.code)
}
fun changeDefaultStoriesFollowList(name: String) {
fun changeDefaultStoriesFollowList(name: TopFilter) {
if (defaultStoriesFollowList.value != name) {
defaultStoriesFollowList.tryEmit(name)
saveAccountSettings()
@@ -250,7 +303,7 @@ class AccountSettings(
changeDefaultNotificationFollowList(name.code)
}
fun changeDefaultNotificationFollowList(name: String) {
fun changeDefaultNotificationFollowList(name: TopFilter) {
if (defaultNotificationFollowList.value != name) {
defaultNotificationFollowList.tryEmit(name)
saveAccountSettings()
@@ -261,24 +314,13 @@ class AccountSettings(
changeDefaultDiscoveryFollowList(name.code)
}
fun changeDefaultDiscoveryFollowList(name: String) {
fun changeDefaultDiscoveryFollowList(name: TopFilter) {
if (defaultDiscoveryFollowList.value != name) {
defaultDiscoveryFollowList.tryEmit(name)
saveAccountSettings()
}
}
// ---
// proxy settings
// ---
fun setTorSettings(newTorSettings: TorSettings): Boolean =
if (torSettings.update(newTorSettings)) {
saveAccountSettings()
true
} else {
false
}
// ---
// language services
// ---
@@ -287,9 +329,21 @@ class AccountSettings(
saveAccountSettings()
}
fun translateToContains(languageCode: Locale) = syncedSettings.languages.translateTo.contains(languageCode.language)
fun addDontTranslateFrom(languageCode: String) {
syncedSettings.languages.addDontTranslateFrom(languageCode)
saveAccountSettings()
}
fun updateTranslateTo(languageCode: Locale): Boolean {
fun removeDontTranslateFrom(languageCode: String) {
syncedSettings.languages.removeDontTranslateFrom(languageCode)
saveAccountSettings()
}
fun translateToContains(languageCode: String) =
syncedSettings.languages.translateTo.value
.contains(languageCode)
fun updateTranslateTo(languageCode: String): Boolean {
if (syncedSettings.languages.updateTranslateTo(languageCode)) {
saveAccountSettings()
return true
@@ -362,6 +416,16 @@ class AccountSettings(
}
}
fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) {
if (newNIPA3PaymentTargets == null || newNIPA3PaymentTargets.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupNipA3PaymentTargets?.id != newNIPA3PaymentTargets.id) {
backupNipA3PaymentTargets = newNIPA3PaymentTargets
saveAccountSettings()
}
}
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
@@ -372,6 +436,26 @@ class AccountSettings(
}
}
fun updateIndexRelayList(newIndexRelayList: IndexerRelayListEvent?) {
if (newIndexRelayList == null || newIndexRelayList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupIndexRelayList?.id != newIndexRelayList.id) {
backupIndexRelayList = newIndexRelayList
saveAccountSettings()
}
}
fun updateRelayFeedList(newRelayFeedList: RelayFeedsListEvent?) {
if (newRelayFeedList == null || newRelayFeedList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupRelayFeedsList?.id != newRelayFeedList.id) {
backupRelayFeedsList = newRelayFeedList
saveAccountSettings()
}
}
fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) {
if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return
@@ -402,7 +486,9 @@ class AccountSettings(
}
}
fun updateChannelListTo(newChannelList: ChannelListEvent?) {
override fun channelList() = backupChannelList
override fun updateChannelListTo(newChannelList: ChannelListEvent?) {
if (newChannelList == null || newChannelList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
@@ -442,7 +528,9 @@ class AccountSettings(
}
}
fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) {
override fun ephemeralChatList() = backupEphemeralChatList
override fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) {
if (newEphemeralChatList == null || newEphemeralChatList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
@@ -452,6 +540,16 @@ class AccountSettings(
}
}
fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) {
if (trustProviderList == null || trustProviderList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupTrustProviderList?.id != trustProviderList.id) {
backupTrustProviderList = trustProviderList
saveAccountSettings()
}
}
fun updateMuteList(newMuteList: MuteListEvent?) {
if (newMuteList == null || newMuteList.tags.isEmpty()) return
@@ -535,7 +633,7 @@ class AccountSettings(
route: String,
timestampInSecs: Long,
): MutableStateFlow<Long> =
MutableStateFlow<Long>(timestampInSecs).also { newFlow ->
MutableStateFlow(timestampInSecs).also { newFlow ->
lastReadPerRoute.update { it + Pair(route, newFlow) }
saveAccountSettings()
}

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -27,13 +27,16 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.util.Locale
@Stable
class AccountSyncedSettings(
internalSettings: AccountSyncedSettingsInternal,
) {
val reactions = AccountReactionPreferences(MutableStateFlow(internalSettings.reactions.reactionChoices.toImmutableList()))
val reactions =
AccountReactionPreferences(
MutableStateFlow(internalSettings.reactions.reactionChoices.toImmutableList()),
MutableStateFlow(internalSettings.reactions.reactionRowItems.toImmutableList()),
)
val zaps =
AccountZapPreferences(
MutableStateFlow(internalSettings.zaps.zapAmountChoices.toImmutableList()),
@@ -41,9 +44,9 @@ class AccountSyncedSettings(
)
val languages =
AccountLanguagePreferences(
internalSettings.languages.dontTranslateFrom,
internalSettings.languages.languagePreferences,
internalSettings.languages.translateTo,
MutableStateFlow(internalSettings.languages.dontTranslateFrom),
MutableStateFlow(internalSettings.languages.languagePreferences),
MutableStateFlow(internalSettings.languages.translateTo),
)
val security =
AccountSecurityPreferences(
@@ -54,7 +57,7 @@ class AccountSyncedSettings(
fun toInternal(): AccountSyncedSettingsInternal =
AccountSyncedSettingsInternal(
reactions = AccountReactionPreferencesInternal(reactions.reactionChoices.value),
reactions = AccountReactionPreferencesInternal(reactions.reactionChoices.value, reactions.reactionRowItems.value),
zaps =
AccountZapPreferencesInternal(
zaps.zapAmountChoices.value,
@@ -62,9 +65,9 @@ class AccountSyncedSettings(
),
languages =
AccountLanguagePreferencesInternal(
languages.dontTranslateFrom,
languages.languagePreferences,
languages.translateTo,
languages.dontTranslateFrom.value,
languages.languagePreferences.value,
languages.translateTo.value,
),
security =
AccountSecurityPreferencesInternal(
@@ -80,6 +83,11 @@ class AccountSyncedSettings(
reactions.reactionChoices.tryEmit(newReactionChoices)
}
val newReactionRowItems = syncedSettingsInternal.reactions.reactionRowItems.toImmutableList()
if (!equalImmutableLists(reactions.reactionRowItems.value, newReactionRowItems)) {
reactions.reactionRowItems.tryEmit(newReactionRowItems)
}
val newZapChoices = syncedSettingsInternal.zaps.zapAmountChoices.toImmutableList()
if (!equalImmutableLists(zaps.zapAmountChoices.value, newZapChoices)) {
zaps.zapAmountChoices.tryEmit(newZapChoices)
@@ -89,16 +97,16 @@ class AccountSyncedSettings(
zaps.defaultZapType.tryEmit(syncedSettingsInternal.zaps.defaultZapType)
}
if (languages.dontTranslateFrom != syncedSettingsInternal.languages.dontTranslateFrom) {
languages.dontTranslateFrom = syncedSettingsInternal.languages.dontTranslateFrom
if (languages.dontTranslateFrom.value != syncedSettingsInternal.languages.dontTranslateFrom) {
languages.dontTranslateFrom.value = syncedSettingsInternal.languages.dontTranslateFrom
}
if (languages.languagePreferences != syncedSettingsInternal.languages.languagePreferences) {
languages.languagePreferences = syncedSettingsInternal.languages.languagePreferences
if (languages.languagePreferences.value != syncedSettingsInternal.languages.languagePreferences) {
languages.languagePreferences.value = syncedSettingsInternal.languages.languagePreferences
}
if (languages.translateTo != syncedSettingsInternal.languages.translateTo) {
languages.translateTo = syncedSettingsInternal.languages.translateTo
if (languages.translateTo.value != syncedSettingsInternal.languages.translateTo) {
languages.translateTo.value = syncedSettingsInternal.languages.translateTo
}
if (security.showSensitiveContent.value != syncedSettingsInternal.security.showSensitiveContent) {
@@ -114,12 +122,13 @@ class AccountSyncedSettings(
}
}
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom - getLanguagesSpokenByUser()
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
}
@Stable
class AccountReactionPreferences(
var reactionChoices: MutableStateFlow<ImmutableList<String>>,
var reactionRowItems: MutableStateFlow<ImmutableList<ReactionRowItem>>,
)
@Stable
@@ -130,27 +139,36 @@ class AccountZapPreferences(
@Stable
class AccountLanguagePreferences(
var dontTranslateFrom: Set<String>,
var languagePreferences: Map<String, String>,
var translateTo: String,
var dontTranslateFrom: MutableStateFlow<Set<String>>,
var languagePreferences: MutableStateFlow<Map<String, String>>,
var translateTo: MutableStateFlow<String>,
) {
// ---
// language services
// ---
fun toggleDontTranslateFrom(languageCode: String) {
dontTranslateFrom =
if (!dontTranslateFrom.contains(languageCode)) {
dontTranslateFrom.plus(languageCode)
dontTranslateFrom.update {
if (it.contains(languageCode)) {
it - languageCode
} else {
dontTranslateFrom.minus(languageCode)
it + languageCode
}
}
}
fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language)
fun addDontTranslateFrom(languageCode: String) {
dontTranslateFrom.update { it + languageCode }
}
fun updateTranslateTo(languageCode: Locale): Boolean {
if (translateTo != languageCode.language) {
translateTo = languageCode.language
fun removeDontTranslateFrom(languageCode: String) {
dontTranslateFrom.update { it - languageCode }
}
fun translateToContains(languageCode: String) = translateTo.value.contains(languageCode)
fun updateTranslateTo(languageCode: String): Boolean {
if (translateTo.value != languageCode) {
translateTo.tryEmit(languageCode)
return true
}
return false
@@ -162,13 +180,15 @@ class AccountLanguagePreferences(
preference: String,
) {
val key = "$source,$target"
if (key !in languagePreferences) {
languagePreferences = languagePreferences + Pair(key, preference)
} else {
if (languagePreferences.get(key) == preference) {
languagePreferences = languagePreferences.minus(key)
languagePreferences.update {
if (key !in it) {
it + Pair(key, preference)
} else {
languagePreferences = languagePreferences + Pair(key, preference)
if (it.get(key) == preference) {
it.minus(key)
} else {
it + Pair(key, preference)
}
}
}
}
@@ -176,7 +196,7 @@ class AccountLanguagePreferences(
fun preferenceBetween(
source: String,
target: String,
): String? = languagePreferences["$source,$target"]
): String? = languagePreferences.value["$source,$target"]
}
@Stable

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.serialization.Serializable
import java.util.Locale
val DefaultReactions =
@@ -38,6 +39,31 @@ val DefaultReactions =
val DefaultZapAmounts = listOf(100L, 500L, 1000L)
@Serializable
enum class ReactionRowAction {
Reply,
Boost,
Like,
Zap,
Share,
}
@Serializable
data class ReactionRowItem(
val action: ReactionRowAction,
val enabled: Boolean = true,
val showCounter: Boolean = true,
)
val DefaultReactionRowItems =
listOf(
ReactionRowItem(ReactionRowAction.Reply),
ReactionRowItem(ReactionRowAction.Boost),
ReactionRowItem(ReactionRowAction.Like),
ReactionRowItem(ReactionRowAction.Zap),
ReactionRowItem(ReactionRowAction.Share, showCounter = false),
)
fun getLanguagesSpokenByUser(): Set<String> {
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
val codedList = mutableSetOf<String>()
@@ -47,6 +73,7 @@ fun getLanguagesSpokenByUser(): Set<String> {
return codedList
}
@Serializable
class AccountSyncedSettingsInternal(
val reactions: AccountReactionPreferencesInternal = AccountReactionPreferencesInternal(),
val zaps: AccountZapPreferencesInternal = AccountZapPreferencesInternal(),
@@ -54,21 +81,26 @@ class AccountSyncedSettingsInternal(
val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(),
)
@Serializable
class AccountReactionPreferencesInternal(
var reactionChoices: List<String> = DefaultReactions,
var reactionRowItems: List<ReactionRowItem> = DefaultReactionRowItems,
)
@Serializable
class AccountZapPreferencesInternal(
var zapAmountChoices: List<Long> = DefaultZapAmounts,
val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
)
@Serializable
class AccountLanguagePreferencesInternal(
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
)
@Serializable
class AccountSecurityPreferencesInternal(
val showSensitiveContent: Boolean? = null,
var warnAboutPostsWithReports: Boolean = true,

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -20,18 +20,18 @@
*/
package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.ui.note.njumpLink
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.MutableStateFlow
data class Spammer(
@@ -65,7 +65,7 @@ class AntiSpamFilter {
// really long, make it ok.
// The idea here is to avoid considering repeated "@Bot, command" messages spam, while still
// blocking repeated "lnbc..." invoices or fishing urls
if (event.content.length < 180 && event.content.startsWith("nostr:") && Nip19Parser.nip19regex.matcher(event.content).find()) return false
if (event.content.length < 180 && event.content.startsWith("nostr:") && Nip19Parser.hasAny(event.content)) return false
// double list strategy:
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
@@ -93,7 +93,9 @@ class AntiSpamFilter {
val spammer = logOffender(hash, event)
if (spammer.shouldHide() && relay != null) {
RelayStats.newSpam(relay, "$link1 $link2")
Amethyst.instance.relayStats
.get(relay)
.newSpam(link1, link2)
}
flowSpam.tryEmit(AntiSpamState(this))
@@ -104,12 +106,11 @@ class AntiSpamFilter {
recentAddressables.put(hash, address)
} else {
// normal event
val existingEvent = recentEventIds[hash]
if (
(recentEventIds[hash] != null && recentEventIds[hash] != event.id) ||
(existingEvent != null && existingEvent != event.id) ||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventIds.contains(event.id))
) {
val existingEvent = recentEventIds[hash]
val link1 = njumpLink(NEvent.create(existingEvent, null, null, relay))
val link2 = njumpLink(NEvent.create(event.id, null, null, relay))
@@ -119,7 +120,9 @@ class AntiSpamFilter {
val spammer = logOffender(hash, event)
if (spammer.shouldHide() && relay != null) {
RelayStats.newSpam(relay, "$link1 $link2")
Amethyst.instance.relayStats
.get(relay)
.newSpam(link1, link2)
}
flowSpam.tryEmit(AntiSpamState(this))
@@ -167,7 +170,7 @@ class AntiSpamFilter {
}
}
val flowSpam = MutableStateFlow<AntiSpamState>(AntiSpamState(this))
val flowSpam = MutableStateFlow(AntiSpamState(this))
}
class AntiSpamState(

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -28,25 +28,28 @@ object Constants {
val primal = RelayUrlNormalizer.normalize("wss://relay.primal.net")
val damus = RelayUrlNormalizer.normalize("wss://relay.damus.io")
val wine = RelayUrlNormalizer.normalize("wss://nostr.wine")
val band = RelayUrlNormalizer.normalize("wss://relay.nostr.band")
val where = RelayUrlNormalizer.normalize("wss://relay.noswhere.com")
val elites = RelayUrlNormalizer.normalize("wss://nostrelites.org")
val bitcoiner = RelayUrlNormalizer.normalize("wss://nostr.bitcoiner.social")
val oxtr = RelayUrlNormalizer.normalize("wss://nostr.oxtr.dev")
val fmtwiz = RelayUrlNormalizer.normalize("wss://nostr.fmt.wiz.biz")
val nostoday = RelayUrlNormalizer.normalize("wss://search.nos.today")
val antiprimal = RelayUrlNormalizer.normalize("wss://antiprimal.net")
val ditto = RelayUrlNormalizer.normalize("wss://relay.ditto.pub")
val auth = RelayUrlNormalizer.normalize("wss://auth.nostr1.com")
val oxchat = RelayUrlNormalizer.normalize("wss://relay.0xchat.com")
val news = RelayUrlNormalizer.normalize("wss://news.utxo.one")
val purplepages = RelayUrlNormalizer.normalize("wss://purplepag.es")
val coracle = RelayUrlNormalizer.normalize("wss://indexer.coracle.social")
val userkinds = RelayUrlNormalizer.normalize("wss://user.kindpag.es")
val yabu = RelayUrlNormalizer.normalize("wss://directory.yabu.me")
val nostr1 = RelayUrlNormalizer.normalize("wss://profiles.nostr1.com")
val eventFinderRelays = setOf(band, wine, damus, primal, mom, nos, bitcoiner, oxtr, fmtwiz)
val defaultSearchRelaySet = setOf(band, wine, where)
val bootstrapInbox = setOf(damus, primal, mom, nos, bitcoiner, oxtr, yabu)
val eventFinderRelays = setOf(wine, damus, primal, mom, nos, bitcoiner, oxtr)
}

View File

@@ -1,4 +1,4 @@
/**
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
@@ -45,27 +45,28 @@ import com.vitorpamplona.amethyst.commons.hashtags.Skull
import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
import com.vitorpamplona.amethyst.commons.hashtags.Weed
import com.vitorpamplona.amethyst.commons.hashtags.Zap
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.ui.components.HashTag
import com.vitorpamplona.amethyst.ui.components.RenderRegular
import com.vitorpamplona.amethyst.ui.components.RenderTextParagraph
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
@Preview
@Composable
fun RenderHashTagIconsPreview() {
val accountViewModel = mockAccountViewModel()
ThemeComparisonColumn {
RenderRegular(
"Testing rendering of hashtags: #flowerstr #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain",
EmptyTagList,
) { word, state ->
when (word) {
is HashTagSegment -> HashTag(word, accountViewModel, EmptyNav)
is RegularTextSegment -> Text(word.segmentText)
) { paragraph, state, spaceWidth, modifier ->
RenderTextParagraph(paragraph, spaceWidth, modifier) { word ->
when (word) {
is HashTagSegment -> HashTag(word, EmptyNav())
is RegularTextSegment -> Text(word.segmentText)
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More