Compare commits

..
424 Commits
Author SHA1 Message Date
Vitor Pamplona 8e4ee7a618 v1.07.2 2026-03-30 16:46:01 -04:00
Vitor PamplonaandGitHub 1872a5879e Merge pull request #2042 from greenart7c3/main
Assign `rejected` based on the presence of the "rejected" key in intent extras rather than retrieving its specific boolean value
2026-03-30 16:39:13 -04:00
Vitor PamplonaandGitHub a293976501 Merge pull request #2043 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-30 16:38:44 -04:00
Vitor PamplonaandGitHub a1095b4c17 Merge pull request #2044 from vitorpamplona/claude/add-gif-keyboard-support-ahCJX
Add image paste support to message input field
2026-03-30 16:38:14 -04:00
Claude 95d678f62f feat: Add GIF keyboard support to short note post screen
Enable the message field to receive images/GIFs from Android's keyboard
using Compose Foundation's contentReceiver API. When content is received,
it's routed through the existing selectImage flow for upload.

https://claude.ai/code/session_019GfY3MtCPcYn3wqTHCMSNu
2026-03-30 20:33:55 +00:00
Crowdin Bot b625ac6d2d New Crowdin translations by GitHub Action 2026-03-30 20:26:29 +00:00
Vitor PamplonaandGitHub f848ad20d8 Merge pull request #2041 from greenart7c3/fix_external_signer
fix: support external signers wrapped in NostrSignerWithClientTag
2026-03-30 16:24:56 -04:00
greenart7c3 198e049873 Assign rejected based on the presence of the "rejected" key in intent extras rather than retrieving its specific boolean value 2026-03-30 17:18:02 -03:00
greenart7c3 f2d007f9ba fix: support external signers wrapped in NostrSignerWithClientTag
The external signer registration logic was failing when the signer was
wrapped in a `NostrSignerWithClientTag`. Fix by:

1. Updating `ListenToExternalSignerIfNeeded` to unwrap the signer if it's
   a `NostrSignerWithClientTag` containing an `IActivityLauncher`
2. Using the unwrapped launcher for intent registration and response handling
3. Renaming the intent callback to `intentLauncher` to avoid shadowing the `ActivityResultLauncher` variable
2026-03-30 17:09:59 -03:00
Vitor Pamplona 4b9c3f239c Fixes version mismatch 2026-03-30 16:02:55 -04:00
Vitor Pamplona 3ac04cdc63 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-03-30 15:59:14 -04:00
Vitor PamplonaandGitHub f9d231063d Merge pull request #2040 from vitorpamplona/claude/migrate-textfield-state-6ezEZ
Migrate text fields from TextFieldValue to TextFieldState
2026-03-30 15:58:19 -04:00
Vitor PamplonaandGitHub 4bca74888d Merge pull request #2039 from vitorpamplona/claude/relay-manager-user-search-jivZ2
Replace hex input with user search dialog in relay management
2026-03-30 15:46:03 -04:00
Vitor PamplonaandGitHub 17ff27bac9 Merge pull request #2038 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-30 15:00:48 -04:00
Vitor Pamplona 3913384fcf Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-03-30 15:00:32 -04:00
Vitor Pamplona b02b8c4ed9 new change logs 2026-03-30 14:57:13 -04:00
Crowdin Bot ed31b12e3f New Crowdin translations by GitHub Action 2026-03-30 18:50:03 +00:00
Vitor PamplonaandGitHub 3b63264927 Merge pull request #2036 from Nam0101/contribai/docs/undocumented-identity-based-comparison-i
Docs: Undocumented identity-based comparison in `equalImmutableLists`
2026-03-30 14:48:27 -04:00
Vitor Pamplona 640c47e29f v1.07.1 2026-03-30 13:53:25 -04:00
Vitor PamplonaandGitHub 3dd1b949eb Merge pull request #2037 from vitorpamplona/claude/threadsafe-without-synchronized-Zw8Tx
Replace synchronized queue with Channel and AtomicBoolean
2026-03-30 13:51:20 -04:00
Claude 13bd492e32 refactor: replace synchronized with lock-free CAS in BleChunkAssembler
Use AtomicReference holding an immutable list with a compare-and-set loop,
eliminating the synchronized block while maintaining thread safety.

https://claude.ai/code/session_01KLRz7FJgWRtgCcZe3qD1VX
2026-03-30 17:48:58 +00:00
Claude ef8b9be0e8 refactor: replace synchronized with Channel + AtomicBoolean in BLE relay classes
Use Channel(UNLIMITED) as a lock-free send queue and AtomicBoolean with
atomic exchange for the isSending flag, eliminating all synchronized blocks
while maintaining thread safety via a double-check pattern.

https://claude.ai/code/session_01KLRz7FJgWRtgCcZe3qD1VX
2026-03-30 17:37:23 +00:00
Vitor Pamplona c44f59b1ce Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-03-30 12:30:54 -04:00
Vitor Pamplona 0c4e5be1ea v1.07.0 2026-03-30 12:29:41 -04:00
Nguyen Van Nam bc491b154b docs: undocumented identity-based comparison in equalimmutablelists
`equalImmutableLists` is public and uses referential equality (`===`) per element rather than structural equality (`==`). Without documentation, callers may assume normal list equality and get false negatives for value-equal but distinct instances. This is a non-obvious contract and should be documented explicitly to prevent subtle logic bugs.


Affected files: ListUtils.kt

Signed-off-by: Nguyen Van Nam <nam.nv205106@gmail.com>
2026-03-30 22:48:41 +07:00
davotoula 06fae3ba31 remove unused imports 2026-03-30 17:26:21 +02:00
Vitor PamplonaandGitHub ea7a839568 Merge pull request #2035 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-30 11:13:44 -04:00
Crowdin Bot 6912a120f8 New Crowdin translations by GitHub Action 2026-03-30 15:12:36 +00:00
davotoula 3d0364ec5e update translations: CZ, DE, PT, SE 2026-03-30 17:09:39 +02:00
David KasparandGitHub e9a33a88ca Merge pull request #2032 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-30 15:11:05 +02:00
Crowdin Bot 4581233c10 New Crowdin translations by GitHub Action 2026-03-30 12:50:10 +00:00
Vitor PamplonaandGitHub f81631d95c Merge pull request #2034 from vitorpamplona/claude/simplify-relay-md-docs-GXKoW
Simplify RELAY.md documentation for clarity and brevity
2026-03-30 08:48:28 -04:00
Vitor PamplonaandGitHub 5214470209 Merge pull request #2033 from vitorpamplona/claude/create-nostr-readme-BUScG
Add comprehensive Nostr client documentation guide
2026-03-30 08:48:14 -04:00
Claude f65fd30728 docs: simplify RELAY.md by removing verbose sections
Remove architecture diagram, NIP support table, module table,
Live Subscriptions explanation, serve method details, production-ready
example, and excessive code comments. Keep the readable Quick Start,
Store options, and Policy documentation in a more concise form.

https://claude.ai/code/session_01U3iW3eRD7bfLwrM3L9gkDc
2026-03-30 12:46:38 +00:00
David KasparandGitHub 4eb03f66f9 Merge pull request #2029 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-30 14:42:13 +02:00
Crowdin Bot 7ea9f72a0a New Crowdin translations by GitHub Action 2026-03-30 12:38:18 +00:00
Vitor PamplonaandGitHub a468f3b6d6 Merge pull request #2031 from vitorpamplona/claude/simplify-relay-api-Zq3o8
Add AutoCloseable support and simplify relay connection lifecycle
2026-03-30 08:36:44 -04:00
Claude df88cab92d refactor: simplify relay API with AutoCloseable and serve() helper
- NostrServer and IEventStore implement AutoCloseable for .use {} support
- Add NostrServer.serve() to handle session lifecycle automatically
- IRelayPolicy + operator now returns PolicyStack instead of List
- Deprecate shutdown() in favor of close()
- Update RELAY.md guide to use simplified API patterns

https://claude.ai/code/session_013oL9PkQaFyNQHKVg2vw9qs
2026-03-30 12:35:27 +00:00
Claude 3d400d4197 docs: add CLIENT.md guide for building Nostr clients with Quartz
Companion to the existing RELAY.md, this guide covers the client side:
Ktor WebSocket transport implementation, subscribing to events (Flow
and callback APIs), publishing signed events, filters, key management,
and multi-relay patterns.

https://claude.ai/code/session_01KFos33kA9VoMhKdBPNLquF
2026-03-30 12:34:51 +00:00
Vitor PamplonaandGitHub b808f5a1f6 Merge pull request #2030 from vitorpamplona/claude/nip89-compliance-modernize-BWso3
Add PlatformLinkTag parsing and NIP-89 app handler extensions
2026-03-30 08:22:36 -04:00
Vitor PamplonaandGitHub 5d7f37fb41 Merge pull request #2027 from nrobi144/fix/general-bugfixes
fix: desktop bugfixes — flaky test, repost rendering, reads feed
2026-03-30 08:21:52 -04:00
Claude 5f0fd52f26 refactor: remove nip88PollApps discovery package (out of scope)
https://claude.ai/code/session_013r2LXj11SieWa5PaLesKfC
2026-03-30 12:17:03 +00:00
Vitor PamplonaandGitHub e556f7716c Merge pull request #2028 from vitorpamplona/claude/create-relay-readme-6VEdI
Add comprehensive Nostr relay implementation guide (RELAY.md)
2026-03-30 08:11:25 -04:00
nrobi144andClaude Opus 4.6 0a41806d7e fix(desktop): feed loading issues — missing events, broken profile navigation
- Profile feed now includes reposts (kind 6/16), not just text notes
- Metadata consume returns false to avoid wasteful null note lookups
- Feed subscription limits raised from 50 to 200 for global/following
- Thread replies subscription fetches NIP-22 comments (kind 1111)
- Cache now handles CommentEvent for thread display
- Wire onNavigateToThread through UserProfileScreen so tapping notes opens threads

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:33:18 +03:00
nrobi144andClaude Opus 4.6 882c39fd0e fix(cache): use getOrCreateNote for reply linking to fix flaky thread test
Fixes #2001 — ThreadFilter test failed intermittently because
consumeTextNote used getNoteIfExists for reply-to linking. If the
reply event arrived before the root, the link was lost. Now uses
getOrCreateNote to create placeholders, same pattern as reposts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:44:24 +03:00
nrobi144andClaude Opus 4.6 2b3005797e feat(desktop): render reposts and quoted notes in feed
- Extract GenericRepostLayout + BoostedMark to commons for cross-platform use
- Feed filters accept kind 6/16 reposts with deduplication by original note
- Relay subscriptions request kinds 1, 6, 16
- Cache consumes GenericRepostEvent (kind 16) and uses getOrCreateNote for
  repost originals to handle out-of-order arrival
- FeedNoteCard renders reposts with overlapping avatars + "Boosted" label
- QuotedNoteEmbed renders nostr:nevent/note references as embedded NoteCards
  with reactive metadata observation
- Direct relay subscriptions fetch missing referenced notes and author metadata
- FeedMetadataCoordinator routes all events (not just metadata) back to cache
- consumeMetadata invalidates note flows when author metadata arrives

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:44:24 +03:00
nrobi144 0e8c6fa434 fix(cache): route ReadsScreen following-mode events through cache
Following-mode long-form feed was not calling consumeEvent(),
so LongTextNoteEvents never reached the cache. Back-navigation
showed empty reads because cache had nothing to seed from.
2026-03-30 11:44:23 +03:00
Claude 4ba92931f6 docs: add relay README for building with Ktor, NostrServer, and SQLite EventStore
Comprehensive guide covering the full relay stack: NostrServer setup,
SQLite EventStore configuration, Ktor WebSocket integration, policy
system (VerifyPolicy, FullAuthPolicy, PolicyStack), indexing strategies,
testing patterns, and NIP support matrix.

https://claude.ai/code/session_01CTyiKDNhgXdfCFBBBXf1NF
2026-03-30 04:13:53 +00:00
Claude 940e59b323 feat: NIP-89 compliance fixes, modernize package structure, link NIP-88 polls
Fix PlatformLinkTag.parse() off-by-one bug where tag indices were shifted
(platform/uri/entityType read from wrong positions). Add missing TagArrayExt
and EventExt files for definition and recommendation packages to align with
the modern pattern used by nip88Polls. Parameterize DiscoverNIP89FeedFilter
by targetKind and create NIP-89 poll app discovery infrastructure linking
NIP-88 polls (kind 1068) to the NIP-89 app handler discovery system.

https://claude.ai/code/session_013r2LXj11SieWa5PaLesKfC
2026-03-30 03:49:21 +00:00
Claude 4d8583eed9 feat: replace hex input with user search in relay manager allow/ban dialogs
Users can now search by name, npub, or NIP-05 identifier when adding
users to relay allow/ban lists, using the same UserSuggestionState
mechanism as the My List screens. The hex pubkey input was not
user-friendly since most users don't know their hex representation.

https://claude.ai/code/session_01SWReoziLKJKxrbwDWprQYs
2026-03-30 03:43:51 +00:00
Vitor PamplonaandGitHub 35d677f531 Merge pull request #2025 from vitorpamplona/claude/nip90-dvm-kinds-itxXb
feat: add all NIP-90 DVM kind event classes from data-vending-machines spec
2026-03-29 23:28:15 -04:00
Claude 273af03c9f feat: add reader methods to all DVM request events for server processing
Each request event now exposes typed reader methods so DVM servers can
extract inputs, parameters, and configuration from incoming requests:
- inputs() returns List<InputTag> with value, type, relay, marker
- Kind-specific readers like language(), model(), searchQuery(), pow(), etc.
- Shared TagArrayExt with dvmParam(), dvmParamAll(), dvmParamValues()

Also adds readers to existing 5300/5301 events (dvmPubKey, user, relays).

https://claude.ai/code/session_017aamEDeRtQ2H9u5U9B5F1C
2026-03-30 03:21:19 +00:00
Claude cd4b352bdf feat: add typed build parameters and shared DVM tag infrastructure
Add shared tag classes (InputTag, OutputTag) and TagArrayBuilder
extensions (inputUrl, inputText, inputEvent, inputJob, inputPrompt,
output, param) for all DVM kinds to reuse.

Update all 17 request event build() methods with typed parameters
matching their spec:
- 5000: inputUrl, outputMimeType, range, alignment
- 5001: eventIds, length, outputMimeType
- 5002: eventIds, language
- 5050: prompt, model, maxTokens, temperature, topK, topP, frequencyPenalty
- 5100: prompt, sourceImageUrl, model, lora, ratio, size, negativePrompt
- 5200: videoUrl, outputMimeType, range
- 5201: videoUrl, language, subtitle, range, outputFormat
- 5202: imageUrl
- 5250: text, language
- 5302: searchQuery, users, since, until, maxResults
- 5303: searchQuery, maxResults
- 5400: inputs, relays, groups, filterJson
- 5500: fileUrl
- 5900: eventId
- 5901: text
- 5905: eventJson, relays
- 5970: eventJson, pow

https://claude.ai/code/session_017aamEDeRtQ2H9u5U9B5F1C
2026-03-30 03:07:07 +00:00
Vitor PamplonaandGitHub ea2054fa56 Merge pull request #2024 from vitorpamplona/claude/update-nip-support-tUrxB
feat: update NIP support list to reflect current implementation status
2026-03-29 22:55:53 -04:00
Claude fe9f2e71a0 feat: update NIP support list to reflect current implementation status
Cross-referenced the official NIP list from nostr-protocol/nips with
quartz package implementations. All NIPs are now supported except NIP-EE
(MLS Protocol). Added NIP-22, NIP-5A, NIP-B0. Removed merged NIPs
(NIP-12, NIP-16, NIP-20). Updated names to match official titles.

https://claude.ai/code/session_01TyGpa2VEryZ9aqmZvmE4v1
2026-03-30 02:47:10 +00:00
Claude 4f32776b24 feat: add all NIP-90 DVM kind event classes from data-vending-machines spec
Add request/response event classes for all 19 DVM kinds defined in
nostr-protocol/data-vending-machines: text extraction (5000/6000),
summarization (5001/6001), translation (5002/6002), text generation
(5050/6050), image generation (5100/6100), video conversion (5200/6200),
video translation (5201/6201), image-to-video (5202/6202), text-to-speech
(5250/6250), content search (5302/6302), people search (5303/6303),
event count (5400/6400), malware scanning (5500/6500), event timestamping
(5900/6900), OP_RETURN creation (5901/6901), event publish schedule
(5905/6905), and event PoW delegation (5970/6970).

Also fixes existing events:
- Content discovery response (6300) now parses both "a" and "e" tags
- User discovery response (6301) now has innerTags() to parse "p" tags

All new events registered in EventFactory.

https://claude.ai/code/session_017aamEDeRtQ2H9u5U9B5F1C
2026-03-30 02:45:54 +00:00
Vitor PamplonaandGitHub 21488561e0 Merge pull request #2023 from vitorpamplona/claude/refactor-nip90-structure-ugeNj
Refactor NIP90 DVMs: reorganize packages and extract tag classes
2026-03-29 22:26:02 -04:00
Vitor PamplonaandGitHub 570f7d787d Merge pull request #2022 from vitorpamplona/claude/nostr-ble-messaging-i2rrJ
Add NIP-BE Bluetooth Low Energy mesh networking support
2026-03-29 22:24:43 -04:00
Claude 6e7f6eccf1 refactor: reorganize NIP-BE into subpackages and add README
Restructured nipBEBle/ into logical subpackages:
- Root: public API surface (BleNostrMesh, BleConfig, BlePeer, BleRole)
- protocol/: wire format (BleMessageChunker, BleChunkAssembler)
- transport/: platform BLE abstraction (BleTransport, AndroidBleTransport)
- relay/: Nostr protocol over BLE (BleNostrClient, BleNostrServer, BleMeshManager)

Added README.md with quick start guide, package structure overview,
advanced usage examples, and wire protocol reference.

https://claude.ai/code/session_01Tz5E73Rj7tL48A3qUGS5DT
2026-03-30 02:23:49 +00:00
Claude ce3de5caf7 refactor: restructure NIP90 DVMs to match NIP88 Polls pattern
Each event kind now gets its own subpackage with dedicated tag classes,
TagArrayExt for parsing, and TagArrayBuilderExt for building. Events use
the eventTemplate pattern instead of manual tag construction.

New structure:
- status/ (kind 7000) with StatusTag, AmountTag
- contentDiscoveryRequest/ (kind 5300) with RelaysTag, ParamTag
- contentDiscoveryResponse/ (kind 6300)
- userDiscoveryRequest/ (kind 5301)
- userDiscoveryResponse/ (kind 6301)

https://claude.ai/code/session_01JeB9nAK4SKuwKZBEd6EAPd
2026-03-30 02:22:54 +00:00
Claude 28f68382dd feat: add BleNostrMesh facade for simpler developer API
Before (4 steps, manual wiring):
  val transport = AndroidBleTransport(context)
  val mesh = BleMeshManager(transport, object : BleMeshListener { ... })
  transport.setListener(mesh)
  mesh.start()

After (2 steps, auto-wired):
  val mesh = BleNostrMesh(AndroidBleTransport(context))
  mesh.onEvent { event, peer -> saveEvent(event) }
  mesh.start()

The facade uses lambda callbacks instead of interface implementation,
and auto-wires the transport listener via AndroidBleTransportContract.
BleMeshManager remains available for advanced use cases.

https://claude.ai/code/session_01Tz5E73Rj7tL48A3qUGS5DT
2026-03-30 02:18:24 +00:00
Claude 57aec95e8d fix: align NIP-BE chunk format with KoalaSat/samiz reference implementation
Reviewed the samiz BLE implementation and fixed compatibility issues:

- Chunk index: changed from 2 bytes to 1 byte (matching samiz/NIP-BE example)
- Chunk overhead: 2 bytes total (1 index + 1 total count), not 3
- chunkSize parameter now means payload size (500), not total chunk size
- Android: TX power HIGH, advertise timeout indefinite (matching samiz)
- Android: request MTU 512 and CONNECTION_PRIORITY_HIGH on connect
- Android: discover services after MTU negotiation (samiz flow)
- Android: set WRITE_TYPE_DEFAULT on write characteristic
- MTU-to-chunkSize: properly accounts for ATT overhead (3) + chunk overhead (2)

These changes ensure wire-level compatibility with samiz devices.

https://claude.ai/code/session_01Tz5E73Rj7tL48A3qUGS5DT
2026-03-30 02:12:52 +00:00
Vitor PamplonaandGitHub 2d21529176 Merge pull request #2021 from vitorpamplona/claude/implement-nip-29-MEtLk
Add NIP-29 Relay Groups event types and tag support
2026-03-29 21:58:40 -04:00
Vitor PamplonaandGitHub bb7cdc973a Merge pull request #2019 from vitorpamplona/claude/implement-nip69-InF2y
Add NIP-69 P2P Order Events support
2026-03-29 21:58:33 -04:00
Claude fb78c1a4c4 feat: implement NIP-BE Nostr BLE Communications Protocol in Quartz
Adds full NIP-BE implementation for BLE-based Nostr peer-to-peer
messaging and synchronization with KMP support across all targets.

Protocol layer (commonMain):
- BleConfig: NIP-BE constants (service UUID, characteristic UUIDs)
- BleMessageChunker: DEFLATE compression + chunk splitting/joining
- BleRole/assignRole: Role assignment based on UUID comparison
- BleTransport: Platform-agnostic BLE transport interface
- BleNostrClient: IRelayClient implementation over BLE
- BleNostrServer: Relay server over BLE with notification support
- BleMeshManager: High-level mesh manager with auto-discovery,
  role assignment, and event broadcasting
- BleChunkAssembler: Thread-safe chunk reassembly

Platform support:
- Deflate expect/actual for jvmAndroid, apple, and linux targets
- AndroidBleTransport: Full Android BLE implementation using GATT
  server/client APIs with scanning, advertising, and MTU negotiation

Tests: Deflate compression, message chunking, role assignment,
and chunk assembly (25 tests passing).

https://claude.ai/code/session_01Tz5E73Rj7tL48A3qUGS5DT
2026-03-30 01:58:26 +00:00
Vitor PamplonaandGitHub abd53c87a0 Merge pull request #2020 from vitorpamplona/claude/implement-nip-15-kMewH
Add NIP-15 Marketplace event types and data models
2026-03-29 21:58:26 -04:00
Claude c5bd3ebcd3 feat: implement NIP-15 Nostr Marketplace protocol in Quartz
Adds support for the NIP-15 decentralized marketplace protocol with
all event kinds: StallEvent (30017), ProductEvent (30018),
MarketplaceEvent (30019), AuctionEvent (30020), BidEvent (1021),
and BidConfirmationEvent (1022). Follows NIP-88 Polls structure
with JSON content data models and tag builder extensions.

https://claude.ai/code/session_014tf8Fn7J5CNpEgRsyMnhAy
2026-03-30 01:56:01 +00:00
Vitor Pamplona 974b4fcfb8 Removing a hex scheme-less urldetector test to leave it for later. 2026-03-29 21:55:33 -04:00
Claude f1547a020c feat: implement NIP-29 relay-based groups on Quartz
Add protocol-level support for NIP-29 relay-based groups with all
event kinds following the nip88Polls structure pattern.

Event kinds implemented:
- 9000 (put-user), 9001 (remove-user), 9002 (edit-metadata)
- 9005 (delete-event), 9007 (create-group), 9008 (delete-group)
- 9009 (create-invite), 9021 (join-request), 9022 (leave-request)
- 39000 (group-metadata), 39001 (group-admins)
- 39002 (group-members), 39003 (supported-roles)

Shared tags: GroupIdTag (h), PreviousTag, RoleTag, CodeTag, GroupAdminTag

https://claude.ai/code/session_016mcHkA5DUr4M2K5CddAGSq
2026-03-30 01:52:58 +00:00
Claude 0a25018f20 feat: implement NIP-69 P2P Order events (kind 38383) in Quartz
Add support for peer-to-peer order events as defined in NIP-69,
enabling unified liquidity pools across P2P trading platforms.

Implements P2POrderEvent as a BaseAddressableEvent with all required
tags (k, f, s, amt, fa, pm, premium, expires_at, expiration, y, z)
and optional tags (source, rating, network, layer, name, g, bond).
Follows the nip88Polls structure with per-tag classes, TagArrayExt,
and TagArrayBuilderExt. Reuses existing GeoHashTag and ExpirationTag.

https://claude.ai/code/session_01VpvrrRMLdjpB5C9Pq4VupK
2026-03-30 01:52:15 +00:00
Vitor Pamplona 59ea94c248 Fixes PlatformLog's missing file on linux 2026-03-29 21:48:56 -04:00
Vitor Pamplona 9e21fcd2e8 Fixes lack of proxy access to download VLC files in claude's web environment. 2026-03-29 21:43:54 -04:00
Vitor Pamplona 6371f2e0d0 Fixes Url 2026-03-29 21:41:57 -04:00
Vitor Pamplona 207fc6beba removes Volatile (missing import) 2026-03-29 21:30:12 -04:00
Vitor PamplonaandGitHub 7068263c00 Merge pull request #2018 from vitorpamplona/claude/implement-nip88-rendering-Ps1Ea
Implement NIP-C7 chat messages (kind 9)
2026-03-29 21:24:59 -04:00
Claude 092c057e6f feat: implement NIP-C7 chat messages (kind 9)
Add ChatEvent in quartz/nipC7Chats with support for kind 9 chat
messages and q-tag based replies per the NIP-C7 specification.
Wire up rendering in NoteCompose and NoteMaster (ThreadFeedView).

https://claude.ai/code/session_01BmAMHBCRKXG612i6zrhhP4
2026-03-30 01:17:28 +00:00
Vitor PamplonaandGitHub 15c4fd61f1 Merge pull request #2017 from vitorpamplona/claude/implement-nip5a-rendering-5ioVq
feat: implement NIP-5A static website event rendering
2026-03-29 20:43:09 -04:00
Vitor PamplonaandGitHub c89a02eb0d Merge branch 'main' into claude/implement-nip5a-rendering-5ioVq 2026-03-29 20:43:02 -04:00
Claude 104b836bbe refactor: remove path listing from NIP-5A rendering
Users should click through to view the website in the browser
rather than seeing a list of file paths in the note card.

https://claude.ai/code/session_01XTmqQ9QatUA7aPCWt7NHNt
2026-03-30 00:41:00 +00:00
Claude 869628ae16 Revert "refactor: redesign NIP-5A rendering as website preview card"
This reverts commit 07f16c2efe.
2026-03-30 00:37:59 +00:00
Claude 07f16c2efe refactor: redesign NIP-5A rendering as website preview card
Replace the file-listing style with a UrlPreviewCard-like layout
showing nsite host URL, title, description, and optional favicon.
Remove path rendering and unused string resources.

https://claude.ai/code/session_01XTmqQ9QatUA7aPCWt7NHNt
2026-03-30 00:25:09 +00:00
Vitor PamplonaandGitHub 365a526b12 Merge pull request #2016 from vitorpamplona/claude/implement-nip7d-rendering-d4zgy
feat: implement NIP-7D thread events (kind 11)
2026-03-29 20:19:18 -04:00
Vitor PamplonaandGitHub 7f4e1f153a Merge pull request #2014 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-29 20:17:43 -04:00
Crowdin Bot d9471f9531 New Crowdin translations by GitHub Action 2026-03-30 00:14:57 +00:00
Vitor PamplonaandGitHub 5e20806229 Merge pull request #2015 from vitorpamplona/claude/implement-nip87-rendering-PBlES
Add NIP-87 Ecash support with Cashu and Fedimint event types
2026-03-29 20:13:32 -04:00
Vitor PamplonaandGitHub f91f68a74e Merge pull request #2013 from vitorpamplona/claude/refactor-url-detector-FCLCA
Refactor URL detection and path normalization for clarity
2026-03-29 19:16:05 -04:00
Claude c4044858a9 feat: implement NIP-87 ecash mint discoverability
Add protocol support and UI rendering for NIP-87 which defines ecash
mint discovery via three event kinds: MintRecommendationEvent (38000),
CashuMintEvent (38172), and FedimintEvent (38173).

https://claude.ai/code/session_01JR2nFVPjPGG9jTV4Qq2PUW
2026-03-29 22:25:46 +00:00
Claude 776bc6d36f feat: implement NIP-5A static website event rendering
Add Quartz protocol support for NIP-5A Pubkey Static Websites with
RootSiteEvent (kind 15128) and NamedSiteEvent (kind 35128), including
tag parsers for path, server, title, description, and source. Wire up
rendering in NoteCompose and ThreadFeedView to display site metadata.

https://claude.ai/code/session_01XTmqQ9QatUA7aPCWt7NHNt
2026-03-29 22:18:02 +00:00
Claude 8e66776ee1 feat: implement NIP-7D thread events (kind 11)
Add support for NIP-7D thread events with title rendering in both
NoteCompose (feed view) and ThreadFeedView (detail view). Replies
use existing NIP-22 kind 1111 comments.

https://claude.ai/code/session_01MR2hLpmq3pkzzT11t86dAt
2026-03-29 22:15:21 +00:00
David KasparandGitHub a170335db8 Merge pull request #2012 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-29 21:36:21 +02:00
Crowdin Bot 59c18f2c75 New Crowdin translations by GitHub Action 2026-03-29 18:39:41 +00:00
David KasparandGitHub 6613d1cacd Merge pull request #2011 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-29 20:38:25 +02:00
Crowdin Bot bed5f96f20 New Crowdin translations by GitHub Action 2026-03-29 14:53:06 +00:00
Vitor Pamplona c624293661 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-03-29 10:51:11 -04:00
Vitor Pamplona 9dc208deba Removing internal runBlockings 2026-03-29 10:43:38 -04:00
Vitor PamplonaandGitHub decf7df2bb Merge pull request #2007 from davotoula/ci-build-stop-daemons-after-build
Added ./gradlew --stop (with if: always()) after the Gradle steps in both test and build-desktop jobs
2026-03-29 10:21:39 -04:00
Vitor Pamplona ebbcb603fb Fixes lazy on trimming service 2026-03-29 10:20:06 -04:00
Vitor Pamplona 8613ae4f31 Speeding up start by parallelizing access to the preference files before they are needed. 2026-03-29 10:17:51 -04:00
Vitor PamplonaandGitHub 56510e4213 Merge pull request #2010 from vitorpamplona/claude/nip43-relay-members-dWO19
Add NIP-43 relay members support with UI screens
2026-03-29 10:17:29 -04:00
Vitor PamplonaandGitHub cd17badaf4 Merge pull request #2008 from vitorpamplona/claude/add-nip-61-support-KTkuk
Add NIP-61 Nutzaps support with event types and tag handling
2026-03-29 10:17:18 -04:00
Vitor PamplonaandGitHub a4ce8439de Merge branch 'main' into claude/add-nip-61-support-KTkuk 2026-03-29 10:17:11 -04:00
Vitor PamplonaandGitHub 2c5f80f613 Merge pull request #2009 from vitorpamplona/claude/add-nip60-support-DOf8B
Add NIP-60 Cashu wallet and spending history event support
2026-03-29 10:16:46 -04:00
Claude cad0ff3d80 fix: Make add/remove member events support multiple p tags and add NIP-43 to NoteMaster
Changes memberPubKey() to memberPubKeys() returning a list of all p tags
in RelayAddMemberEvent and RelayRemoveMemberEvent. Updates renderers to
display multiple members. Adds NIP-43 event rendering to ThreadFeedView
(NoteMaster) alongside NoteCompose.

https://claude.ai/code/session_01PRqe4bBCb9u62oPh8u9sHx
2026-03-29 14:13:41 +00:00
Vitor Pamplona ae99700cef Updates media 2026-03-29 10:02:52 -04:00
Vitor PamplonaandGitHub 03c692a8cf Merge pull request #2005 from davotoula/Introduce-log-level-filtering
Introduce log level filtering
2026-03-29 09:56:21 -04:00
David KasparandGitHub efe0bde005 Merge pull request #2006 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-29 15:42:25 +02:00
davotoula 3f09150d80 Added ./gradlew --stop (with if: always()) after the Gradle steps in both test and build-desktop jobs 2026-03-29 15:41:40 +02:00
Crowdin Bot bc367b2a7b New Crowdin translations by GitHub Action 2026-03-29 13:40:34 +00:00
davotoula 380026c7be optimise imports 2026-03-29 15:38:51 +02:00
davotoula 0b3bce5bc2 fixes for sonar 2026-03-29 15:13:43 +02:00
davotoulaandClaude Opus 4.6 c40c1ed383 perf: convert remaining 12 interpolated log calls to lambda overloads
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 13:07:34 +02:00
davotoulaandClaude Opus 4.6 c17fb4641a feat: add find-non-lambda-logs skill for auditing log call hygiene
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 13:00:21 +02:00
davotoulaandClaude Opus 4.6 6d89de9444 refactor: simplify PlatformLog actuals and convert remaining interpolated log calls to lambdas
- Extract private log() helper in JVM and iOS PlatformLog to reduce
  copy-paste branching
- Fix JVM formatter to be private and non-nullable
- Convert 60+ interpolated Log.w/e/i calls to lambda overloads,
  including hot-path Filter.kt toJson() and LocalCache event processing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:58:11 +02:00
davotoulaandClaude Opus 4.6 3b81731771 perf: convert 194 interpolated Log.d() calls to lambda overloads
Defers string construction until after the level check, avoiding
allocation when debug logging is filtered in release/benchmark builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:58:10 +02:00
davotoulaandClaude Opus 4.6 541b197a64 feat: add throwable support to Log.d/i and inline lambda overloads
- Add optional throwable parameter to d() and i() across all platforms
- Add inline lambda overloads for all log levels to defer message
  construction, avoiding string allocation when level is filtered
- Restore VoiceMessagePreview to pass throwable directly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:56:18 +02:00
davotoulaandClaude Opus 4.6 250d89ca0a feat: set Log.minLevel in app entry points and migrate android.util.Log usages
- Set Log.minLevel based on BuildConfig.DEBUG in Amethyst.kt init
- Set Log.minLevel = DEBUG in Desktop Main.kt
- Migrate VideoCompressionHelper to quartz Log + LogLevel enum
- Migrate VoiceAnonymizer, VoiceMessagePreview, LiveStatusIndicator to quartz Log

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:56:18 +02:00
davotoulaandClaude Opus 4.6 25b2cb42d0 feat: convert Log from expect/actual to filtered wrapper over PlatformLog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:56:18 +02:00
davotoulaandClaude Opus 4.6 65bc617222 feat: add PlatformLog actual implementations for all platforms
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:55:18 +02:00
davotoulaandClaude Opus 4.6 067938657f feat: add LogLevel enum and PlatformLog expect declaration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:55:18 +02:00
Claude 72106ea478 feat: add NIP-61 Nutzaps protocol support in quartz
Implements NIP-61 (Nutzaps) event types following the NIP-88 structure pattern:
- NutzapEvent (kind 9321): P2PK-locked Cashu token payments with proof, mint URL, unit tags
- NutzapInfoEvent (kind 10019): Replaceable event for recipient preferences (mints, relays, P2PK pubkey)
- NutzapRedemptionEvent (kind 7376): Token redemption history with NIP-44 encrypted content
- TokenEvent (kind 7375): Unspent proof storage with NIP-44 encrypted content

https://claude.ai/code/session_013tQRvZXdyh5VD11xENpNE5
2026-03-29 04:37:54 +00:00
Claude 32324e5bbe feat: Add NIP-60 Cashu wallet support in quartz
Implements the NIP-60 specification for cashu-based wallets with four event types:
- CashuWalletEvent (kind:17375) - replaceable wallet definition with encrypted mints/privkey
- CashuTokenEvent (kind:7375) - unspent cashu proofs with NIP-44 encrypted JSON content
- CashuSpendingHistoryEvent (kind:7376) - transaction history with encrypted tag array
- CashuMintQuoteEvent (kind:7374) - optional mint quote state with NIP-40 expiration

Follows the same structural patterns as the NIP-88 polls implementation.

https://claude.ai/code/session_018UfHPzrQCAFMwB1zB1ftDM
2026-03-29 04:34:41 +00:00
Claude c3447e2a05 feat: Add @Preview annotations to NIP-43 UI components
Adds preview functions for RelayMemberEventCard variants (membership
list, add/remove member, join/leave request) and MembershipActions
states (not member, is member, join sent, leave sent).

https://claude.ai/code/session_01PRqe4bBCb9u62oPh8u9sHx
2026-03-29 04:22:33 +00:00
Claude a462221ca6 refactor: improve URL detector performance and readability
- Replace 6 regex operations in Url.removeDotSegments() with simple
  string operations (startsWith/indexOf) using a when-expression
- Remove regex-based dropLastSegment() in favor of StringBuilder with
  lastIndexOf
- Extract duplicated hex/octal/decimal parsing in DomainNameReader
  into shared parseNumericLiteral() helper
- Deduplicate scheme matching by unifying findValidScheme* methods
  into findSchemeSuffix() using regionMatches (avoids lowercase allocation)
- Extract readPath() validity check into isPathValid() to remove
  duplicated 5-line condition
- Extract trySchemeNoSlashesOrUserPass() from readScheme() to
  eliminate duplicated branch logic
- Restructure readCurrent() with when-expression and extract
  resetDomainCounters() helper

https://claude.ai/code/session_017oGieyaUiLCxehNJ5aMFDK
2026-03-29 04:08:58 +00:00
Claude b1b9707c5f feat: Implement NIP-43 relay access metadata and membership management
Adds full NIP-43 support with Quartz event classes (kinds 13534, 8000,
8001, 28934, 28935, 28936) following the nip88Polls structure pattern.
Includes relay membership list screen with join/leave actions and
NoteCompose rendering for NIP-43 events in the feed.

https://claude.ai/code/session_01PRqe4bBCb9u62oPh8u9sHx
2026-03-29 03:13:56 +00:00
Vitor Pamplona 48da76c6a3 Activates the decryption for all types of files if it matches the decryption url with the cipher info 2026-03-28 22:24:54 -04:00
Vitor Pamplona a096d62cfb Adds relay discovery to the node master rendering list 2026-03-28 22:19:56 -04:00
Vitor Pamplona 8702ec4d96 Adds a scroll to the settings page. 2026-03-28 20:22:16 -04:00
Vitor Pamplona 4b5405c6b7 Dynamically adjust the preferred server to the first one when the list changes and the current default is not in the list of servers. 2026-03-28 20:21:57 -04:00
Vitor Pamplona 589bb041b2 Fixes refactoring issues by Claude. 2026-03-28 19:58:04 -04:00
Vitor Pamplona ff9b619d26 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  fix: keep screen on during PiP playback and survive screen lock
  feat: add RelayDiscoveryEvent renderer for feed display
  feat: add OpenGraph preview to WebBookmark cards
  revert: keep INostrClient/NostrClient naming and restore onEose/isLive
  refactor: simplify NostrClient API for beginner-friendliness
2026-03-28 19:25:29 -04:00
Vitor Pamplona 14ff2fbf8c Sends Accept and Content-Type to the NIP86 request 2026-03-28 19:23:48 -04:00
Vitor Pamplona 7290f16d0d sorts pubkeys in the relay management and fixes issues on duplicated keys for the lazy column 2026-03-28 19:23:23 -04:00
Vitor PamplonaandGitHub 567cfa33e8 Merge pull request #2000 from vitorpamplona/claude/add-relay-discovery-events-NqmlS
Add relay discovery event rendering UI component
2026-03-28 19:12:41 -04:00
Vitor PamplonaandGitHub 31adfe80ff Merge pull request #1999 from vitorpamplona/claude/pip-wake-lock-investigation-XhK3D
Fix PiP video playback: keep screen on and improve lifecycle
2026-03-28 19:12:05 -04:00
Vitor Pamplona dbd0fe03fa Adds another test case for NIP-11 2026-03-28 18:42:04 -04:00
Claude 1514de53c7 fix: keep screen on during PiP playback and survive screen lock
PiP video now keeps the screen on while playing (matching YouTube behavior)
and no longer kills the activity when the screen locks, allowing audio to
continue through the PlaybackService.

https://claude.ai/code/session_01QJzmFaabSA9YQ3oCQwEHTJ
2026-03-28 22:40:03 +00:00
Claude 0adea4f40a feat: add RelayDiscoveryEvent renderer for feed display
Add a composable to render NIP-66 RelayDiscoveryEvent (kind 30166)
in the note feed, displaying relay URL, RTT metrics with color-coded
chips, network type, relay type, requirements, supported NIPs,
accepted kinds, topics, and geohash location.

https://claude.ai/code/session_01EsPmtRFQgknkDV6zmKkeS2
2026-03-28 22:36:42 +00:00
Vitor Pamplona 23dcda55d3 Solves crashing when multiple relays with the same url are included in the resulting list. 2026-03-28 18:30:21 -04:00
Vitor PamplonaandGitHub d991af72ff Merge pull request #1986 from vitorpamplona/claude/simplify-nostrclient-api-ZpOTB
Refactor Nostr client API with clearer naming conventions
2026-03-28 18:09:12 -04:00
Vitor PamplonaandGitHub f96e2c02de Merge pull request #1997 from vitorpamplona/claude/add-opengraph-preview-uaOzk
Enhance web bookmarks with URL preview metadata
2026-03-28 18:08:59 -04:00
Vitor Pamplona c596e16182 Improves layout of the Relay Monitor events. 2026-03-28 18:04:42 -04:00
Claude 9c361dbe1e feat: add OpenGraph preview to WebBookmark cards
Fetches URL metadata via the existing UrlCachedPreviewer to display
OG images, fallback titles, and descriptions in the bookmark list.

https://claude.ai/code/session_016igt51hduwQ7bXF9kNNHGD
2026-03-28 21:29:29 +00:00
Vitor Pamplona 485ed871d1 Finishing up the Poll Screen 2026-03-28 17:12:51 -04:00
Vitor Pamplona 20eb95ae57 Removing more warnings 2026-03-28 16:30:00 -04:00
Vitor Pamplona 3c01c17d59 Clearing more warnings 2026-03-28 15:46:59 -04:00
Vitor Pamplona f3cefe0e13 resolving some more warnings 2026-03-28 15:35:45 -04:00
Vitor Pamplona 4db617aeef fixes the need for coroutine scopes 2026-03-28 15:26:29 -04:00
Claude 72bc7e0ed5 style: apply linter formatting to TextFieldState migration
https://claude.ai/code/session_01FDGf1Zi1pVvzFi3JY5agnJ
2026-03-28 19:13:18 +00:00
Claude f997586e4b feat: migrate remaining IMessageField implementors to TextFieldState
Completes the TextFieldState migration for ShortNotePostViewModel,
LongFormPostViewModel, and their associated post screens (GeoHash,
Hashtag, ShortNote, LongForm).

https://claude.ai/code/session_01FDGf1Zi1pVvzFi3JY5agnJ
2026-03-28 19:11:09 +00:00
Claude 1d208edd33 feat: migrate ThinPaddingTextField to BasicTextField(TextFieldState) for GIF keyboard support
Migrates all new post screens from the old BasicTextField(TextFieldValue)
to the new BasicTextField(TextFieldState) API, which properly sets
EditorInfo.contentMimeTypes to enable GIF keyboard input.

Key changes:
- Add TextFieldState-based ThinPaddingTextField overload (keep old for readonly dropdowns)
- Create UrlUserTagOutputTransformation for the new OutputTransformation API
- Create TextFieldState extension functions (insertUrlAtCursor, replaceCurrentWord, currentWord)
- Update IMessageField interface to use TextFieldState + onMessageChanged()
- Migrate all IMessageField implementors: ChannelNewMessageViewModel,
  ChatNewMessageViewModel, NewProductViewModel, NewPublicMessageViewModel,
  CommentPostViewModel, ShortNotePostViewModel, LongFormPostViewModel
- Update PreviewState to accept String instead of TextFieldValue
- Add TextFieldState overload to UserSuggestionState.replaceCurrentWord()
- Update all post screen call sites

https://claude.ai/code/session_01FDGf1Zi1pVvzFi3JY5agnJ
2026-03-28 19:09:38 +00:00
Vitor PamplonaandGitHub c0212c09e5 Merge pull request #1996 from vitorpamplona/claude/migrate-autofill-apis-kQZDc
Replace deprecated autofill API with semantics-based approach
2026-03-28 14:58:16 -04:00
Vitor PamplonaandGitHub 8c1ca8dc8b Merge branch 'main' into claude/migrate-autofill-apis-kQZDc 2026-03-28 14:58:01 -04:00
Vitor PamplonaandGitHub c44f155a82 Merge pull request #1995 from vitorpamplona/claude/migrate-clipboard-manager-QEupN
Update Compose clipboard API from LocalClipboardManager to LocalClipboard
2026-03-28 14:50:19 -04:00
Claude 0eb32ae4fb refactor: migrate LocalClipboardManager to LocalClipboard
Replace deprecated LocalClipboardManager with LocalClipboard across
all 21 files in both amethyst and desktopApp modules.

https://claude.ai/code/session_01FE2NjKxhUYUFRvhyqbYpUH
2026-03-28 18:46:02 +00:00
Vitor PamplonaandGitHub 9b3e77e2d5 Merge pull request #1994 from vitorpamplona/claude/migrate-tabrow-material3-FlqJl
Replace TabRow and ScrollableTabRow with Secondary variants
2026-03-28 14:43:54 -04:00
Claude 61cf21a875 feat: migrate from deprecated LocalAutofillTree to semantics-based Autofill API
Replace manual AutofillNode/LocalAutofillTree/LocalAutofill usage with
the new Modifier.semantics { contentType = ContentType.Password } API
from androidx.compose.ui.autofill in KeyTextField, PasswordField, and
AccountBackupScreen.

https://claude.ai/code/session_018e5E6dFATZTEnGtTZbkKhP
2026-03-28 18:40:29 +00:00
Claude 6a13b2b7b7 feat: migrate TabRow/ScrollableTabRow to Material 3 Secondary variants
Replace deprecated material3 TabRow with SecondaryTabRow and
ScrollableTabRow with SecondaryScrollableTabRow across all 12 files.

https://claude.ai/code/session_01VBDx3qQ8n9AzQJV3dGWYff
2026-03-28 18:40:10 +00:00
Vitor PamplonaandGitHub 72f6e47478 Merge pull request #1993 from vitorpamplona/claude/migrate-context-compat-activity-iAHrz
Replace ContextCompat.startActivity with Context.startActivity
2026-03-28 14:39:21 -04:00
Claude 8f1b10efa6 refactor: replace ContextCompat.startActivity with Context.startActivity
ContextCompat.startActivity is an unnecessary wrapper that just delegates
to Context.startActivity. Call the method directly on the context instead.

https://claude.ai/code/session_019gvXxtttDRZTnSE1x8F2EE
2026-03-28 18:36:14 +00:00
Vitor PamplonaandGitHub 9231fa5457 Merge pull request #1990 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-28 14:35:53 -04:00
Crowdin Bot 496ee8a58c New Crowdin translations by GitHub Action 2026-03-28 18:27:56 +00:00
Vitor Pamplona 7b1c28c4b7 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  increase android CI build to 45 minutes
  New Crowdin translations by GitHub Action
  update translations: CZ, DE, PT, SE
2026-03-28 14:25:44 -04:00
Vitor Pamplona cfa59dab01 removing more warnings 2026-03-28 14:24:13 -04:00
Crowdin Bot 61000e9662 New Crowdin translations by GitHub Action 2026-03-28 17:47:14 +00:00
David KasparandGitHub 7c02dc3d2d Merge pull request #1989 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-28 18:46:43 +01:00
davotoula d707900aa0 increase android CI build to 45 minutes 2026-03-28 18:45:54 +01:00
Crowdin Bot 9c5e5381a4 New Crowdin translations by GitHub Action 2026-03-28 17:43:09 +00:00
davotoula b8b754a698 update translations: CZ, DE, PT, SE 2026-03-28 18:39:09 +01:00
Vitor PamplonaandGitHub aa7f480f14 Merge branch 'main' into claude/simplify-nostrclient-api-ZpOTB 2026-03-28 13:09:52 -04:00
Vitor PamplonaandGitHub e8ba9a2d2f Merge pull request #1987 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-28 12:48:43 -04:00
Crowdin Bot b6184ad075 New Crowdin translations by GitHub Action 2026-03-28 16:46:57 +00:00
Vitor PamplonaandGitHub 352a7f12a2 Merge pull request #1985 from vitorpamplona/claude/fix-quartz-deprecations-Y8BYY
Suppress deprecation warnings and improve code quality
2026-03-28 12:45:25 -04:00
Claude 4be617e64a revert: keep INostrClient/NostrClient naming and restore onEose/isLive
Reverts three naming changes from the previous refactor:
- Interface stays INostrClient (not NostrClient)
- Class stays NostrClient (not DefaultNostrClient)
- onEose stays onEose (not onCaughtUp)
- isLive stays isLive (not isRealTime)

All other renames from the API simplification are preserved:
subscribe, unsubscribe, publish, count, fetchAll, fetchFirst, etc.

https://claude.ai/code/session_01JPcYCcRx5eZN4GvgGxGwbf
2026-03-28 16:43:30 +00:00
Claude 4e2717c5c5 refactor: simplify NostrClient API for beginner-friendliness
Comprehensive rename of the NostrClient relay infrastructure to use
intuitive, self-documenting names instead of protocol-level jargon.

Interface/class renames:
- INostrClient → NostrClient (interface)
- NostrClient → DefaultNostrClient (implementation)
- IRequestListener → SubscriptionListener
- IRelayClientListener → RelayConnectionListener
- IOpenNostrRequest → SubscriptionHandle
- NostrClientStaticReq → StaticSubscription
- NostrClientDynamicReq → DynamicSubscription

Method renames:
- openReqSubscription() → subscribe()
- close(subId) → unsubscribe(subId)
- send() → publish()
- queryCount() → count()
- subscribe/unsubscribe(listener) → addConnectionListener/removeConnectionListener
- renewFilters() → syncFilters()

Callback renames:
- onEose → onCaughtUp
- isLive → isRealTime
- onStartReq → onSubscriptionStarted
- onCloseReq → onSubscriptionClosed

Extension function renames:
- query() → fetchAll()
- downloadFirstEvent() → fetchFirst()
- req() → subscribe()
- reqAsFlow() → subscribeAsFlow()
- reqUntilEoseAsFlow() → fetchAsFlow()
- sendAndWaitForResponse() → publishAndConfirm()
- queryCountSuspend() → count()
- queryCountMergedHll() → countMerged()
- reqBypassingRelayLimits() → fetchAllPages()
- updateFilter() → refresh() on SubscriptionHandle

https://claude.ai/code/session_01JPcYCcRx5eZN4GvgGxGwbf
2026-03-28 16:43:30 +00:00
Claude 610fcd7e0c fix: suppress deprecation and unchecked cast warnings in Quartz internal code
Deprecated APIs in Quartz are kept for library consumers but internal usages
now carry @Suppress annotations so the module builds warning-free. Also
replaces redundant .toInt() on hex Int literals in ChaCha20Core and migrates
deprecated java.net.URL to URI.resolve() in ServerInfoParser.

The spotless license header delimiter is updated to recognize @file: annotations.

https://claude.ai/code/session_01EwS56YAGGnnac5EuwhaUSs
2026-03-28 16:39:51 +00:00
Vitor PamplonaandGitHub 0054c01921 Merge pull request #1984 from vitorpamplona/claude/parallelize-db-tests-I6D3g
Refactor forEachDB to use coroutines with parallel execution
2026-03-28 12:22:02 -04:00
Claude d299b65c8a feat: make forEachDB action suspending, remove redundant runBlocking
Change the action lambda to `suspend (EventStore) -> Unit` so callers
can use suspend functions directly. Remove the now-unnecessary
runBlocking wrapper around delay() in ExpirationTest.

https://claude.ai/code/session_01TQRzVTAXBVWRGstcsapA5F
2026-03-28 16:15:23 +00:00
Claude 0889473c15 feat: parallelize BaseDBTest forEachDB using coroutines
Each EventStore uses an independent in-memory SQLite database, so tests
can safely run concurrently. This launches all 16 DB configurations in
parallel via coroutines on Dispatchers.Default instead of running them
sequentially.

https://claude.ai/code/session_01TQRzVTAXBVWRGstcsapA5F
2026-03-28 16:12:44 +00:00
Vitor PamplonaandGitHub 009326ae0b Merge pull request #1982 from davotoula/fix-for-githooks-in-git-worktree
Handle git worktree where .git is a file, not a directory
2026-03-28 12:08:28 -04:00
davotoula 60686d0fef fix: resolves the .git file in worktrees by reading the gitdir: pointer and using the actual git directory for hooks installation 2026-03-28 16:59:01 +01:00
Vitor Pamplona 4c5606cd8d Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  feat: complete NIP-85 Trusted Assertions implementation in quartz
2026-03-28 11:51:50 -04:00
Vitor Pamplona dd2ec3f05f moves the initialization of the Secp lib to the setup of the test to make sure it doesn't affect the expiration timing tests. 2026-03-28 11:50:43 -04:00
Vitor Pamplona b147e6d4c3 Fixes missing actual 2026-03-28 11:39:25 -04:00
Vitor Pamplona 2745b29bf7 removing some warnings 2026-03-28 11:39:15 -04:00
Vitor PamplonaandGitHub be62ca5374 Merge pull request #1981 from vitorpamplona/claude/implement-nip-85-hBlbp
Add NIP-85 trusted assertions support for events and addressables
2026-03-28 11:36:29 -04:00
Vitor PamplonaandGitHub b51efb5caf Merge branch 'main' into claude/implement-nip-85-hBlbp 2026-03-28 11:35:19 -04:00
Vitor Pamplona 5776e74bb5 Fixing some warnings 2026-03-28 11:33:03 -04:00
Vitor Pamplona eaa45aee05 Fixes constructor issue in this BigDecimal Implementation 2026-03-28 11:31:14 -04:00
Vitor PamplonaandGitHub c11e41529e Merge pull request #1980 from davotoula/toast-for-downloads
Show toast instead of dialog on media download success
2026-03-28 10:36:06 -04:00
davotoula cbf9b31a3f feat: show toast instead of dialog on media download success
Replace the interruptive AlertDialog with a brief Android Toast when
media downloads complete. Errors still show the full dialog for visibility.
2026-03-28 15:04:27 +01:00
Claude d40dccde11 feat: complete NIP-85 Trusted Assertions implementation in quartz
Add full compliance with NIP-85 by implementing all four assertion event
kinds and their associated tag types in the nip85TrustedAssertions package:

- Kind 30382 (User Assertions): Add all 15 missing tag parsers and
  builders (first_created_at, post_cnt, reply_cnt, reactions_cnt, all
  zap/report/topic/active_hours tags) to ContactCardEvent
- Kind 30383 (Event Assertions): New EventAssertionEvent with rank,
  comment_cnt, quote_cnt, repost_cnt, reaction_cnt, zap_cnt, zap_amount
- Kind 30384 (Addressable Event Assertions): New
  AddressableAssertionEvent with same tags as 30383
- Kind 30385 (External ID Assertions): New ExternalIdAssertionEvent
  with rank, comment_cnt, reaction_cnt
- Add ProviderTypes for all new assertion kinds (30383/30384/30385)
- Register all new event kinds in EventFactory
- Add comprehensive tests for all assertion event types

https://claude.ai/code/session_01XZQsSnkHJ6tFiPS7fRLzPJ
2026-03-28 13:59:12 +00:00
Vitor PamplonaandGitHub e3c6c35688 Merge pull request #1973 from vitorpamplona/claude/review-quartz-multiplatform-00Oi0
feat: add linuxX64 KMP target and restructure native source sets
2026-03-28 09:54:11 -04:00
Vitor PamplonaandGitHub 05e7b305a7 Merge pull request #1977 from vitorpamplona/claude/polls-feed-screen-DQ78j
Add Polls feed screen with filtering and discovery support
2026-03-28 09:53:50 -04:00
Vitor PamplonaandGitHub 3b15242a02 Merge pull request #1978 from vitorpamplona/claude/nip-a4-compliance-mftpU
Add NIP-A4 support: k tag in zap requests and e tag filtering
2026-03-28 09:53:15 -04:00
Claude 5db9330306 fix: update test packages after nipA4PublicMessages rename
Move test files from experimental.publicMessages to nipA4PublicMessages
to match the package rename on main.

https://claude.ai/code/session_019JsSsTTXivnNWiAtE5DE1R
2026-03-28 13:52:02 +00:00
Claude 559ad40223 feat: add Polls feed screen with Global/Follows filter
Add a dedicated Polls-only feed screen (NIP-88 kind 1068, non-zap polls)
accessible from the left navigation drawer. The screen includes the
standard top nav filter bar for Global/Follows/etc filtering, reusing
the Discovery follow list settings.

New files:
- PollsFeedFilter: filters LocalCache for PollEvent only
- PollsFilterAssembler + PollsSubAssembler: relay subscriptions
- PollsScreen: main screen with pull-to-refresh feed
- PollsTopBar: top bar with Global/Follows filter spinner
- Relay filter subassemblies for authors, follows, global, hashtag

Wiring:
- Route.Polls added to navigation
- Polls entry in left drawer with ic_poll icon
- pollsFeed added to AccountFeedContentStates
- PollsFilterAssembler added to RelaySubscriptionsCoordinator

https://claude.ai/code/session_013JWrsYpQczmZtpW3WwRRfK
2026-03-28 13:51:51 +00:00
Vitor PamplonaandGitHub 3ad42d2d8b Merge pull request #1979 from vitorpamplona/claude/nip24-compliance-check-WvjhO
Add birthday field support to UserMetadata
2026-03-28 09:48:35 -04:00
Claude b465785c27 feat: NIP-A4 compliance - add k tag to zap requests and enforce e tag prohibition
- Add KindTag to LnZapRequestEvent.create() when zapping events, as required
  by NIP-A4 ("NIP-57 zaps MUST include the k tag to 24")
- Enforce e tag prohibition in all PublicMessageEvent.build() methods by
  stripping any e tags from the tag builder output (NIP-A4: "e tags must not
  be used")
- Add comprehensive tests for both changes

https://claude.ai/code/session_019JsSsTTXivnNWiAtE5DE1R
2026-03-28 13:48:29 +00:00
Vitor Pamplona 9b483f903d Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  feat: implement NIP-32 Labeling protocol in quartz
2026-03-28 09:41:26 -04:00
Vitor Pamplona 0c7e5fddea Fixes test cases 2026-03-28 09:41:09 -04:00
Claude 46fd8c651c feat: add NIP-24 birthday field to UserMetadata
NIP-24 defines an optional birthday object with year, month, and day
fields for kind 0 metadata events. This was the only NIP-24 field
missing from Quartz's UserMetadata model.

https://claude.ai/code/session_01Sbj2DF5XDtEuDCeJ7yR6oS
2026-03-28 13:30:51 +00:00
Vitor PamplonaandGitHub 7e1ff20e6d Merge pull request #1976 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-28 09:30:27 -04:00
Crowdin Bot 89b0064a11 New Crowdin translations by GitHub Action 2026-03-28 13:27:22 +00:00
Vitor PamplonaandGitHub b5a0c8b6d5 Merge pull request #1975 from vitorpamplona/claude/implement-nip-32-gWs2E
Add NIP-32 Labeling support with LabelEvent and tag parsing
2026-03-28 09:25:48 -04:00
Vitor Pamplona 943d46e01a Moves public messages out of experimental 2026-03-28 09:18:34 -04:00
Vitor Pamplona 5d281c34d5 Moves trusted assertions out of experimental 2026-03-28 09:16:44 -04:00
Vitor PamplonaandGitHub bfad08a4a7 Merge pull request #1974 from greenart7c3/claude/add-workflow-permissions-OO87O
Add explicit permissions to GitHub Actions workflows
2026-03-28 08:45:08 -04:00
Claude 8cbd11816e ci: add explicit permissions to workflows 2026-03-28 09:32:48 +00:00
Claude aa15085f40 feat: implement NIP-32 Labeling protocol in quartz
Add kind 1985 LabelEvent with L (namespace) and l (label) tag support
for distributed moderation, content classification, and license assignment.
Includes tag parsers, builder extensions, self-reporting helpers, and tests.

https://claude.ai/code/session_011t5ZoP1BdgZTT5Cen9GH5z
2026-03-28 03:57:46 +00:00
Claude 5fc7526076 feat: add linuxX64 KMP target and restructure native source sets
Restructure quartz module's Kotlin/Native source set hierarchy for
proper multiplatform coverage. Adds linuxX64 target with complete
actual implementations for all expect declarations.

Source set hierarchy:
  commonMain
  ├── jvmAndroid → jvmMain, androidMain
  └── nativeMain (shared pure Kotlin)
      ├── appleMain (Apple APIs) → iosMain
      └── linuxMain (POSIX/OpenSSL) → linuxX64Main

nativeMain: Address, OptimizedJsonMapper, EventHasherSerializer,
  ChessEngine, Secp256k1Instance, BitSet, StringExt, io/ utilities

appleMain: Log (NSLog), SecureRandom (SecRandomCopyBytes),
  UriParser (NSURLComponents), BigDecimal (NSDecimalNumber),
  GZip (zlib), Sha256 (CC_SHA256), crypto (Apple provider),
  LargeCache (CacheMap), UrlEncoder, UnicodeNormalizer

linuxMain: Log (println), SecureRandom (/dev/urandom),
  UriParser (pure Kotlin), BigDecimal (pure Kotlin),
  GZip (zlib cinterop), Sha256 (OpenSSL), crypto (OpenSSL),
  LargeCache (AtomicReference<LinkedHashMap>), UrlEncoder

macOS targets deferred pending negentropy-kmp macOS artifacts.
macosMain/Platform.kt retained as scaffold for future activation.

https://claude.ai/code/session_01M9CAuPUV3TfPB2xShnsBhw
2026-03-28 02:55:20 +00:00
Vitor Pamplona 007a0b0d43 More descriptive counters 2026-03-27 21:42:35 -04:00
Vitor Pamplona 391a0bfab2 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  sonar fixes
  update translations: CZ, DE, PT, SE
  New Crowdin translations by GitHub Action
  update video compression library to latest
  update to use new limit short size bit more aggressive compression defaults
  add local maven repo for easier library development
2026-03-27 19:40:04 -04:00
Vitor Pamplona ea8fbd4af9 Making a localhost:3030 string work correctly in the URL Detector 2026-03-27 19:34:55 -04:00
Vitor PamplonaandGitHub 16dd14f403 Merge pull request #1972 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-27 19:30:35 -04:00
Crowdin Bot 8f3c9d2d93 New Crowdin translations by GitHub Action 2026-03-27 23:25:02 +00:00
davotoula 44b8ef5d8f sonar fixes 2026-03-28 00:23:31 +01:00
davotoula c5a88bba97 update translations: CZ, DE, PT, SE 2026-03-28 00:19:38 +01:00
David KasparandGitHub 6a6fadb493 Merge pull request #1971 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-28 00:06:55 +01:00
Crowdin Bot 4d38c1dfb0 New Crowdin translations by GitHub Action 2026-03-27 22:58:31 +00:00
Vitor PamplonaandGitHub da060a27fd Merge pull request #1970 from davotoula/update-video-compression-library
Update video compression library
2026-03-27 18:57:20 -04:00
Vitor Pamplona 49f0ca30b2 Fixes test case 2026-03-27 18:49:12 -04:00
Vitor Pamplona e8c98f31e6 Reverting merge issues 2026-03-27 18:48:29 -04:00
Vitor Pamplona ff8e5dd4e4 Fixes missing strings during merge 2026-03-27 18:43:06 -04:00
Vitor Pamplona e444e59f66 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 NIP-66 monitor reports UI and string resources
  feat: register NIP-66 filter assembler in coordinator
  feat: add NIP-66 relay info subscription composable
  feat: add NIP-66 relay discovery filter sub-assembler
  feat: display NIP-66 relay monitor reports in relay information screen
2026-03-27 18:27:40 -04:00
davotoula bb469700b7 update video compression library to latest 2026-03-27 23:25:55 +01:00
davotoula 3423065b94 update to use new limit short size
bit more aggressive compression defaults
2026-03-27 23:25:55 +01:00
Vitor Pamplona 549baa2ceb Ignore vlc temp files 2026-03-27 17:04:04 -04:00
Vitor Pamplona c068fe38be Fixes tests 2026-03-27 16:36:42 -04:00
davotoula acfb596647 add local maven repo for easier library development 2026-03-27 21:32:32 +01:00
Vitor Pamplona 323694761e Updating dependencies 2026-03-27 16:21:16 -04:00
Vitor PamplonaandGitHub 540194e0ca Merge pull request #1969 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-27 16:18:57 -04:00
Crowdin Bot 6a99210633 New Crowdin translations by GitHub Action 2026-03-27 20:15:23 +00:00
Vitor PamplonaandGitHub d0a5e7d44b Merge pull request #1968 from vitorpamplona/claude/relay-info-nip66-GpWzf
Add relay monitor discovery events display to relay info screen
2026-03-27 16:13:49 -04:00
Vitor PamplonaandGitHub e0c569ea9b Merge pull request #1966 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-27 16:13:04 -04:00
Crowdin Bot 1696ad1248 New Crowdin translations by GitHub Action 2026-03-27 20:04:06 +00:00
Claude 77656ffc84 feat: add NIP-66 monitor reports UI and string resources
Add relay monitor report section to RelayInformationScreen showing
discovery events from multiple NIP-66 monitors. Add string resources
for the monitor reports UI.

https://claude.ai/code/session_01KWuXTp3Z7HST9cu6RWQ4G5
2026-03-27 19:58:26 +00:00
Vitor Pamplona aa3202de4b feat: register NIP-66 filter assembler in coordinator 2026-03-27 15:56:19 -04:00
Vitor Pamplona 3387e119d5 feat: add NIP-66 relay info subscription composable 2026-03-27 15:55:25 -04:00
Vitor Pamplona 0a44bb4904 feat: add NIP-66 relay discovery filter sub-assembler 2026-03-27 15:55:19 -04:00
Vitor Pamplona d65ffd1978 feat: display NIP-66 relay monitor reports in relay information screen
Subscribe to kind 30166 (RelayDiscoveryEvent) events for the relay being
viewed and display monitoring data from multiple providers. Shows RTT
metrics (open/read/write), network type, relay type, supported NIPs,
and access requirements reported by each monitor.

https://claude.ai/code/session_01KWuXTp3Z7HST9cu6RWQ4G5
2026-03-27 15:55:02 -04:00
Vitor Pamplona 4d426a011a Fixes typo 2026-03-27 15:50:15 -04:00
Vitor Pamplona 7ec1ec1a3f Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst: (26 commits)
  feat(cache): seed NotificationsScreen from cache on compose
  feat(cache): wire State classes on DesktopIAccount for GC retention
  feat(cache): extract Kind3FollowListState + Nip65RelayListState to commons
  feat(cache): extract BookmarkListState to commons/commonMain
  feat(cache): add periodic memory cleanup for Desktop
  feat(cache): replace BoundedLargeCache with LargeSoftCache on Desktop
  refactor(cache): move LargeSoftCache to commons/jvmAndroid, fix ICacheProvider types
  feat(cache): seed Reads and Bookmarks screens from cache on compose
  fix(cache): don't reset followersCount to 0 on subscription restart
  feat(cache): cache profile metadata and follower/following counts
  fix(cache): use relayStatuses (available) instead of connectedRelays for subscriptions
  fix(cache): add missing relay subscriptions for FeedScreen and UserProfileScreen
  refactor(cache): P3 simplifications — remove dead code, reduce abstractions
  fix(cache): address review findings — subscription churn, O(n) size, atomicity
  feat(cache): ThreadScreen → DesktopFeedViewModel + DesktopThreadFilter
  feat(ui): relay health indicator in NavigationRail
  feat(cache): UserProfileScreen notes feed → DesktopFeedViewModel
  feat(cache): FeedScreen → DesktopFeedViewModel + Note-based FeedNoteCard
  feat(cache): Coordinator error handling + interaction subscriptions
  feat(cache): kind-based consumer registry + new event types
  ...
2026-03-27 15:47:56 -04:00
Vitor PamplonaandGitHub 787b9ab9db Merge pull request #1905 from nrobi144/feat/desktop-cache-v2
feat(desktop): cache-centric architecture for desktop feeds
2026-03-27 15:45:34 -04:00
Vitor Pamplona 20f8d14d28 Final touches on rendering Zap Goals 2026-03-27 15:43:20 -04:00
Vitor Pamplona 58628dbc12 spotless apply 2026-03-27 15:36:14 -04:00
Vitor PamplonaandGitHub 3a7402a62b Merge pull request #1964 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-27 15:34:28 -04:00
Vitor PamplonaandGitHub fe92deeb53 Merge pull request #1965 from vitorpamplona/claude/nip75-goals-feature-332LM
Add NIP-75 Zap Goals support with creation and rendering
2026-03-27 15:33:57 -04:00
Vitor PamplonaandGitHub 25fee78296 Merge branch 'main' into claude/nip75-goals-feature-332LM 2026-03-27 15:33:50 -04:00
Vitor Pamplona 787eb01c2c Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
  Get rid of libsodium related files.
2026-03-27 15:29:10 -04:00
Vitor Pamplona 1a2e34bdcf Adds a Pin icon to the post instead of making a separate section in user profiles 2026-03-27 15:27:50 -04:00
Crowdin Bot a0bea7602e New Crowdin translations by GitHub Action 2026-03-27 19:19:32 +00:00
Vitor PamplonaandGitHub ddb23118a2 Merge pull request #1963 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-27 15:18:10 -04:00
Vitor PamplonaandGitHub 0a62b898be Merge pull request #1962 from KotlinGeekDev/kmp-completeness
Remove libsodium files.
2026-03-27 15:17:59 -04:00
Crowdin Bot 37dad897cf New Crowdin translations by GitHub Action 2026-03-27 18:59:52 +00:00
Vitor Pamplona b79cd53d34 Finishes vanish final touches. 2026-03-27 14:57:20 -04:00
KotlinGeekDev d7930d4527 Get rid of libsodium related files. 2026-03-27 19:26:17 +01:00
Vitor Pamplona face5ba6e6 Fixes long lists of vanish request relays 2026-03-27 14:18:32 -04:00
Vitor Pamplona 427423edda Adds the user's outbox relays to keep a copy of vanish requests. 2026-03-27 14:18:07 -04:00
Vitor Pamplona 153666b9b4 Improves vanishing tests 2026-03-27 13:59:55 -04:00
Vitor Pamplona 1ece456f41 Uses the new query method from quartz 2026-03-27 12:36:45 -04:00
Vitor Pamplona a456517fbf Offers a new query method from quartz 2026-03-27 12:36:35 -04:00
Vitor Pamplona d3ddb86dbb Allows users to pick relays on the request to vanish screen 2026-03-27 12:13:19 -04:00
Vitor Pamplona 0862be2761 Improvements to the layout of the kinds 2026-03-27 11:21:46 -04:00
Vitor Pamplona cf28e799b7 Better wording 2026-03-27 11:00:47 -04:00
Vitor Pamplona 94c900e98f Moves manage action to the Relay Info header 2026-03-27 10:59:59 -04:00
Vitor PamplonaandGitHub e144eabf96 Merge pull request #1961 from vitorpamplona/claude/pinned-notes-in-tab-8y3TR
Integrate pinned notes into new threads tab
2026-03-27 10:25:26 -04:00
Vitor PamplonaandGitHub e209b9e0cc Merge pull request #1960 from vitorpamplona/claude/pubkey-user-mapping-vWcSL
Display user profiles in relay management pubkey lists
2026-03-27 10:16:12 -04:00
Claude 7618a5039d feat: show pinned notes at top of Notes tab instead of separate tab
Remove the dedicated Pinned Notes tab from the profile and display
pinned notes at the beginning of the Notes tab with a pin icon marker.

https://claude.ai/code/session_01ChamaudsEwwQ8mNtqnx4ze
2026-03-27 14:12:06 +00:00
Claude 9e871c5719 refactor: use flow.map for pubkey-to-user resolution, filter invalid pubkeys
Replace MutableStateFlow caches with Flow.map derived from bannedPubkeys
and allowedPubkeys. Use LocalCache.checkGetOrCreateUser to safely resolve
pubkeys, filtering out invalid ones (null). Remove raw pubkey display
from PubkeyUserCard since invalid pubkeys are already excluded.

https://claude.ai/code/session_018x2PcJX6VGyJmVuphkbK54
2026-03-27 14:08:31 +00:00
Vitor PamplonaandGitHub 9be81ec1a5 Merge pull request #1959 from vitorpamplona/claude/auto-load-opengraph-bookmarks-XGj82
Add Open Graph preview fetching to web bookmarks dialog
2026-03-27 09:57:24 -04:00
Claude 438d7c9122 feat: map pubkeys to users with profile info in relay management
Resolve pubkeys to User objects via LocalCache in RelayManagementViewModel,
caching the mapping. Render banned/allowed pubkeys using SlimListItem with
user picture, name, and NIP-05 verification, with the remove action button
in trailingContent. Falls back to HexEntryCard for unknown pubkeys.

https://claude.ai/code/session_018x2PcJX6VGyJmVuphkbK54
2026-03-27 13:40:29 +00:00
Claude c0a8da0e0c feat: auto-load OpenGraph data when adding web bookmarks
When creating a new web bookmark, automatically fetches OpenGraph
metadata (title, description) after the user leaves the URL field.
Only populates fields that are still empty, preserving any user input.

https://claude.ai/code/session_01RteQotL6WHqJtDr38fUHan
2026-03-27 13:28:41 +00:00
Vitor Pamplona 51c68bfaf1 use Users instead of Pubkeys as name of the tab 2026-03-27 09:22:20 -04:00
Vitor Pamplona 511fcb4c2a Fixes Web Bookmarks floating action button to circle 2026-03-27 08:37:21 -04:00
Vitor PamplonaandGitHub 09e47d4174 Merge pull request #1958 from vitorpamplona/claude/nip62-relay-deletion-EN01M
Add NIP-62 Request to Vanish feature for data deletion
2026-03-27 08:29:51 -04:00
Vitor PamplonaandGitHub 33b91050e1 Merge branch 'main' into claude/nip62-relay-deletion-EN01M 2026-03-27 08:29:43 -04:00
Vitor PamplonaandGitHub 35e1c0b9c0 Merge pull request #1957 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-27 08:27:25 -04:00
Crowdin Bot 74b943336c New Crowdin translations by GitHub Action 2026-03-27 12:22:02 +00:00
Vitor PamplonaandGitHub d121d73bb7 Merge pull request #1954 from vitorpamplona/claude/nip86-relay-management-KuTru
Add NIP-86 relay management UI and client implementation
2026-03-27 08:20:33 -04:00
Vitor PamplonaandGitHub cbb8a345e2 Add NavigateIfIntentRequested to AppNavigation 2026-03-27 08:18:16 -04:00
Vitor PamplonaandGitHub 143d8c30aa Merge branch 'main' into claude/nip86-relay-management-KuTru 2026-03-27 08:17:23 -04:00
Vitor PamplonaandGitHub 515659791b Merge pull request #1955 from vitorpamplona/claude/add-negentropy-support-PcTpR
Add NIP-77 Negentropy set reconciliation protocol support
2026-03-27 08:15:31 -04:00
Vitor PamplonaandGitHub 009b5655f9 Merge branch 'main' into claude/add-negentropy-support-PcTpR 2026-03-27 08:15:24 -04:00
Vitor PamplonaandGitHub bc7a7effb8 Merge pull request #1956 from vitorpamplona/claude/add-pinned-notes-njyQF
Add pinned notes feature with NIP-10001 support
2026-03-27 08:11:50 -04:00
Claude c66d63dbb1 feat: add pinned notes support (NIP-51 kind 10001)
Implement pinned notes using NIP-51 kind 10001 event. This allows users
to pin notes to their profile and view pinned notes on other profiles.

- Rewrite PinListEvent from non-standard kind 33888 to NIP-51 kind 10001
  using e tags and BaseReplaceableEvent
- Add PinListState for reactive pin state management
- Add pin/unpin actions to Account, AccountViewModel, and 3-dot menu
- Add Pinned Notes tab to user profile page
- Add Pinned Notes tab to My Bookmarks screen
- Subscribe to kind 10001 in account metadata and profile relay filters

https://claude.ai/code/session_012GQzb2qcGfAcizC6jqZArD
2026-03-27 03:00:49 +00:00
Claude be297b89b3 feat: Add NIP-75 Zap Goal rendering and creation screen
- Add GoalEvent renderer (Goal.kt) with progress bar showing zap
  funding status, goal image, summary, and closed status
- Add NoteCompose integration so GoalEvents render properly in feeds
- Add NewGoalScreen with form for description, amount, summary,
  image URL, website URL, and optional deadline
- Add NewGoalViewModel handling event creation via GoalEvent.build()
- Add Route.NewGoal and navigation registration
- Add string resources for goal UI

https://claude.ai/code/session_012dS1srrK9LjA4vEZ8WKDEk
2026-03-27 00:16:18 +00:00
Claude 2ed3de8d80 feat: add Vanish History screen with relay compliance testing
Add a new screen that fetches and displays all existing NIP-62 Request
to Vanish events from connected relays. Each entry shows the target
relays and event date. Per-relay "Test" buttons query the relay for
events older than the vanish date to check NIP-62 compliance - if
events are found, the relay is flagged as non-compliant.

https://claude.ai/code/session_019Xrprdfq6pVN8beYrYUSr4
2026-03-26 23:58:10 +00:00
Vitor PamplonaandGitHub 12a51f6e6d Merge pull request #1953 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-26 19:45:10 -04:00
Crowdin Bot dfd959be4e New Crowdin translations by GitHub Action 2026-03-26 23:11:00 +00:00
Vitor Pamplona 75a8e461ad 20x Faster Rfc3986Normalizer and way less objects being created. 2026-03-26 19:04:18 -04:00
Vitor Pamplona 7c8d44559e Benchmark for the new Rfc3986 Normalizer 2026-03-26 17:32:07 -04:00
Vitor Pamplona 395b16050b Try catch to avoid crashes 2026-03-26 17:31:32 -04:00
Vitor Pamplona 4cb5b63631 Speeding up DrawerContent 2026-03-26 15:56:01 -04:00
Vitor Pamplona e92d5c8a5c Simplifies navigation row 2026-03-26 15:48:47 -04:00
Vitor Pamplona 9cbcb3b39d Speedup rendering of the left bar by avoiding a textfield 2026-03-26 15:37:06 -04:00
Vitor Pamplona 85ebb7918b AsyncImage is faster than Image for resources 2026-03-26 15:22:03 -04:00
Vitor Pamplona 5d256bab2c stable subs 2026-03-26 15:21:08 -04:00
Vitor Pamplona 9171209d44 Kickstart LRUCache to void constructor blocking in main thread 2026-03-26 15:20:49 -04:00
Vitor Pamplona 3ecf761d68 Removes logs from animation 2026-03-26 14:37:43 -04:00
Vitor Pamplona 39e89834ba Moves gesture enable to inside the composable.
Creates a function just for navhost
2026-03-26 14:37:16 -04:00
Vitor Pamplona bba4d28e4b stable toast manager 2026-03-26 14:35:31 -04:00
Vitor Pamplona d0a3679778 Minimizes the size of the banner 2026-03-26 14:35:18 -04:00
Vitor Pamplona 423bc04dba Fixes logging 2026-03-26 13:54:46 -04:00
Vitor Pamplona 9f7672249b Otherwise, it is created by the main thread when the AccountViewModel Factory is run 2026-03-26 13:54:33 -04:00
Vitor PamplonaandGitHub 79d66cca8f Merge pull request #1952 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-26 11:41:15 -04:00
Crowdin Bot 7cb9cc6cd5 New Crowdin translations by GitHub Action 2026-03-26 15:37:20 +00:00
Vitor Pamplona 7d6f1a539f 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
  fix: restore local WatchAndDisplayNip05Row in ShowUserSuggestionList
  refactor: remove Namecoin label from search results
  feat: show Namecoin label on .bit search results without ViewModel state
  refactor: remove Namecoin label from search results per review
  refactor: simplify .bit search resolution per review feedback
  feat: resolve bare .bit domains and show Namecoin resolution status in UI
  New Crowdin translations by GitHub Action
  New Crowdin translations by GitHub Action
  refactor: rename trustAllCerts → usePinnedTrustStore
  fix: harden TLS for GrapheneOS and security-conscious Android ROMs
  doc: verify .onion ElectrumX cert via Tor, document pinning
  fix: bypass cert pinning for .onion ElectrumX servers
  feat: TOFU cert pinning for custom ElectrumX servers
  feat: add Test Connection diagnostics to Namecoin settings
  fix: pin ElectrumX server certs instead of trust-all TrustManager

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
2026-03-26 11:34:54 -04:00
Vitor Pamplona 155e57ab11 Defers most module creations to when they are needed via lambdas 2026-03-26 11:11:44 -04:00
Vitor Pamplona 7952f558eb Parallelizes the JSON parsing from stored account. 2026-03-26 11:11:21 -04:00
Vitor Pamplona 958a460ea9 Adds a try/catch to make sure this procedure unsubscribes from the req 2026-03-26 11:10:45 -04:00
Vitor Pamplona 3776f65179 Defers the creation of the geohashStateFlow until needed 2026-03-26 11:08:27 -04:00
Vitor Pamplona 44c39b01ea Defers NIP05 Resolver/NamecoinResolver Builder until needed 2026-03-26 11:07:56 -04:00
Vitor Pamplona d73dd662c6 Reduces calculations and state creations in the NavHost builder 2026-03-26 11:03:07 -04:00
Vitor Pamplona 4ef1ba1ac6 Fixes the bug of not sending NIP-17 reactions in the complete UI mode 2026-03-26 09:25:09 -04:00
Vitor PamplonaandGitHub 276dc6589c Merge pull request #1937 from mstrofnone/fix/samsung-oneui7-namecoin-tls
fix: pin ElectrumX server certs for Samsung One UI 7 (Android 16) compatibility
2026-03-26 09:14:30 -04:00
Vitor PamplonaandGitHub f9c381a113 Merge pull request #1951 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-26 08:38:12 -04:00
Crowdin Bot b77e6c1b6c New Crowdin translations by GitHub Action 2026-03-26 10:57:57 +00:00
davotoula 17eea1dca9 update translations: CZ, DE, PT, SE 2026-03-26 11:53:59 +01:00
M 7293b0985b fix: restore local WatchAndDisplayNip05Row in ShowUserSuggestionList
The local private WatchAndDisplayNip05Row uses NonClickableObserveAndDisplayNIP05
(plain text domain), while the shared one in ShowQRScreen uses the clickable
ObserveAndDisplayNIP05. They are not the same function — restore the local copy
to preserve the correct non-clickable behavior in user suggestions.
2026-03-26 17:36:53 +11:00
nrobi144 9c9c7d3f24 feat(cache): seed NotificationsScreen from cache on compose
- Add localCache param to NotificationsScreen
- Seed reactions and zaps from cache on initial compose
- Notifications now persist across tab navigation
- All screens now have cache seeding (Feed, Thread, Profile,
  Bookmarks, Reads, Notifications)
2026-03-26 07:20:22 +02:00
nrobi144 072e437277 feat(cache): wire State classes on DesktopIAccount for GC retention
- Add BookmarkListState, Kind3FollowListState, Nip65RelayListState
  to DesktopIAccount — pins important AddressableNotes via strong refs
- Create stub Kind3FollowListRepository + Nip65RelayListRepository
  (no persistence yet, null backups)
- Update followingKeySet() to read from Kind3FollowListState.flow
- Revert accidental long-form branch changes in DeckColumnContainer
2026-03-26 07:17:39 +02:00
nrobi144 2b95cc013a feat(cache): extract Kind3FollowListState + Nip65RelayListState to commons
- Add Kind3FollowListState to commons/commonMain with ICacheProvider
  + Kind3FollowListRepository interface for settings needs
- Add Nip65RelayListState to commons/commonMain with ICacheProvider
  + Nip65RelayListRepository interface with default relay sets
- Per-feature repository interfaces match existing EphemeralChatRepository
  / PublicChatListRepository pattern in commons
- Android originals unchanged — Desktop will use commons versions
- Static LocalCache.justConsumeMyOwnEvent calls replaced with cache param
2026-03-26 07:14:16 +02:00
nrobi144 7151d3052a feat(cache): extract BookmarkListState to commons/commonMain
- Move BookmarkListState from amethyst/ to commons/commonMain/
- Change cache param from LocalCache to ICacheProvider
- Android file becomes typealias to commons version
- No settings or decryptionCache dependencies (simplest State class)
- Pins bookmarkList AddressableNote via strong ref for GC retention
2026-03-26 07:14:16 +02:00
nrobi144 cf6a74b162 feat(cache): add periodic memory cleanup for Desktop
- Add cleanMemory() to DesktopLocalCache (sweeps stale WeakRef entries)
- Add startCleanupLoop() to Coordinator with heap monitoring
  (MemoryMXBean, checks every 30s, cleans at >75% heap or every 5min)
- cleanObservers() clears unused NoteFlowSets on notes + addressables
- Try-catch per operation so one failure doesn't skip the rest
- 2-minute startup grace period before first cleanup
- Cleanup job cancelled on clear() (account switch / logout)
2026-03-26 07:14:15 +02:00
nrobi144 3dbbc039b3 feat(cache): replace BoundedLargeCache with LargeSoftCache on Desktop
- DesktopLocalCache now uses LargeSoftCache (WeakReference-based, GC-driven)
  instead of BoundedLargeCache (strong refs, 50k cap, arbitrary eviction)
- Delete BoundedLargeCache.kt — no longer needed
- Rewrite findUsersStartingWith to use forEach instead of values()
- Remove BoundedLargeCache eviction tests (no longer applicable)
- Notes now only disappear when nothing references them, not arbitrarily

Per Vitor's feedback: "this maximum size approach might not work well,
as things will just disappear"
2026-03-26 07:13:27 +02:00
nrobi144 00b06a0e2a refactor(cache): move LargeSoftCache to commons/jvmAndroid, fix ICacheProvider types
- Move LargeSoftCache from amethyst/model to commons/jvmAndroid/model/cache
  so both Android and Desktop share the same WeakReference cache implementation
- Change ICacheProvider return types from Any? to proper types (User?, Note?)
  since these model classes already live in commons
- Remove unnecessary casts in ThreadAssembler, ChatNewMessageState, SearchBarState
- Improve LargeSoftCache.cleanUp() to use single-pass iterator (zero allocation)
- Update Android imports in LocalCache, LargeSoftCacheAddressExt, and test

Per Vitor's feedback on PR #1905: BoundedLargeCache evicts arbitrarily.
LargeSoftCache uses WeakReferences so GC respects the reference graph.
2026-03-26 07:13:27 +02:00
nrobi144andClaude Opus 4.6 3b489fb3cb feat(cache): seed Reads and Bookmarks screens from cache on compose
Both screens now show cached data instantly on navigation instead of
showing loading states while re-fetching from relays.

- BookmarksScreen: reads cached BookmarkListEvent from addressableNotes,
  seeds public bookmark events from notes cache
- ReadsScreen: seeds long-form notes (kind 30023) from notes cache

Relay subscriptions still run to fetch fresh data in parallel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:27 +02:00
nrobi144andClaude Opus 4.6 f27c8811d5 fix(cache): don't reset followersCount to 0 on subscription restart
Cached value now stays visible until relay data exceeds it, preventing
the count jumping from cached→0→ticking back up on each navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:27 +02:00
nrobi144andClaude Opus 4.6 f2169e0236 feat(cache): cache profile metadata and follower/following counts
Profile screen now reads initial values from cache so navigating back
shows data instantly instead of re-fetching everything from scratch.

- Seed displayName/about/picture from User.metadataOrNull() on compose
- Add followerCounts/followingCounts maps to DesktopLocalCache
- Write counts on each update, read on profile open
- Follower count still ticks up live (good UX) but starts from cached value
- 4 new tests for profile count caching + metadata cache reads

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:27 +02:00
nrobi144andClaude Opus 4.6 3e41bab5d5 fix(cache): use relayStatuses (available) instead of connectedRelays for subscriptions
Root cause of "0 relays, 0 notes, 0 follows": all screens used
connectedRelays as the relay set for subscriptions, but NostrClient
uses relay-on-demand — relays only connect when openReqSubscription
is called with their URL. This created a deadlock: screens waited for
connected relays, but relays only connect when screens subscribe.

Fix: use relayStatuses.keys (registered/available relays) which are
populated by addDefaultRelays() at startup. openReqSubscription
triggers connection on-demand.

Affected screens: FeedScreen, UserProfileScreen, ThreadScreen,
NotificationsScreen, BookmarksScreen, ReadsScreen, NewDmDialog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:27 +02:00
nrobi144andClaude Opus 4.6 1e1bc1b172 fix(cache): add missing relay subscriptions for FeedScreen and UserProfileScreen
FeedScreen and UserProfileScreen used DesktopFeedViewModel (cache-backed)
but never subscribed to relays for kind 1/kind 3 events, causing 0 notes,
0 followed, and 0 relays on the home feed.

Adds rememberSubscription calls for:
- Contact list (kind 3) in FeedScreen → populates followedUsers
- Global/following feed (kind 1) in FeedScreen → populates cache
- Profile notes (kind 1) in UserProfileScreen → populates cache

Also adds 33 integration tests covering the full cache pipeline:
- DesktopCachePipelineTest: cache → filter → ViewModel (25 tests)
- CoordinatorPipelineTest: coordinator → cache → ViewModel (8 tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:26 +02:00
nrobi144andClaude Opus 4.6 0621e8f7c2 refactor(cache): P3 simplifications — remove dead code, reduce abstractions
- Replace SubscriptionHealth map + data class with single lastEventAt
  Long? timestamp (only consumer was RelayHealthIndicator taking max())
- Replace consumer HashMap registry with when block (safe casts,
  compiler-checked, 9 kinds doesn't benefit from O(1) lookup)
- Remove dead loadReactionsForNotes() (superseded by requestInteractions)
- Remove unused BoundedLargeCache methods: containsKey, isEmpty,
  mapNotNull, forEach (zero external callers)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:26 +02:00
nrobi144andClaude Opus 4.6 4f5665a2bd fix(cache): address review findings — subscription churn, O(n) size, atomicity
P1 fixes from compound engineering review:
- Fix subscription churn flooding relays: DisposableEffect keyed on
  feedMode/noteId (stable) instead of feedState (changes every 250ms)
- BoundedLargeCache: AtomicInteger counter replaces O(n)
  ConcurrentSkipListMap.size() — eliminates 50K traversal per event
- Non-atomic updateHealth(): use MutableStateFlow.update{} for
  atomic compare-and-set instead of read-modify-write race

P2 fixes:
- DesktopThreadFilter: LinkedHashSet for O(1) containment checks,
  down from O(R²) with MutableList
- consumeContactList: createdAt guard ensures newer events always win
- consumeEvent error logging: include event ID and relay URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:26 +02:00
nrobi144andClaude Opus 4.6 c598c6135c feat(cache): ThreadScreen → DesktopFeedViewModel + DesktopThreadFilter
Replace EventCollectionState + 7 rememberSubscription blocks + inline
count maps with DesktopFeedViewModel + DesktopThreadFilter (graph walk).
Keep root note + replies relay subscriptions to populate cache. Use
FeedNoteCard for both root and reply notes (reads counts from Note.flowSet).
Remove ~200 lines of inline state management (zapsByEvent, reactionIdsByEvent,
replyIdsByEvent, repostIdsByEvent, bookmarkList, bookmark subscription).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:26 +02:00
nrobi144andClaude Opus 4.6 8c1e54e147 feat(ui): relay health indicator in NavigationRail
Add RelayHealthIndicator component — shows elapsed time since last
relay event. Hidden when <30s (healthy), shows "45s ago" / "3m ago"
when stale. Placed above BunkerHeartbeatIndicator in NavigationRail.
Reads from Coordinator's subscriptionHealth StateFlow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:26 +02:00
nrobi144andClaude Opus 4.6 50eb03bc3e feat(cache): UserProfileScreen notes feed → DesktopFeedViewModel
Replace EventCollectionState + createUserPostsSubscription + manual cache
hydration with DesktopFeedViewModel + DesktopProfileFeedFilter. Profile
header subscriptions (metadata, contact list, followers) kept as-is since
they're not feed concerns. DisposableEffect cleanup on profile switch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:26 +02:00
nrobi144andClaude Opus 4.6 c63ace1d9c feat(cache): FeedScreen → DesktopFeedViewModel + Note-based FeedNoteCard
Rewrite FeedScreen from inline EventCollectionState + 8 rememberSubscription()
calls to cache-centric DesktopFeedViewModel pattern:

- ViewModel keyed on feedMode with DisposableEffect cleanup (destroy())
- FeedState pattern matching (Loading/Loaded/Empty/Error)
- FeedNoteCard takes Note instead of Event, reads counts from Note.flowSet
- DisposableEffect for note.clearFlow() cleanup in LazyColumn
- DisposableEffect for Coordinator interaction subscription lifecycle
- LaunchedEffect for rate-limited metadata loading via Coordinator
- Extract FeedHeader into separate composable
- Add destroy() to DesktopFeedViewModel (ViewModel.clear() is internal in KMP)
- Update UserProfileScreen to look up Note from cache for FeedNoteCard

Removes ~350 lines of inline state management (EventCollectionState,
zapsByEvent, reactionIdsByEvent, replyIdsByEvent, repostIdsByEvent,
followedUsers, bookmarkList, 8 rememberSubscription blocks).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:26 +02:00
nrobi144andClaude Opus 4.6 e8ede21c17 feat(cache): Coordinator error handling + interaction subscriptions
Add try-catch in consumeEvent() so one bad event doesn't kill the
pipeline. Add requestInteractions()/releaseInteractions() with Job
tracking via ConcurrentHashMap for screen-scoped subscription lifecycle.
Add SubscriptionHealth tracking (lastEventReceivedAt, eoseReceived)
exposed as StateFlow for UI consumption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:25 +02:00
nrobi144andClaude Opus 4.6 684ba158ab feat(cache): kind-based consumer registry + new event types
Replace when-dispatch with HashMap<Int, Handler> for O(1) event routing.
Add consume methods for: RepostEvent (kind 6), ContactListEvent (kind 3),
LongTextNoteEvent (kind 30023), BookmarkListEvent (kind 30001).
Replace @Volatile followedUsers with MutableStateFlow for thread safety
and Compose observability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:25 +02:00
nrobi144andClaude Opus 4.6 e834dbd1e5 fix(ui): show display names instead of npubs in feed
toNoteDisplayData() now reads User.toBestDisplayName() from cache
instead of always showing npub. Falls back to npub if no metadata.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:25 +02:00
nrobi144andClaude Opus 4.6 e80473a7d9 feat(cache): Phase 3 — route all screen events through cache
All screens now pipe relay events into DesktopLocalCache via
coordinator.consumeEvent(). Existing per-screen EventCollectionState
kept for rendering (incremental migration), but data also enters
cache for persistence across navigation.

- FeedScreen: route global + following feed events
- ThreadScreen: route root note + reply events
- UserProfileScreen: route posts + add cache hydration on mount
  (LaunchedEffect queries cache for existing posts → instant display)
- BookmarksScreen: route public + private bookmark events
- ReadsScreen: route long-form feed events (add coordinator param)
- NotificationsScreen: route notification events

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:25 +02:00
nrobi144andClaude Opus 4.6 c198df57fd feat(cache): Phase 2+3 foundation — FeedFilters + DesktopFeedViewModel
Phase 2: 8 FeedFilter implementations:
- DesktopGlobalFeedFilter (kind 1, limit 2500)
- DesktopFollowingFeedFilter (kind 1 + followed pubkeys)
- DesktopThreadFilter (root + reply graph walk)
- DesktopProfileFeedFilter (notes by pubkey, limit 1000)
- DesktopBookmarkFeedFilter (notes by ID set)
- DesktopReadsFeedFilter (kind 30023, limit 500)
- DesktopNotificationFeedFilter (events tagging user)
- DesktopSearchFeedFilter (content text search)

All use AdditiveFeedFilter for incremental updates (O(batch)
after initial load). Limits are 5x Android's.

Phase 3 foundation: DesktopFeedViewModel subclass that calls
refreshSuspended() on init to load existing cache data —
fixes navigation persistence (back shows instant data).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:12:46 +02:00
nrobi144andClaude Opus 4.6 543f7a329c feat(cache): Phase 1 — cache-centric architecture foundation
- Add BoundedLargeCache: LargeCache wrapper with size enforcement
  (50k notes, 25k users, 10k addressable). Lock-free reads via
  ConcurrentSkipListMap, evicts 10% when cap exceeded.

- Switch DesktopLocalCache from ConcurrentHashMap to BoundedLargeCache.
  Exposes filterIntoSet, values, etc. matching Android's query patterns.

- Add consume methods for kinds 1 (TextNote), 7 (Reaction),
  9734 (ZapRequest), 9735 (Zap). Uses tagsWithoutCitations() for
  reply parsing, originalPost()+taggedAddresses() for reactions,
  zappedPost() for zaps. Follows Android LocalCache pattern.

- Add consumeEvent() bridge in coordinator with BasicBundledInsert
  (250ms batching). Routes relay onEvent callbacks to cache.

- Fix SharedFlow: extraBufferCapacity=64, DROP_OLDEST. Prevents
  blocking emitters and dropped events.

- Set -Xmx2g JVM arg for long desktop sessions.

- Clear cache on logout (coordinator first, then cache).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:12:46 +02:00
nrobi144andClaude Opus 4.6 61119e6916 docs: deepen cache plan — BoundedLargeCache, FeedNoteCard rewrite details
- Resolve LruCache vs LargeCache: use LargeCache (lock-free ConcurrentSkipListMap)
  with BoundedLargeCache wrapper for size enforcement on put()
- Confirm LargeCache available on desktop via quartz jvmAndroid source set
- Detail FeedNoteCard rewrite: Note model field mapping, 5 subscription removals
- Fix filter examples to use filterIntoSet (LargeCache API)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:12:46 +02:00
nrobi144andClaude Opus 4.6 9c160006fa docs: desktop cache architecture brainstorm and plan
Cache-centric architecture for navigation persistence, mirroring
Android Amethyst's pattern. Three-phase incremental migration:
Phase 1 - Store events in DesktopLocalCache with LRU eviction
Phase 2 - Create FeedFilter implementations
Phase 3 - Migrate screens to FeedViewModel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:12:45 +02:00
Claude 2f8e62fda5 feat: implement NIP-86 Relay Management API
Add quartz protocol package for NIP-86 JSON-RPC relay management with
NIP-98 HTTP authorization, and a management UI accessible from the
relay information screen when the relay advertises NIP-86 support.

Quartz (nip86RelayManagement/):
- Nip86Method: all 21 method constants from the spec
- Nip86Request: serializable JSON-RPC request with factory methods
- Nip86Response: response model with typed result parsing helpers
- Nip86Client: builds NIP-98 auth headers, serializes requests,
  parses responses for each result type (pubkeys, events, kinds, IPs)

Amethyst:
- Nip86Retriever: OkHttp-based HTTP executor for NIP-86 calls
- RelayManagementViewModel: state management for all NIP-86 operations
- RelayManagementScreen: tabbed UI (Pubkeys, Events, Kinds, IPs,
  Settings) with add/remove dialogs, moderation queue, and relay
  settings fields
- Route.RelayManagement added to navigation
- "Manage Relay" button shown in RelayInformationScreen top bar when
  relay's supported_nips includes "86"

https://claude.ai/code/session_01QckCAm1T8pJqqmURBiNtaQ
2026-03-26 04:32:43 +00:00
Claude 70baf55e1c feat: add NIP-77 negentropy sync support
Add support for NIP-77 (Negentropy Syncing) protocol messages using
the negentropy-kmp library for efficient set reconciliation between
client and relay.

New files:
- NegOpenCmd, NegMsgCmd, NegCloseCmd: Client-to-relay commands
- NegMsgMessage, NegErrMessage: Relay-to-client messages
- NegentropySession: Client-side reconciliation orchestrator
- NegentropyServerSession: Server-side reconciliation handler
- NegentropyManager: High-level sync manager with listener callbacks

Updated serializers:
- Jackson MessageSerializer/Deserializer for NEG-MSG, NEG-ERR
- Jackson CommandSerializer/Deserializer for NEG-OPEN, NEG-MSG, NEG-CLOSE
- Kotlin MessageKSerializer/CommandKSerializer for all NIP-77 types

Compatible with strfry relay's negentropy implementation via the
negentropy-kmp library which implements the same Protocol V1 spec.

https://claude.ai/code/session_01Dc6W1G1jURAAR9kzyVvk6g
2026-03-26 04:23:32 +00:00
Claude 463938768e feat: add NIP-62 Request to Vanish screen
Add a new screen allowing users to send NIP-62 relay deletion requests.
Users can select a specific relay or ALL RELAYS, pick a date (data
created before that date will be requested for deletion), and provide
an optional reason. Includes confirmation dialog with clear warnings
about the irreversible nature of vanish requests.

https://claude.ai/code/session_019Xrprdfq6pVN8beYrYUSr4
2026-03-26 04:18:39 +00:00
M ebbcfa26fb refactor: remove Namecoin label from search results
Per review: .bit resolution is just NIP-05, no special labeling needed.
2026-03-26 10:25:31 +11:00
M e7cde18371 feat: show Namecoin label on .bit search results without ViewModel state
Derive the label from the existing searchTerm — if it ends with .bit,
show a chain-emoji label above each user result. No new StateFlow or
ViewModel state management needed.
2026-03-26 09:49:34 +11:00
M 1ca904b415 refactor: remove Namecoin label from search results per review
Remove namecoinResolvedUser StateFlow and the chain-emoji label
from search results. .bit resolution still works through the
existing NIP-05 path — just no special UI decoration.
2026-03-26 09:37:56 +11:00
Vitor Pamplona e43047c027 removes warnings 2026-03-25 18:33:03 -04:00
M 67fd48c5b8 refactor: simplify .bit search resolution per review feedback
Address @vitorpamplona's review comments on the last commit:

1. Remove NamecoinResolutionState sealed class and status banners
   — the existing NIP-05 resolver already handles .bit, no need for
   separate Namecoin-specific UI state (spinner, chain emoji, error)

2. Remove separate toNip05IdOrNull() resolution path
   — widen the existing `if (term.contains('@'))` gate in both
   SearchBarViewModel and UserSuggestionState to also accept bare
   .bit domains (synthesize _@domain.bit → existing NIP-05 flow)

3. Remove d/ and id/ identifier support from search
   — per feedback, only NIP-05 style resolution (.bit) in search

4. Remove special Namecoin result rendering in ImportFollowList
   — just show UserLine for all results, no separation needed

5. Keep a lightweight 'Namecoin' label on resolved users in search
   — namecoinResolvedUser StateFlow tracks the .bit-resolved user,
   shown as a small label above the UserCompose row

Net: -405 lines, +50 lines across 6 files.
2026-03-26 08:43:39 +11:00
M 0d83af8912 feat: resolve bare .bit domains and show Namecoin resolution status in UI
Widen the existing NIP-05 resolution gate in UserSuggestionState and
SearchBarViewModel to also accept bare .bit domains and d//id/ Namecoin
identifiers. A synthesised Nip05Id is passed to the existing
nip05Client.get() path — which already routes .bit to the
NamecoinNameResolver — so no separate resolution logic is needed.

Add NamecoinResolutionState (Idle/Resolving/Resolved/Error) as a
StateFlow so the UI can show progress. ImportFollowListSelectUserScreen
and SearchScreen display an animated status banner (spinner while
resolving, chain emoji on success, warning on error) and label
Namecoin-resolved profiles in the results list.
2026-03-26 07:40:25 +11:00
Vitor PamplonaandGitHub 46b75abc81 Merge pull request #1950 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-25 16:20:00 -04:00
Crowdin Bot 618edf9dd5 New Crowdin translations by GitHub Action 2026-03-25 20:17:28 +00:00
Vitor PamplonaandGitHub 0de01ca295 Merge pull request #1949 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-25 16:16:54 -04:00
Vitor Pamplona 2e9beacb13 no message 2026-03-25 16:14:20 -04:00
Crowdin Bot 9db9283952 New Crowdin translations by GitHub Action 2026-03-25 20:07:46 +00:00
Vitor PamplonaandGitHub 452d2accc9 Merge pull request #1948 from vitorpamplona/claude/add-web-bookmarks-qYwpU
feat: implement NIP-B0 Web Bookmarking (kind 39701)
2026-03-25 16:06:09 -04:00
Vitor Pamplona bf8791088c Trying to resolve the build issue between spotless and vlcSetup without running vlcSetup when applying spotless from Claude. 2026-03-25 16:05:29 -04:00
Vitor PamplonaandGitHub 15ccdf0c73 Merge pull request #1938 from vitorpamplona/claude/organize-favorite-relay-feeds-DL3o2
feat: separate favorite relay feeds into own category in nav popup
2026-03-25 15:44:59 -04:00
Vitor PamplonaandGitHub c903996cc2 Merge pull request #1939 from vitorpamplona/claude/nip89-client-tag-LZYhd
feat: NIP-89 client tag on all signed events
2026-03-25 15:42:31 -04:00
Vitor Pamplona 052fa22d59 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  New Crowdin translations by GitHub Action
2026-03-25 15:38:16 -04:00
Vitor Pamplona e90945d201 Removing the solution to a warning about spotless dependencies because claude can't run the vlc step 2026-03-25 15:34:32 -04:00
M 2e02edc333 refactor: rename trustAllCerts → usePinnedTrustStore
The field no longer means 'trust all certificates' — since the Samsung
One UI 7 fix, it means 'use the pinned trust store (hardcoded +
TOFU-pinned certs + system CAs) instead of system-only trust.'

The old name was actively misleading and could cause a future
contributor to interpret the flag literally, potentially reintroducing
the trust-all pattern that Samsung Knox and GrapheneOS reject.

Renamed across all 4 files:
- ElectrumxServer.kt: field definition + updated KDoc
- ElectrumXClient.kt: createSocket() usage + comments
- NamecoinSettings.kt: parseServerString() usage + comments
- NamecoinSettingsTest.kt: test assertions
2026-03-26 06:33:43 +11:00
Claude cbee188ca5 feat: implement NIP-B0 Web Bookmarking (kind 39701)
Add support for NIP-B0 web bookmarks with a complete protocol
implementation in Quartz and a full UI in Amethyst for adding,
editing, browsing, and deleting web bookmarks.

Quartz:
- WebBookmarkEvent (kind 39701) as an addressable event
- Tag builder/parser extensions for title, published_at, hashtags
- Registered in EventFactory

Amethyst:
- WebBookmarksScreen with FAB to add, and per-card edit/delete/open
- WebBookmarkEditDialog for add/edit with URL, title, description, tags
- WebBookmarkFeedFilter querying addressable notes by kind + pubkey
- Account.sendWebBookmark() and Account.deleteWebBookmark()
- LocalCache.consume() for WebBookmarkEvent
- Drawer navigation entry with Language icon
- Route.WebBookmarks and navigation registration

https://claude.ai/code/session_01UzfLJttwuJzovtb8HX5F9n
2026-03-25 19:29:55 +00:00
M 7f451ee7c7 fix: harden TLS for GrapheneOS and security-conscious Android ROMs
Address compatibility and security issues identified by reviewing
GrapheneOS source (hardened Conscrypt, strict TLS enforcement):

1. .onion: prefer pinned factory, fall back to trust-all

   GrapheneOS patches Conscrypt with stricter TLS enforcement that may
   reject no-op X509TrustManagers even for proxied sockets. The .onion
   server's cert is already in PINNED_ELECTRUMX_CERTS (same operator as
   electrumx.testls.space), so we now use cachedPinnedSslFactory() as
   the primary path. onionSslFactory() (trust-all) is kept as a fallback
   on SSLHandshakeException only — this handles cert rotation or unknown
   .onion servers gracefully.

2. TOFU: require explicit user confirmation before pinning

   Previously, Test Connection auto-pinned every cert on success. An
   attacker performing MITM during first test would get their cert
   permanently trusted. Now each new cert triggers an AlertDialog showing
   the full SHA-256 fingerprint. Users must explicitly accept ('Trust')
   or reject each cert — aligning with GrapheneOS's philosophy of
   explicit trust decisions.

3. Clarify trustAllCerts semantics

   The field name is misleading post-refactor: it now means 'use pinned
   trust store' not 'trust all certificates'. Added TODO to rename to
   usePinnedTrustStore and updated inline comments to prevent future
   misinterpretation.

Note: hostname verification on raw SSLSocket is a pre-existing gap
(not introduced by the Samsung fix PR) — SSLSocket.createSocket() uses
the host parameter for SNI only, not hostname verification. A follow-up
should add endpointIdentificationAlgorithm='HTTPS' or fingerprint-based
verification for pinned certs.
2026-03-26 06:26:18 +11:00
Vitor PamplonaandGitHub 138428a233 Merge pull request #1947 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-25 15:24:15 -04:00
Crowdin Bot fd86e3c045 New Crowdin translations by GitHub Action 2026-03-25 19:23:05 +00:00
Vitor Pamplona 283fb96bb7 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst:
  reuse hex methods.
  spotless
  delete voice files on failuer
  added progress and test plan to doc
  fix: delete abandoned compressed video when larger than original fix: eagerly delete intermediate temp files in upload pipeline fix: delete temp file from MediaCompressorFileUtils after image compression refactor: simplify temp file cleanup logic - Remove TOCTOU anti-pattern (file.exists() before file.delete()) - Consolidate double deleteTempUri calls into single conditional - Remove restating comments
  analysis for temporary files
  fix: clean up temp files after upload completes
  perf: optimize ChaCha20 and XChaCha20-Poly1305 for speed
  style: apply spotless formatting to ChaCha20/Poly1305 sources
  feat: replace libsodium with pure Kotlin ChaCha20/Poly1305 implementation
2026-03-25 15:20:28 -04:00
Vitor PamplonaandGitHub 6db28dca0b Merge pull request #1908 from vitorpamplona/claude/remove-libsodium-dependency-DjtWa
Replace libsodium with pure Kotlin XChaCha20-Poly1305 implementation
2026-03-25 13:53:48 -04:00
Vitor Pamplona 2474ba1713 Fixes anon accounts becoming empty bubbles in the notifiy section of new posts. 2026-03-25 13:53:06 -04:00
Vitor Pamplona fc560d2752 reuse hex methods. 2026-03-25 13:22:02 -04:00
Vitor PamplonaandGitHub 2ead6645c0 Merge pull request #1925 from vitorpamplona/claude/review-cache-cleanup-Yr2P6
Add cleanup of temporary files in upload pipeline
2026-03-25 13:20:52 -04:00
Vitor PamplonaandGitHub 138bb144ee Merge branch 'main' into claude/review-cache-cleanup-Yr2P6 2026-03-25 13:20:30 -04:00
Vitor Pamplona e2ac534be0 spotless 2026-03-25 12:55:31 -04:00
davotoula aa5dad9fac delete voice files on failuer 2026-03-25 17:42:05 +01:00
Vitor PamplonaandGitHub 0d92c86459 Merge branch 'main' into claude/remove-libsodium-dependency-DjtWa 2026-03-25 11:50:07 -04:00
Vitor Pamplona 9e9f6c3164 Completing the transition from NIP-44 architecture specific tests to commontTest 2026-03-25 11:48:37 -04:00
Vitor PamplonaandGitHub d84d146981 Merge branch 'main' into claude/remove-libsodium-dependency-DjtWa 2026-03-25 11:24:55 -04:00
Vitor Pamplona 617db3613f Re-enables benchmark with native android ChaCha functions 2026-03-25 11:17:17 -04:00
Vitor Pamplona 60a44d25eb 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: implement NIP-45 HyperLogLog for probabilistic event counting
2026-03-25 10:55:12 -04:00
Vitor Pamplona 2ffb920f75 Fixes NIP-46 decoding test 2026-03-25 10:51:31 -04:00
Vitor PamplonaandGitHub f8baf83146 Merge pull request #1941 from vitorpamplona/claude/implement-hyperloglog-nip45-Jai8I
feat: implement NIP-45 HyperLogLog support
2026-03-25 09:34:35 -04:00
Vitor PamplonaandGitHub 08090f57b5 Merge pull request #1945 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-25 09:34:18 -04:00
Crowdin Bot 19ef2ca041 New Crowdin translations by GitHub Action 2026-03-25 13:30:02 +00:00
Vitor PamplonaandGitHub 3a21396838 Merge pull request #1936 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-03-25 09:28:32 -04:00
Crowdin Bot 0705d070bd New Crowdin translations by GitHub Action 2026-03-25 12:40:37 +00:00
Vitor Pamplona d6541e37c3 merge 2026-03-25 08:37:18 -04:00
Vitor Pamplona 7583f7f8e1 Moves some of the nip-44 based tests to commonTest 2026-03-25 08:33:04 -04:00
Vitor Pamplona c654d2267c Protects against crashes when the signer sends an unverifiable payload back to Amethyst 2026-03-25 08:28:31 -04:00
Vitor PamplonaandGitHub 0a0b6aefa0 Merge pull request #1943 from dadofsambonzuki/attestation-fix
Align attestation events with latest NIP format
2026-03-25 07:49:07 -04:00
Vitor PamplonaandGitHub a1ad460b90 Merge pull request #1944 from KotlinGeekDev/kmp-completeness
Kmp completeness: update feature parity table.
2026-03-25 07:47:38 -04:00
Vitor PamplonaandGitHub 9f6684b044 Merge pull request #1942 from nrobi144/features/long-form-content
feat(desktop): highlight UX, shared stores, profile tabs, editor toolbar
2026-03-25 07:47:21 -04:00
KotlinGeekDev ac60cbeb02 Update Quartz feature parity table. 2026-03-25 12:42:41 +01:00
Nathan Day 92ae288720 Align attestation tags with latest NIP format 2026-03-25 11:30:29 +00:00
Nathan DayandGitHub 0b049c656c Merge branch 'vitorpamplona:main' into main 2026-03-25 11:01:43 +00:00
davotoula 42db03790a added progress and test plan to doc 2026-03-25 10:23:38 +01:00
davotoula 60b7c6bd0d fix: delete abandoned compressed video when larger than original
fix: eagerly delete intermediate temp files in upload pipeline
fix: delete temp file from MediaCompressorFileUtils after image compression
refactor: simplify temp file cleanup logic
- Remove TOCTOU anti-pattern (file.exists() before file.delete())
- Consolidate double deleteTempUri calls into single conditional
- Remove restating comments
2026-03-25 10:19:16 +01:00
davotoula 390edf311d analysis for temporary files 2026-03-25 09:29:48 +01:00
davotoula 26fb6fead4 Merge branch 'main-upstream' into claude/review-cache-cleanup-Yr2P6 2026-03-25 09:18:53 +01:00
nrobi144andClaude Opus 4.6 e037254b39 fix(highlights): shared store, context menu, link rendering, publish, profile tabs
- Share DesktopHighlightStore and DesktopDraftStore at app level instead
  of per-column to fix cross-deck reactivity and draft persistence
- Replace broken clipboard-based highlight creation with
  LocalContextMenuRepresentation that piggybacks on Copy to get selected text
- Render highlights as highlight:// links instead of bold/italic markers
- Add collapsible highlights panel in article reader with edit/delete/publish
- Publish highlights to relays as NIP-84 events via HighlightPublishAction
- Add Reads tab (kind 30023) and Highlights tab (kind 9802) to profile
- Rewrite MarkdownToolbar with MarkdownEditorState for selection-aware
  toggle, Material icons, and keyboard shortcuts (Cmd+B/I/E/K)
- Wire DraftsScreen "New Draft" button to ArticleEditorScreen navigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 06:59:41 +02:00
Claude 403351610b feat: implement NIP-45 HyperLogLog for probabilistic event counting
Add client-side HyperLogLog support as specified by NIP-45:
- HyperLogLog object with merge, estimate, encode/decode, and
  computeOffset for deterministic filter-based offset calculation
- Add hll ByteArray field to CountResult for relay HLL data
- Update CountResultKSerializer to serialize/deserialize hll hex field
- Add queryCountMergedHll extension for multi-relay HLL merging
- 20 unit tests covering HLL operations and serialization round-trips

https://claude.ai/code/session_01CQzygEkwckLCiXzSRPMpBa
2026-03-25 03:43:47 +00:00
Claude 36af9e2346 feat: add NIP-89 client tag to all signed events via NostrSignerWithClientTag decorator
Introduces a signer decorator that automatically appends the NIP-89
client tag to every event before signing, ensuring all events carry
client identification without modifying individual event creation sites.
Works transparently with internal, external (NIP-55), and remote (NIP-46) signers.

https://claude.ai/code/session_01WRtuk1ySCfqXzfFrieKsDs
2026-03-25 03:24:49 +00:00
Claude c3c9bd016e feat: separate favorite relay feeds into their own category in nav popup
Relay feeds were previously grouped under the generic "Feeds" category
in the top nav bar filter dialog. This adds a dedicated "Relays" group
so users can more easily find their favorite relay feeds.

https://claude.ai/code/session_016gjbPgYRXrQCoY1zKPz5PX
2026-03-25 02:59:40 +00:00
M a4953f628d doc: verify .onion ElectrumX cert via Tor, document pinning
Connected to the .onion hidden service via Tor SOCKS proxy and
confirmed it serves the SAME certificate as electrumx.testls.space:
  SHA-256: 53:65:D5:BB:26:19:F5:40:1C:D8:8E:FC:AF:FB:A5:B2:...

The .onion cert is already covered by the first pinned cert entry.
Added comments documenting this relationship and instructions for
fetching .onion certs via Tor for future updates.

The .onion still uses onionSslFactory() (trust-all) as the primary
path — this is correct since Tor provides its own authentication.
The pinned cert serves as defense-in-depth.
2026-03-25 12:13:49 +11:00
M 3afe0c9978 fix: bypass cert pinning for .onion ElectrumX servers
Tor hidden services are authenticated by their onion address (the
public key hash), making TLS certificate verification redundant.

The .onion server in TOR_ELECTRUMX_SERVERS was going through
pinnedSslFactory() but its cert isn't in the pinned list (and can't
be fetched without Tor). This would cause .onion connections to fail
with SSLHandshakeException when Tor mode is active.

Fix: .onion addresses now use a dedicated onionSslFactory() with
trust-all, which is safe because:
1. Tor provides end-to-end encryption
2. The onion address IS the server identity proof
3. Samsung Knox trust-all rejection doesn't apply to proxied sockets
2026-03-25 12:10:45 +11:00
M b8b51db0b5 feat: TOFU cert pinning for custom ElectrumX servers
When a user adds a custom ElectrumX server and runs Test Connection,
the server's TLS certificate is automatically captured and pinned
(Trust On First Use). This allows custom servers with self-signed
certificates to work on Samsung/Xiaomi/OnePlus devices.

Changes:
- ElectrumXClient: addPinnedCert(), setDynamicCerts() for runtime
  cert management; testServer() now captures server cert PEM and
  SHA-256 fingerprint from SSL session
- NamecoinSharedPreferences: persist pinned certs to DataStore
- AppModules: load pinned certs on startup, sync to ElectrumXClient
- NamecoinSettings: custom servers always set trustAllCerts=true
  (ElectrumX servers almost universally use self-signed certs)
- UI: shows cert fingerprint in test results, auto-pins on success
- ServerTestResult: new serverCertPem + certFingerprint fields

Flow: Add server → Test Connection → cert auto-captured and pinned →
future connections trust that cert even on Samsung Knox devices.
2026-03-25 12:05:05 +11:00
Vitor Pamplona f42881a48e v1.06.3 2026-03-24 20:55:04 -04:00
Vitor Pamplona 6fc72f47bb Highlights when arriving at the reply message 2026-03-24 20:53:22 -04:00
M 49698de99c feat: add Test Connection diagnostics to Namecoin settings
Add per-server connection testing with detailed error reporting:
- Test Connection button tests each ElectrumX server individually
- Shows streaming results:  success with response time,  failure
  with human-readable error (TLS handshake failed, connection refused,
  timeout, DNS failed, invalid response, etc.)
- Diagnostic card shows: last test timestamp + success count, device
  info (manufacturer/model/Android/API), TLS version negotiated
- ElectrumXClient.testServer() method for single-server diagnostics
  with TLS version capture from SSL session

This gives users (and bug reporters) immediate visibility into why
Namecoin resolution may be failing silently on their device.
2026-03-25 11:52:05 +11:00
Vitor Pamplona 041c788e21 Improvements to anonymous replies 2026-03-24 20:42:06 -04:00
M 15a2d77a0f fix: pin ElectrumX server certs instead of trust-all TrustManager
Samsung One UI 7 (Android 16) silently rejects TLS connections that use
a no-op X509TrustManager which accepts all certificates. This breaks
Namecoin resolution on all Samsung devices running One UI 7, including
Galaxy A15 (SM-A156E) and Galaxy S24 Ultra (SM-S938B).

Replace trustAllSslFactory() with pinnedSslFactory() that:
- Pins the actual self-signed certificates of known ElectrumX servers
- Also includes system CA certificates for servers with real certs
- Uses a proper TrustManagerFactory chain that Samsung Knox accepts

Additional OEM compatibility hardening:
- Cache the SSLSocketFactory (avoid expensive rebuild per connection)
- Request TLSv1.2 explicitly (Xiaomi MIUI/HyperOS and OnePlus ColorOS
  Conscrypt forks may default to TLS 1.0 for raw socket upgrades)
- Enforce TLSv1.2+ enabled protocols on the SSLSocket
- KeyStore fallback to PKCS12 if default type fails (Xiaomi)
- Defensive try/catch on system CA cert re-insertion (some OEMs return
  certs that cannot be added to a new KeyStore)

Tested on Android 16 (API 36) emulator — all ElectrumX servers connect
(hostname, IP-address, factory reuse) and .bit resolution works E2E.

Pinned certs:
- electrumx.testls.space:50002 (expires 2027-05-04)
- nmc2.bitcoins.sk:57002 (expires 2030-10-22)
2026-03-25 11:41:12 +11:00
nrobi144andClaude Opus 4.6 efa294ea72 feat(highlights): add article highlights and note-taking system
Phase 1: HighlightData model + DesktopHighlightStore (Preferences-based)
Phase 2: Text selection highlight via Cmd+H/Cmd+Shift+H, inline bold/italic
         rendering, HighlightAnnotationDialog, SelectionContainer wrapping
Phase 3: MyHighlightsScreen with grouped view, HighlightPublishAction for
         NIP-84 (kind 9802), deck/sidebar/menu wiring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:16:54 +02:00
nrobi144andClaude Opus 4.6 c75176891f feat(reads): integrate long-form into deck layout with zoom and reactions
- Wire Article, Editor, Drafts into DeckColumnType + DeckColumnContainer
- Add Drafts to sidebar nav, Add Column dialog, and Window menu
- Add article navigation (onNavigateToArticle) in SinglePaneLayout
- Add NoteActionsRow to ReadsScreen LongFormCards (zaps, reactions, replies)
- Add Cmd+/Cmd- zoom in ArticleReaderScreen via fontScale on RenderMarkdown
- Add kind 30023 to ProfileSubscription for long-form in user profiles
- Add fontScale param to RenderMarkdown using scaled LocalDensity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:18:37 +02:00
nrobi144andClaude Opus 4.6 328fcf86d7 refactor(reads): address all review findings — security, performance, simplicity
P1 Critical:
- Add 300ms debounce on editor preview to prevent AST re-parse per keystroke
- Hoist Regex constants in ReadingTimeCalculator and TableOfContents
- Add URI scheme allowlist to editor onLinkClick (was missing vs reader)
- Add MAX_CONTENT_BYTES (100KB) validation before publish

P2 Important:
- Delete MarkdownSpikeScreen (278 LOC spike artifact)
- Delete HighlightCreator (95 LOC, zero callers — YAGNI)
- Delete DraftLongTextNoteEvent (162 LOC, no consumers — YAGNI)
- Replace ArticleMediaRenderer interface with onLinkClick lambda
- Extract inline subscription to FilterBuilders.longFormByAddress()
- Replace SimpleDateFormat with thread-safe DateTimeFormatter
- Remove dead placeholders (bookmark button, reactions row, unused param)
- Cache draft index in memory to avoid redundant disk reads
- Add TODO for publish-before-relay-ack issue

P3 Nice-to-have:
- Remove timestamp from subscription subId for stability
- Remove redundant slug ".." removal (regex handles it)
- Add metadata field length limits (title 256, summary 1024)
- Fix identical isMacOS branches in editor
- Validate image URLs (http/https only) in ArticleHeader

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:03:29 +02:00
nrobi144andClaude Opus 4.6 4fcbffdb3b feat(reads): Phase 2 — editor, drafts, publish, highlights
- Create DraftLongTextNoteEvent (kind 30024) in quartz with EventFactory registration
- Create LongFormPublishAction for signing + publishing kind 30023 events
- Create split-pane ArticleEditorScreen with live markdown preview
- Create MarkdownToolbar (bold, italic, heading, link, image, code, quote)
- Create MetadataPanel (title, summary, banner, tags, slug)
- Create DesktopDraftStore with JSON index + .md files, atomic writes, slug sanitization
- Create DraftsScreen with draft list, delete, new draft button
- Create HighlightCreator dialog for kind 9802 highlight creation
- Add Drafts nav rail entry and Editor/Drafts screen routing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:59:52 +02:00
nrobi144andClaude Opus 4.6 5ef36363cd feat(reads): Phase 1 — article reader with markdown, ToC, reading time
- Add richtext-commonmark deps to commons for shared markdown rendering
- Create RenderMarkdown composable with URL scheme allowlist (security)
- Create ArticleMediaRenderer interface for platform-specific media
- Create ArticleHeader with banner, title, author, reading time metadata
- Create TableOfContents with heading extraction and scroll-spy
- Create ReadingTimeCalculator (238 WPM prose, 80 WPM code, image decay)
- Create ArticleReaderScreen with Medium-style typography (680dp, 1.58x)
- Add DesktopScreen.Article navigation with address tag routing
- Update ReadsScreen to navigate via address tags instead of event IDs
- Responsive ToC sidebar (shown when window > 1100dp)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:59:52 +02:00
Claude cba65344b9 fix: clean up temp files after upload completes
MetadataStripper, MediaCompressor, and EncryptFiles create intermediate
temp files in cacheDir during the upload pipeline, but these were never
deleted after the upload finished. Over time this causes unbounded disk
growth. Wrap upload calls in try/finally to ensure all intermediate temp
files (compressed, stripped, encrypted) are deleted regardless of
success or failure.

https://claude.ai/code/session_01YS3dbZ1k84N2EHS3AruYRV
2026-03-24 04:37:41 +00:00
Claude 425fc24bc2 perf: optimize ChaCha20 and XChaCha20-Poly1305 for speed
ChaCha20Core.chaCha20Xor:
- Parse key/nonce once into initial state, reuse across blocks
- XOR at word level (4 bytes at a time) instead of byte-by-byte
- Reuse working IntArray across blocks instead of allocating per block

XChaCha20Poly1305:
- Add hChaCha20FromNonce24 to avoid nonce copyOfRange(0,16) allocation
- Add chaCha20PolyKey to generate only 32 bytes instead of full 64-byte block
- Use single result array instead of ciphertext + tag concatenation

Benchmark (98-byte padded message, JVM):
  Before: encrypt 2286 ns/op, decrypt 2062 ns/op
  After:  encrypt 1544 ns/op, decrypt 341 ns/op

https://claude.ai/code/session_01YHBwqnb3Z7Q7PX31tJ2G3i
2026-03-24 02:35:30 +00:00
Claude 2e1bcf3e52 style: apply spotless formatting to ChaCha20/Poly1305 sources
https://claude.ai/code/session_01YHBwqnb3Z7Q7PX31tJ2G3i
2026-03-23 17:57:31 +00:00
Claude d586a0dc25 feat: replace libsodium with pure Kotlin ChaCha20/Poly1305 implementation
Remove LazySodium/JNA/libsodium native dependencies and replace with
pure Kotlin implementations of ChaCha20, HChaCha20, XChaCha20,
Poly1305 MAC, and XChaCha20-Poly1305 AEAD. This eliminates platform-
specific native binaries while maintaining full NIP-44 compatibility.

- Add ChaCha20Core with RFC 8439 block function, IETF stream XOR,
  HChaCha20, and XChaCha20
- Add Poly1305 MAC (RFC 8439 §2.5)
- Add XChaCha20Poly1305 AEAD encrypt/decrypt
- Convert LibSodiumInstance from expect/actual to commonMain object
- Delete platform-specific LibSodiumInstance actuals (android/jvm/ios)
- Remove iOS native interop (cinterop, linker opts, .a libraries)
- Remove lazysodium-java, lazysodium-android, JNA from build deps
- Clean up ProGuard rules (remove JNA/LazySodium keep rules)
- Add RFC 8439 + libsodium test vectors for ChaCha20 and XChaCha20
- Update benchmark to use simplified ChaCha20 API

https://claude.ai/code/session_01YHBwqnb3Z7Q7PX31tJ2G3i
2026-03-23 17:40:29 +00:00
Nathan DayandGitHub 4491f3aedb Merge pull request #1 from dadofsambonzuki/fix/dm-swipe-back-signing
Fix swipe-back triggering unwanted draft deletion signing
2026-03-16 12:25:52 +00:00
967 changed files with 45363 additions and 6663 deletions
+2 -2
View File
@@ -745,8 +745,8 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15
versionCode = 434
versionName = "1.06.2"
versionCode = 438
versionName = "1.07.2"
vectorDrawables {
useSupportLibrary = true
@@ -0,0 +1,86 @@
---
name: find-non-lambda-logs
description: Use when auditing or migrating Log calls to lambda overloads, after adding new logging, or checking for string interpolation in Log.d/i/w/e calls that waste allocations when the log level is filtered out
---
# Find Non-Lambda Log Calls
## Overview
Locates `Log.d/i/w/e` calls that use string interpolation without the lambda overload, wasting string allocation when the log level is filtered out in release builds.
## When to Use
- After merging branches that add new logging
- Periodic audit of logging hygiene
- After migrating `android.util.Log` usages to the shared `Log` wrapper
## What to Flag
Calls with **string interpolation** (`$` in message) that do **not** pass a throwable:
```kotlin
// FLAG - interpolation without lambda, no throwable
Log.d("Tag", "Processing ${event.id}")
Log.w("Tag", "Failed for $url")
// IGNORE - passes throwable (lambda overload doesn't accept throwable)
Log.w("Tag", "Error: ${e.message}", e)
Log.e("Tag", "Failed for $url", throwable)
// IGNORE - no interpolation (no allocation benefit from lambda)
Log.d("Tag", "Initialization complete")
```
## Search Commands
**Important:** Tags can be string literals (`"Tag"`) or variables (`tag`, `LOG_TAG`). Run both patterns for each step.
### Step 1: Find interpolated Log.d/Log.i (highest priority — filtered in release)
```
pattern: Log\.(d|i)\("[^"]+",\s*"[^"]*\$
type: kotlin
```
```
pattern: Log\.(d|i)\(\w+,\s*"[^"]*\$
type: kotlin
```
### Step 2: Find interpolated Log.w/Log.e without throwable
```
pattern: Log\.(w|e)\("[^"]+",\s*"[^"]*\$
type: kotlin
```
```
pattern: Log\.(w|e)\(\w+,\s*"[^"]*\$
type: kotlin
```
Then **manually exclude** lines where a throwable is passed as third argument (ending with `, e)`, `, throwable)`, etc.). Check the actual line — a catch block catching `e` doesn't mean `e` is passed to the Log call.
### Step 3: Verify no android.util.Log leakage
```
pattern: android\.util\.Log\.(d|i|w|e|v)\(
type: kotlin
```
These bypass the `Log.minLevel` filter entirely. Exclude `PlatformLog.android.kt` which is the wrapper implementation.
## Fix Pattern
```kotlin
// Before
Log.d("Tag", "Processing event ${event.id} from ${relay.url}")
// After
Log.d("Tag") { "Processing event ${event.id} from ${relay.url}" }
```
## Do NOT Convert
- Calls passing a `Throwable` parameter - the lambda overload `(tag) { message }` has no throwable parameter
- Static string calls with no `$` interpolation - no allocation benefit
- Commented-out log calls
+3 -3
View File
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.2` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.07.2` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.06.2"
quartz = "1.07.2"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.06.2")
implementation("com.vitorpamplona.quartz:quartz:1.07.2")
}
```
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.06.2
com.vitorpamplona.quartz:quartz:1.07.2
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.06.2"
quartz = "1.07.2"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.06.2")
implementation("com.vitorpamplona.quartz:quartz:1.07.2")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.06.2")
implementation("com.vitorpamplona.quartz:quartz:1.07.2")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
+1 -1
View File
@@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp
## Current artifact
```
com.vitorpamplona.quartz:quartz:1.06.2
com.vitorpamplona.quartz:quartz:1.07.2
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
+5 -2
View File
@@ -12,8 +12,11 @@ echo "$JAVA_HOME"
echo "$(java -version)"
echo "Running test... "
./gradlew test --quiet
if [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then
./gradlew test --quiet -x :desktopApp:test -x :desktopApp:upxDownload -x :desktopApp:vlcDownload
else
./gradlew test --quiet
fi
status=$?
if [ "$status" = 0 ] ; then
+12 -1
View File
@@ -6,6 +6,9 @@ on:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
@@ -71,6 +74,10 @@ jobs:
- name: Test (gradle)
run: ./gradlew test --no-daemon
- name: Stop Gradle Daemon
if: always()
run: ./gradlew --stop
- name: Android Test Report
uses: asadmansr/android-test-report-action@v1.2.0
if: ${{ always() && matrix.os == 'ubuntu-latest' }}
@@ -85,7 +92,7 @@ jobs:
build-android:
needs: test
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -188,6 +195,10 @@ jobs:
- name: Build Desktop Distribution
run: ./gradlew :desktopApp:${{ matrix.task }}
- name: Stop Gradle Daemon
if: always()
run: ./gradlew --stop
- name: Upload Desktop Distribution
uses: actions/upload-artifact@v6
with:
+3
View File
@@ -5,6 +5,9 @@ on:
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
permissions:
contents: write
jobs:
create-release:
runs-on: ubuntu-latest
+4
View File
@@ -5,6 +5,10 @@ on:
# paths: ["app/src/main/res/**/strings.xml"] // removes filter to allow downloads at any moment.
branches: [ main ]
permissions:
contents: write
pull-requests: write
jobs:
synchronize-with-crowdin:
runs-on: ubuntu-latest
+1
View File
@@ -23,6 +23,7 @@
/.idea/deviceManager.xml
/.idea/inspectionProfiles/
/.idea/migrations.xml
/desktopApp/vlc-temp/
/commons/.idea/gradle.xml
/commons/.idea/misc.xml
/commons/.idea/workspace.xml
+191 -3
View File
@@ -1,5 +1,193 @@
<a id="v1.07.2"></a>
# [Release v1.07.2: GIF Keyboard](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.2) - 2026-03-30
- Adds GIF uploads support from Keyboard
- Migrates text fields and @ modifiers to the new Jetpack Compose states
- Fixes Bug that wasn't openning Amber to sign
- Fixes Bug on rejections using old Ambers
- Replaces hex input with user search dialog in relay management (allow/ban user)
<a id="v1.07.1"></a>
# [Release v1.07.1: Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.1) - 2026-03-30
- New translations
- Fixes BLE's module need for synchronized events.
<a id="v1.07.0"></a>
# [Release v1.07.0: Vanish, Relay Management, Pin Notes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.0) - 2026-03-30
Amethyst:
- Adds support to Pin Notes
- Adds support to Polls feed screen
- Adds support for Requests to Vanish
- Adds support for Relay management (NIP-86) from Amethyst
- Adds support for Relay monitor assessments (NIP-66) in the Relay Info screen.
- Adds support for Relay member information (NIP-43) in the Relay Info screen
- Adds support for WebBookmarks
- Adds support for Zap Goals
Quartz:
- Rewords the NostrClient API for simpler commands.
- Adds NIP-15: Nostr Marketplace protocol — product listings, stalls, merchant events (#2020)
- Adds NIP-24: Birthday field support added to UserMetadata (#1979)
- Adds NIP-29: Relay-based groups — group events, member management, moderation (#2021)
- Adds NIP-32: Labeling protocol — LabelEvent and tag parsing (#1975)
- Adds NIP-43: Relay access metadata and membership management with UI screens (#2010)
- Adds NIP-60: Cashu wallet and spending history event support (#2009)
- Adds NIP-61: Nutzaps protocol support (#2008)
- Adds NIP-62: Request to Vanish feature — data deletion with relay compliance testing (#1958)
- Adds NIP-66: Relay monitor discovery events — UI, filter assembler, subscription composable (#1968)
- Adds NIP-69: P2P Order Events (kind 38383) (#2019)
- Adds NIP-75: Zap Goals support with creation and rendering (#1965)
- Adds NIP-77: Negentropy set reconciliation protocol support (#1955)
- Adds NIP-7D: Thread events (kind 11) rendering (#2016)
- Adds NIP-85: Trusted Assertions — assertions for events and addressables (#1981)
- Adds NIP-86: Relay management UI and client implementation (#1954)
- Adds NIP-87: Ecash mint discoverability — Cashu and Fedimint event types (#2015)
- Adds NIP-89: Compliance fixes, PlatformLinkTag parsing, app handler extensions (#2030)
- Adds NIP-90: All DVM kind event classes from data-vending-machines spec, restructured packages (#2023, #2025)
- Adds NIP-A4: Add k tag to zap requests and enforce e tag prohibition (#1978)
- Adds NIP-BE: Bluetooth Low Energy mesh networking / Nostr BLE Communications Protocol (#2022)
- Adds NIP-C7: Chat messages (kind 9) implementation (#2018)
- Adds NIP-5A: Static website event rendering (#2017)
- Adds NIP-51, kind 10001: Pinned notes feature (#1956)
Improvements and Fixes:
- Show toast instead of dialog on media download success
- Dynamically adjust preferred Blossom server when list changes
- Add relay discovery to node master rendering list
- Add scroll to settings page
- Solves crashing when multiple relays with the same url are included in the resulting list.
- Keep screen on during PiP playback and survive screen lock (#1999)
- Desktop feed loading — missing events, broken profile navigation (#2027)
- Use getOrCreateNote for reply linking to fix flaky thread test (#2027)
- Route ReadsScreen following-mode events through cache (#2027)
- Pin ElectrumX server certs for Samsung One UI 7 / Android 16 compatibility (#1937)
- Duplicate keys in relay management lazy column — sort pubkeys
- URL detector — fixes localhost:3030 strings, Japanese character URLs
- Web Bookmarks floating action button shape (circle) and open graph previews.
- NIP-86 requests now send Accept and Content-Type headers
- On DMs, activates decryption for all filetypes that match decryption url with the cipher info, not only binaries
- Adds a try/finally to subscriptions to make sure they close even in crashes.
- Protects against crashes when the signer sends an unverifiable payload back to Amethyst
Desktop:
- Cache-centric architecture for desktop feeds (#1905)
- Render reposts and quoted notes in feed (#2027)
Performance
- Faster startup procedures with less loading on the main thread.
- 20x Faster Rfc3986Normalizer and way less objects being created.
- Url Detector without using regex
- Parallelize preference file access at startup for faster launch
- Remove internal runBlocking calls
- Lazy loading the memory trimming service
- Speeding up DrawerContent rendering
- Eagerly delete intermediate temp files in upload pipeline
Refactoring:
- Simplify NostrClient API for beginner-friendliness (#1986)
- Simplify relay API with AutoCloseable and serve() helper (#2031)
- URL detector performance and readability improvements (#2013)
- Restructure NIP-90 DVMs to match NIP-88 Polls pattern (#2023)
- Reorganize NIP-BE into subpackages
- Move public messages and trusted assertions out of experimental
Migrations & Deprecation Fixes
- Migrate `LocalClipboardManager` to `LocalClipboard` (#1995)
- Migrate `LocalAutofillTree` to semantics-based Autofill API (#1996)
- Migrate `TabRow`/`ScrollableTabRow` to Material 3 Secondary variants (#1994)
- Replace `ContextCompat.startActivity` with `Context.startActivity` (#1993)
- Suppress deprecation and unchecked cast warnings in Quartz internal code (#1985)
Platform & Build
- Add linuxX64 target and restructure native source sets
- Add explicit permissions to GitHub Actions workflows
- Stop Gradle daemons after build steps
- Increase Android CI build timeout to 45 minutes
- Update video compression library to latest
- Update dependencies, add local maven repo for easier library development
- Remove libsodium files
- Git hooks installation in worktrees (.git file vs directory)
- Fixes lack of proxy access to download VLC files in claude's web environment.
- Log level filtering — LogLevel enum, PlatformLog, lambda overloads for 194+ Log.d() calls (#2005)
- Parallelize BaseDBTest forEachDB using coroutines
Documentation
- Add CLIENT.md guide for building Nostr clients with Quartz
- Add RELAY.md guide for building relays with Ktor, NostrServer, SQLite
Contributors
- @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z — Primary development
- @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef — Log level filtering, video compression, toast downloads, CI fixes, git worktree fix
- @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c — Desktop cache architecture, desktop bugfixes
- @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 — CI workflow permissions
- @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k — Remove libsodium files
- **mstrofnone** — ElectrumX cert pinning for Samsung One UI 7
Translations
- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp
- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz
- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm
- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6
- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw
- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t
- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903
- Chinese by hypnotichemionus4 and @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h
- Russian by Anton Zhao
<a id="v1.06.3"></a>
# [Release v1.06.3: Anon Posting](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.3) - 2026-03-24
Improvements to Anon Posting and Reply navigation on DMs
<a id="v1.06.2"></a>
# [Release v1.06.2: Multi-choice Polls](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.2) - 2026-03-24
- Add poll type selector for single and multiple choice polls
- Add drag-to-seek support on the video progress indicator
- Hide video controls on playback start
- Recording indicator bar stops recording when tapped, not just the small stop icon.
- Fixes non http uris in the references tag
- Makes sure quote tags only happen once in events.
- Removes the clickable NIP-05 URL in the @ tagging of users
- Reduces the need to compute naddr to index articles in bookmarks
- Checks if p tags are user pubkeys when loading drafts.
- Scroll to the replied message when clicking reply preview in chat
- Add an anonymous reply with a throwaway keypair
- Fixes pool rendering when one label is large and the other is small
- Use uri-reference-kmp in commonMain. Remove platform-specific implementations.
- Avoids crashing the app in URLs with japanese chars
- Sorts followers into a set to avoid LazyColumn key conflicts.
- Clickable relay rows on the profile page
- Move cache lookups from NavHost route lambdas into screen composables
- Improvements to the status of attestations. Validity first, then Processing status.
- Fixes Tor Manager flow value and improves Tor binding lifecycle.
- Simplifies the report feed in the user profile
- Clears some inconsistencies in translated strings
- Fixes client tags
- Fixes routes for AppDefinition events.
<a id="v1.06.1"></a>
# [Release v1.06.1: Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.1) - 2026-03-23
- Improvements to the rendering of Polls.
- Solves some of the crashes of the concurrent modification exception
- Fixes URL parsers with Japanese chars
- Fixes Wallet import from Primal on Poco phones
- Improves the wording of the Last Seen
- Fixes for "Cannot disable reuse from root if it was caused by other groups"
- Fixes comparator to avoid Comparison method violates its general contract!
- Improves zap-store settings
<a id="v1.06.0"></a>
# [Release v1.06.0: Polls, Relay Feeds, Wallets and much more](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.0) - 2025-03-21
# [Release v1.06.0: Polls, Relay Feeds, Wallets and much more](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.0) - 2026-03-21
Polls:
- Adds support for creating and rendering NIP-85
@@ -271,13 +459,13 @@ Updated translations:
- Russian by Anton Zhao
<a id="v1.05.1"></a>
# [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08
# [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2026-01-08
- Fixed mixed DMs between logged in users.
- Fixed draft screen click to edit post.
<a id="v1.05.0"></a>
# [Release v1.05.0: Bookmark Lists and WoT Scores](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08
# [Release v1.05.0: Bookmark Lists and WoT Scores](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2026-01-08
#Amethyst v1.05.0: Bookmark Lists, Voice Notes, and WoT Scores
+83 -86
View File
@@ -39,107 +39,104 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
<img align="right" src="./docs/screenshots/home.png" data-canonical-src="./docs/screenshots/home.png" width="350px">
- [x] Events / Relay Subscriptions (NIP-01)
- [x] Basic protocol flow (NIP-01)
- [x] Follow List (NIP-02)
- [x] OpenTimestamps Attestations (NIP-03)
- [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] Encrypted Direct Message (NIP-04)
- [x] DNS-based Identifiers (NIP-05)
- [x] Key Derivation from Mnemonic (NIP-06)
- [ ] window.nostr for Web Browsers (NIP-07, Not applicable)
- [x] Handling Mentions (NIP-08)
- [x] Event Deletion Request (NIP-09)
- [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)
- [ ] Proof of Work Calculations (NIP-13)
- [x] Events with a Subject (NIP-14)
- [ ] Marketplace (NIP-15)
- [x] Event Treatment (NIP-16)
- [x] Proof of Work (NIP-13)
- [x] Subject Tag in Text Events (NIP-14)
- [x] Nostr Marketplace (NIP-15)
- [x] Private Direct Messages (NIP-17)
- [x] Image/Video/Url/LnInvoice/Cashu Previews
- [x] Reposts, Quotes, Generic Reposts (NIP-18)
- [x] Bech32 Encoding support (NIP-19)
- [x] Command Results (NIP-20)
- [x] URI Support (NIP-21)
- [x] Long-form Content (NIP-23) (view only)
- [x] User Profile Fields / Relay list (NIP-24)
- [x] Reposts (NIP-18)
- [x] bech32-encoded Entities (NIP-19)
- [x] nostr: URI Scheme (NIP-21)
- [x] Comment (NIP-22)
- [x] Long-form Content (NIP-23)
- [x] Extra Metadata Fields and Tags (NIP-24)
- [x] Reactions (NIP-25)
- [ ] Delegated Event Signing (NIP-26, Will not implement)
- [x] Delegated Event Signing (NIP-26)
- [x] Text Note References (NIP-27)
- [x] Public Chats (NIP-28)
- [ ] Relay-based Groups (NIP-29)
- [x] Public Chat (NIP-28)
- [x] Relay-based Groups (NIP-29)
- [x] Custom Emoji (NIP-30)
- [x] Event alt descriptors (NIP-31)
- [ ] Labeling (NIP-32)
- [x] Git Stuff (NIP-34)
- [x] Dealing with Unknown Events (NIP-31)
- [x] Labeling (NIP-32)
- [x] git stuff (NIP-34)
- [x] Torrents (NIP-35)
- [x] Sensitive Content (NIP-36)
- [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)
- [x] Event Counts (NIP-45)
- [o] Nostr Connect (NIP-46)
- [o] Wallet Connect API (NIP-47)
- [ ] Proxy Tags (NIP-48, Not applicable)
- [x] Encryption for import/export (NIP-49)
- [x] Relay Search (NIP-50)
- [x] Draft Events (NIP-37)
- [x] User Statuses (NIP-38)
- [x] External Identities in Profiles (NIP-39)
- [x] Expiration Timestamp (NIP-40)
- [x] Authentication of Clients to Relays (NIP-42)
- [x] Relay Access Metadata and Requests (NIP-43)
- [x] Encrypted Payloads / Versioned (NIP-44)
- [x] Counting Results (NIP-45)
- [x] Nostr Remote Signing (NIP-46)
- [x] Nostr Wallet Connect (NIP-47)
- [x] Proxy Tags (NIP-48)
- [x] Private Key Encryption (NIP-49)
- [x] Search Capability (NIP-50)
- [x] Lists (NIP-51)
- [o] Calendar Events (NIP-52)
- [x] Live Activities & Live Chats (NIP-53)
- [x] Calendar Events (NIP-52)
- [x] Live Activities (NIP-53)
- [x] Wiki (NIP-54)
- [x] Android Signer (NIP-55)
- [x] Android Signer Application (NIP-55)
- [x] Reporting (NIP-56)
- [x] Lightning Tips
- [x] Zaps (NIP-57)
- [x] Lightning Zaps (NIP-57)
- [x] Zap Splits (NIP-57)
- [x] Private Zaps (NIP-57 / Draft)
- [x] Zapraiser (NIP-57 / Draft)
- [x] Private Zaps (NIP-57)
- [x] Zapraiser (NIP-57)
- [x] Badges (NIP-58)
- [x] Gift Wraps & Seals (NIP-59)
- [ ] Cashu Wallets (NIP-60)
- [ ] Nutzaps (NIP-61)
- [x] Gift Wrap (NIP-59)
- [x] Pubkey Static Websites (NIP-5A)
- [x] Cashu Wallet (NIP-60)
- [x] Nutzaps (NIP-61)
- [x] Request to Vanish (NIP-62)
- [x] Chess (NIP-64)
- [x] Chess / PGN (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] Relay Discovery and Liveness Monitoring (NIP-66)
- [x] Picture-first Feeds (NIP-68)
- [x] Peer-to-peer Order Events (NIP-69)
- [x] 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] Zap Goals (NIP-75)
- [x] Negentropy Syncing (NIP-77)
- [x] Application-specific Data (NIP-78)
- [x] Threads (NIP-7D)
- [x] Highlights (NIP-84)
- [x] Trusted Assertions (NIP-85)
- [ ] Relay Management API (NIP-86)
- [ ] ECash Mint Discoverability (NIP-87)
- [x] Relay Management API (NIP-86)
- [x] 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)
- [x] Verifiable file URLs (NIP-94)
- [x] Data Vending Machines (NIP-90)
- [x] Media Attachments (NIP-92)
- [x] File Metadata (NIP-94)
- [x] Binary Blobs (NIP-95/Draft)
- [x] HTTP File Storage Integration (NIP-96)
- [x] HTTP Auth (NIP-98)
- [x] Classifieds (NIP-99)
- [x] Classified Listings (NIP-99)
- [x] Voice Messages (NIP-A0)
- [x] Public Messages (NIP-A4)
- [ ] Web Bookmarks
- [x] Web Bookmarks (NIP-B0)
- [x] Blossom (NIP-B7)
- [ ] Nostr BLE Communications Protocol (NIP-BE)
- [x] Nostr BLE Communications Protocol (NIP-BE)
- [x] Code Snippets (NIP-C0)
- [ ] NIP-29 Chats (NIP-C7)
- [x] Chats (NIP-C7)
- [ ] MLS Protocol (NIP-EE)
- [x] Audio Tracks (zapstr.live) (kind:31337)
- [x] Lightning Tips
- [x] Image/Video/Url/LnInvoice/Cashu Previews
- [x] Push Notifications (Google and Unified Push)
- [x] In-Device Automatic Translations
- [x] Hashtag Following and Custom Hashtags
@@ -405,24 +402,24 @@ 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. |
| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes |
|:-------------------------|:-------------------------------|:---------------------:|:-----------:|:-----------------------------------------------------------------------|
| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ✅ Full | |
| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ✅ Full | |
| | AES Encryption (CBC & GCM) | ✅ Full | ✅ Full | |
| | Hashing (SHA-256, etc.) | ✅ Full | ✅ Full | |
| | MAC (HmacSHA256, etc.) | ✅ Full | ✅ Full | |
| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ✅ Full | A fully custom implementation exists in `commonMain`. |
| | GZip Compression | ✅ Full | ✅ Full | |
| | BitSet | ✅ Full | ✅ Full | |
| | LargeCache | ✅ Full | ✅ Full | |
| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ✅ Full | |
| | 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 | ✅ Full | |
| | Unicode Normalization | ✅ Full | ✅ Full | |
| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. |
| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. |
## Contributing
+3 -3
View File
@@ -54,9 +54,9 @@ android {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 434
versionName = generateVersionName("1.06.2")
buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\""
versionCode = 438
versionName = generateVersionName("1.07.2")
buildConfigField "String", "RELEASE_NOTES_ID", "\"12cd4bce977ed53502cf121ecba89a190ab02685333c8f230bac35b04f920eeb\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -70,9 +70,9 @@ class ThreadDualAxisChartAssemblerTest {
Account(
settings = AccountSettings(keyPair = keyPair),
signer = NostrSignerInternal(keyPair),
geolocationFlow = MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading),
nwcFilterAssembler = NWCPaymentFilterAssembler(client),
otsResolverBuilder = EmptyOtsResolverBuilder,
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
nwcFilterAssembler = { NWCPaymentFilterAssembler(client) },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
cache = LocalCache,
client = client,
scope = scope,
@@ -46,7 +46,7 @@ object PushDistributorHandler : PushDistributorActions {
fun setEndpoint(newEndpoint: String) {
endpointInternal = newEndpoint
Log.d("PushHandler", "New endpoint saved : $endpointInternal")
Log.d("PushHandler") { "New endpoint saved : $endpointInternal" }
}
fun removeEndpoint() {
@@ -53,7 +53,7 @@ class PushMessageReceiver : MessagingReceiver() {
instance: String,
) {
val messageStr = message.content.decodeToString()
Log.d(TAG, "New message $messageStr for Instance: $instance")
Log.d(TAG) { "New message $messageStr for Instance: $instance" }
scope.launch {
try {
parseMessage(messageStr)?.let {
@@ -61,7 +61,7 @@ class PushMessageReceiver : MessagingReceiver() {
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.d(TAG, "Message could not be parsed: ${e.message}")
Log.d(TAG) { "Message could not be parsed: ${e.message}" }
}
}
}
@@ -87,7 +87,7 @@ class PushMessageReceiver : MessagingReceiver() {
) {
val sanitizedEndpoint = if (endpoint.url.endsWith("?up=1")) endpoint.url.dropLast(5) else endpoint.url
if (sanitizedEndpoint != pushHandler.getSavedEndpoint()) {
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint")
Log.d(TAG) { "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint" }
pushHandler.setEndpoint(sanitizedEndpoint)
scope.launch(Dispatchers.IO) {
PushNotificationUtils.checkAndInit(sanitizedEndpoint, LocalPreferences.allSavedAccounts()) {
@@ -97,7 +97,7 @@ class PushMessageReceiver : MessagingReceiver() {
NotificationUtils.getOrCreateDMChannel(appContext)
}
} else {
Log.d(TAG, "Same endpoint provided:- $endpoint for Instance: $instance $sanitizedEndpoint")
Log.d(TAG) { "Same endpoint provided:- $endpoint for Instance: $instance $sanitizedEndpoint" }
}
}
@@ -106,7 +106,7 @@ class PushMessageReceiver : MessagingReceiver() {
reason: FailedReason,
instance: String,
) {
Log.d(TAG, "Registration failed for Instance: $instance")
Log.d(TAG) { "Registration failed for Instance: $instance" }
pushHandler.forceRemoveDistributor(context)
}
@@ -115,7 +115,7 @@ class PushMessageReceiver : MessagingReceiver() {
instance: String,
) {
val removedEndpoint = pushHandler.getSavedEndpoint()
Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance")
Log.d(TAG) { "Endpoint: $removedEndpoint removed for Instance: $instance" }
Log.d(TAG, "App is unregistered. ")
pushHandler.forceRemoveDistributor(context)
pushHandler.removeEndpoint()
@@ -35,7 +35,7 @@ object PushNotificationUtils {
accounts: List<AccountInfo>,
okHttpClient: (String) -> OkHttpClient,
) = with(Dispatchers.IO) {
if (!pushHandler.savedDistributorExists()) return
if (!pushHandler.savedDistributorExists()) return@with
val currentDistributor = PushDistributorHandler.getSavedDistributor()
PushDistributorHandler.saveDistributor(currentDistributor)
@@ -23,10 +23,12 @@ package com.vitorpamplona.amethyst
import android.app.Application
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
class Amethyst : Application() {
init {
Log.d("AmethystApp", "Creating App $this")
Log.minLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.ERROR
Log.d("AmethystApp") { "Creating App $this" }
}
companion object {
@@ -36,7 +38,7 @@ class Amethyst : Application() {
override fun onCreate() {
super.onCreate()
Log.d("AmethystApp", "onCreate $this")
Log.d("AmethystApp") { "onCreate $this" }
instance = AppModules(this)
if (isDebug) {
@@ -48,7 +50,7 @@ class Amethyst : Application() {
override fun onTerminate() {
super.onTerminate()
Log.d("AmethystApp", "onTerminate $this")
Log.d("AmethystApp") { "onTerminate $this" }
instance.terminate(this)
}
@@ -59,7 +61,7 @@ class Amethyst : Application() {
*/
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
Log.d("AmethystApp", "onTrimMemory $level")
Log.d("AmethystApp") { "onTrimMemory $level" }
instance.trim()
}
}
@@ -20,14 +20,15 @@
*/
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.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
@@ -63,9 +64,11 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinder
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.resourceCacheInit
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
@@ -87,6 +90,7 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -94,6 +98,7 @@ import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.File
class AppModules(
@@ -109,31 +114,54 @@ class AppModules(
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
// Pre-load both preference DataStores in parallel on IO threads.
// Both constructors use runBlocking internally, so starting them concurrently
// reduces total blocking time from (torPrefs + uiPrefs) to ~max(torPrefs, uiPrefs).
private val uiPrefsDeferred =
applicationIOScope.async {
val prefs = UiSharedPreferences.uiPreferences(appContext) ?: UiSettings()
UiSharedPreferences(prefs, appContext, applicationIOScope)
}
private val torPrefsDeferred =
applicationIOScope.async {
val prefs = TorSharedPreferences.torPreferences(appContext) ?: TorSettings()
TorSharedPreferences(prefs, appContext, applicationIOScope)
}
// Blocking load of UI Preferences to avoid theme/language blinking
val uiPrefs by lazy {
UiSharedPreferences(appContext, applicationIOScope)
Log.d("AppModules", "UiSharedPreferences Init")
runBlocking { uiPrefsDeferred.await() }
}
// Blocking load of Tor Settings to avoid connection leaks
val torPrefs by lazy {
TorSharedPreferences(appContext, applicationIOScope)
Log.d("AppModules", "TorSharedPreferences Init")
runBlocking { torPrefsDeferred.await() }
}
// Namecoin ElectrumX server preferences (global, like Tor settings)
val namecoinPrefs by lazy {
Log.d("AppModules", "NamecoinSharedPreferences Init")
NamecoinSharedPreferences(appContext, applicationIOScope)
}
// OTS blockchain explorer preferences (global, like Tor settings)
val otsPrefs by lazy {
Log.d("AppModules", "OtsSharedPreferences Init")
OtsSharedPreferences(appContext, applicationIOScope)
}
// App services that should be run as soon as there are subscribers to their flows
val locationManager = LocationState(appContext, applicationIOScope)
val locationManager by lazy {
Log.d("AppModules", "LocationManager Init")
LocationState(appContext, applicationIOScope)
}
val connManager = ConnectivityManager(appContext, applicationIOScope)
val uiState by lazy {
Log.d("AppModules", "UiSettingsState Init")
UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope)
}
@@ -158,40 +186,67 @@ class AppModules(
// 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 electrumXClient by lazy {
Log.d("AppModules", "ElectrumXClient Init")
val client =
ElectrumXClient(
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
)
applicationIOScope.launch {
try {
val pinnedCerts = namecoinPrefs.loadPinnedCerts()
if (pinnedCerts.isNotEmpty()) {
client.setDynamicCerts(pinnedCerts)
}
} catch (_: Exception) {
// Non-fatal — defaults will still work
}
}
client
}
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)
val namecoinResolver by
lazy {
Log.d("AppModules", "Namecoin Resolver Init")
NamecoinNameResolver(
electrumxClient = electrumXClient,
serverListProvider = {
// User-configured custom servers take priority
namecoinPrefs.customServersOrNull
?: if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
TOR_ELECTRUMX_SERVERS
} else {
DEFAULT_ELECTRUMX_SERVERS
}
},
)
}
// Application-wide block height request cache
val otsBlockHeightCache by lazy { OtsBlockHeightCache() }
val nip05Client by
lazy {
Log.d("AppModules", "NIP05Client Init")
Nip05Client(
fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05),
namecoinResolverBuilder = { namecoinResolver },
)
}
val otsResolverBuilder: TorAwareOkHttpOtsResolverBuilder =
TorAwareOkHttpOtsResolverBuilder(
roleBasedHttpClientBuilder::okHttpClientForMoney,
roleBasedHttpClientBuilder::shouldUseTorForMoneyOperations,
otsBlockHeightCache,
customExplorerUrl = { otsPrefs.current.normalizedUrl() },
)
val otsResolverBuilder by
lazy {
Log.d("AppModules", "OtsResolverBuilder Init")
TorAwareOkHttpOtsResolverBuilder(
roleBasedHttpClientBuilder::okHttpClientForMoney,
roleBasedHttpClientBuilder::shouldUseTorForMoneyOperations,
OtsBlockHeightCache(),
customExplorerUrl = { otsPrefs.current.normalizedUrl() },
)
}
// Application-wide ots verification cache
val otsVerifCache by lazy { VerificationStateCache(otsResolverBuilder) }
val otsVerifCache by lazy {
Log.d("AppModules", "OtsCache Init")
VerificationStateCache(otsResolverBuilder)
}
val torEvaluatorFlow =
TorRelayState(
@@ -209,7 +264,7 @@ class AppModules(
scope = applicationIOScope,
)
// Connects the NostrClient class with okHttp
// Connects the INostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder { url ->
val useTor = torEvaluatorFlow.flow.value.useTor(url)
@@ -243,7 +298,12 @@ class AppModules(
val authCoordinator = AuthCoordinator(client, applicationIOScope)
// Tries to verify new OTS events when they arrive.
val otsEventVerifier = IncomingOtsEventVerifier(otsVerifCache, cache, applicationIOScope)
val otsEventVerifier =
IncomingOtsEventVerifier(
otsVerifCache = { otsVerifCache },
cache = cache,
scope = applicationIOScope,
)
// Tracks if it is possible to connect to relays.
val failureTracker = RelayOfflineTracker(client)
@@ -269,10 +329,10 @@ class AppModules(
// keeps all accounts live
val accountsCache =
AccountCacheState(
geolocationFlow = locationManager.geohashStateFlow,
nwcFilterAssembler = sources.nwc,
contentResolverFn = ::contentResolverFn,
otsResolverBuilder = otsResolverBuilder,
geolocationFlow = { locationManager.geohashStateFlow },
nwcFilterAssembler = { sources.nwc },
contentResolverFn = { appContext.contentResolver },
otsResolverBuilder = { otsResolverBuilder.build() },
cache = cache,
client = client,
)
@@ -280,8 +340,8 @@ class AppModules(
val sessionManager =
AccountSessionManager(
accountsCache = accountsCache,
nip05Client = nip05Client,
client = client,
nip05ClientBuilder = { nip05Client },
clientBuilder = { client },
localPreferences = LocalPreferences,
scope = applicationIOScope,
)
@@ -307,7 +367,8 @@ class AppModules(
}
}
val blossomResolver =
val blossomResolver by lazy {
Log.d("AppModules", "BlossomServerResolver Init")
BlossomServerResolver(
loggedInUsers = { listOfNotNull(sessionManager.loggedInAccount()?.pubKey) },
blossomServers = { addressesToSubscribe ->
@@ -323,45 +384,60 @@ class AppModules(
},
httpClientBuilder = roleBasedHttpClientBuilder,
)
}
// Organizes cache clearing
val trimmingService = MemoryTrimmingService(cache)
val trimmingService by
lazy {
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) }
val nip95cache: File by lazy {
Log.d("AppModules", "NIP95 Cache Init")
Nip95CacheFactory.new(appContext)
}
// local video cache with disk + memory
val videoCache: VideoCache by lazy { VideoCacheFactory.new(appContext) }
val videoCache: VideoCache by lazy {
Log.d("AppModules", "VideoCache Init")
VideoCacheFactory.new(appContext)
}
// image cache in disk for coil
val diskCache: DiskCache by lazy { ImageCacheFactory.newDisk(appContext) }
val diskCache: DiskCache by lazy {
Log.d("AppModules", "ImageCacheFactory Init")
ImageCacheFactory.newDisk(appContext)
}
// image cache in memory for coil
val memoryCache: MemoryCache by lazy { ImageCacheFactory.newMemory(appContext) }
val memoryCache: MemoryCache by lazy {
Log.d("AppModules", "MemoryCache Init")
ImageCacheFactory.newMemory(appContext)
}
// crash report storage
val crashReportCache: CrashReportCache by lazy { CrashReportCache(appContext) }
val crashReportCache = CrashReportCache(appContext)
// cache for NIP-11 documents
val nip11Cache: Nip11CachedRetriever by lazy {
Log.d("AppModules", "Nip11CachedRetriever Init")
Nip11CachedRetriever(torEvaluatorFlow::okHttpClientForRelay)
}
fun contentResolverFn(): ContentResolver = appContext.contentResolver
fun setImageLoader() {
Log.d("AppModules", "ImageLoaderSetup Init")
ImageLoaderSetup.setup(
app = appContext,
diskCache = { diskCache },
memoryCache = { memoryCache },
blossomServerResolver = blossomResolver,
) { url ->
okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(url))
}
blossomServerResolver = { blossomResolver },
callFactory = { okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(it)) },
)
}
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(appContext, npub)
@@ -380,14 +456,20 @@ class AppModules(
// initializes diskcache on an IO thread.
applicationIOScope.launch {
// preloads tor preferences
torPrefs
// Sets Coil - Tor - OkHttp link
setImageLoader()
}
// initializes diskcache on an IO thread.
applicationIOScope.launch {
// Sets Coil - Tor - OkHttp link
setImageLoader()
uiState
}
// LRUCache should not be instanciated in the Main thread due to blocking
applicationIOScope.launch {
CachedRobohash
resourceCacheInit()
}
// registers to receive events
@@ -395,15 +477,10 @@ class AppModules(
// initializes diskcache on an IO thread.
applicationIOScope.launch {
// Sets Coil - Tor - OkHttp link
delay(3000)
// Prepares video cache later
delay(10_000)
videoCache
}
applicationIOScope.launch {
// Eagerly initialize OtsSharedPreferences off the main thread
otsPrefs
}
}
fun terminate(appContext: Context) {
@@ -46,19 +46,19 @@ fun debugState(context: Context) {
val jvmHeapAllocatedMb = totalMemoryMb - freeMemoryMb
Log.d(STATE_DUMP_TAG, "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_TAG, "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_TAG, "Memory Class $memClass MB (largeHeap $isLargeHeap)")
Log.d(STATE_DUMP_TAG) { "Memory Class $memClass MB (largeHeap $isLargeHeap)" }
}
Log.d(
@@ -68,14 +68,8 @@ fun debugState(context: Context) {
.size() + "/" + normalizedUrls.size(),
)
Log.d(
STATE_DUMP_TAG,
"Image Disk Cache ${(Amethyst.instance.diskCache.size) / (1024 * 1024)}/${(Amethyst.instance.diskCache.maxSize) / (1024 * 1024)} MB",
)
Log.d(
STATE_DUMP_TAG,
"Image Memory Cache ${(Amethyst.instance.memoryCache.size) / (1024 * 1024)}/${(Amethyst.instance.memoryCache.maxSize) / (1024 * 1024)} MB",
)
Log.d(STATE_DUMP_TAG) { "Image Disk Cache ${(Amethyst.instance.diskCache.size) / (1024 * 1024)}/${(Amethyst.instance.diskCache.maxSize) / (1024 * 1024)} MB" }
Log.d(STATE_DUMP_TAG) { "Image Memory Cache ${(Amethyst.instance.memoryCache.size) / (1024 * 1024)}/${(Amethyst.instance.memoryCache.maxSize) / (1024 * 1024)} MB" }
Log.d(
STATE_DUMP_TAG,
@@ -130,13 +124,12 @@ fun debugState(context: Context) {
LocalCache.ephemeralChannels.values().sumOf { it.notes.size() },
)
LocalCache.chatroomList.forEach { key, room ->
Log.d(
STATE_DUMP_TAG,
Log.d(STATE_DUMP_TAG) {
"Private Chats $key: " +
room.rooms.size() +
" / " +
room.rooms.sumOf { key, value -> value.messages.size },
)
room.rooms.sumOf { key, value -> value.messages.size }
}
}
Log.d(
STATE_DUMP_TAG,
@@ -173,10 +166,10 @@ fun debugState(context: Context) {
.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_TAG, "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_TAG, "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 " }
}
}
@@ -188,7 +181,7 @@ inline fun <T> logTime(
if (isDebug) {
val (result, elapsed) = measureTimedValue(block)
if (elapsed.inWholeMilliseconds > minToReportMs) {
Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: $debugMessage")
Log.d("DEBUG-TIME") { "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: $debugMessage" }
}
result
} else {
@@ -203,7 +196,7 @@ inline fun <T> logTime(
if (isDebug) {
val (result, elapsed) = measureTimedValue(block)
if (elapsed.inWholeMilliseconds > minToReportMs) {
Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: ${debugMessage(result)}")
Log.d("DEBUG-TIME") { "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: ${debugMessage(result)}" }
}
result
} else {
@@ -33,7 +33,6 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
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
@@ -59,9 +58,11 @@ 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.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -253,7 +254,7 @@ object LocalPreferences {
val prefsDir = File(prefsDirPath)
prefsDir.list()?.forEach {
if (it.contains(npub) && !File(prefsDir, it).delete()) {
Log.w("LocalPreferences", "Failed to delete preference file: $it")
Log.w("LocalPreferences") { "Failed to delete preference file: $it" }
}
}
}
@@ -281,7 +282,7 @@ object LocalPreferences {
*/
@SuppressLint("ApplySharedPref")
suspend fun deleteAccount(accountInfo: AccountInfo) {
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}")
Log.d("LocalPreferences") { "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}" }
withContext(Dispatchers.IO) {
encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() }
removeAccount(accountInfo)
@@ -445,108 +446,139 @@ object LocalPreferences {
}
private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? {
Log.d("LocalPreferences", "Load account from file $npub")
Log.d("LocalPreferences") { "Load account from file $npub" }
val result =
withContext(Dispatchers.IO) {
checkNotInMainThread()
return@withContext with(encryptedPreferences(npub)) {
Log.d("LocalPreferences") { "Load account from file $npub - opened file" }
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null
val externalSignerPackageName =
getString(PrefKeys.SIGNER_PACKAGE_NAME, null)
?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null
val externalSignerPackageName = getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null
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 keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray())
Log.d("LocalPreferences") { "Load account from file $npub - keys ready" }
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()
val latestUserMetadata = parseEventOrNull<MetadataEvent>(PrefKeys.LATEST_USER_METADATA)
val latestContactList = parseEventOrNull<ContactListEvent>(PrefKeys.LATEST_CONTACT_LIST)
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)
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
val latestAppSpecificData = parseEventOrNull<AppSpecificDataEvent>(PrefKeys.LATEST_APP_SPECIFIC_DATA)
val latestChannelList = parseEventOrNull<ChannelListEvent>(PrefKeys.LATEST_CHANNEL_LIST)
val latestCommunityList = parseEventOrNull<CommunityListEvent>(PrefKeys.LATEST_COMMUNITY_LIST)
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 hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
val defaultHomeFollowListStr = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null)
val defaultStoriesFollowListStr = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null)
val defaultNotificationFollowListStr = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null)
val defaultDiscoveryFollowListStr = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null)
val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)
val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null)
val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null)
val latestUserMetadataStr = getString(PrefKeys.LATEST_USER_METADATA, null)
val latestContactListStr = getString(PrefKeys.LATEST_CONTACT_LIST, null)
val latestDmRelayListStr = getString(PrefKeys.LATEST_DM_RELAY_LIST, null)
val latestNip65RelayListStr = getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null)
val latestSearchRelayListStr = getString(PrefKeys.LATEST_SEARCH_RELAY_LIST, null)
val latestIndexRelayListStr = getString(PrefKeys.LATEST_INDEX_RELAY_LIST, null)
val latestRelayFeedsListStr = getString(PrefKeys.LATEST_RELAY_FEEDS_LIST, null)
val latestBlockedRelayListStr = getString(PrefKeys.LATEST_BLOCKED_RELAY_LIST, null)
val latestTrustedRelayListStr = getString(PrefKeys.LATEST_TRUSTED_RELAY_LIST, null)
val latestMuteListStr = getString(PrefKeys.LATEST_MUTE_LIST, null)
val latestPrivateHomeRelayListStr = getString(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST, null)
val latestAppSpecificDataStr = getString(PrefKeys.LATEST_APP_SPECIFIC_DATA, null)
val latestChannelListStr = getString(PrefKeys.LATEST_CHANNEL_LIST, null)
val latestCommunityListStr = getString(PrefKeys.LATEST_COMMUNITY_LIST, null)
val latestHashtagListStr = getString(PrefKeys.LATEST_HASHTAG_LIST, null)
val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null)
val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null)
val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null)
val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null)
val lastReadPerRouteStr = getString(PrefKeys.LAST_READ_PER_ROUTE, null)
Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" }
val defaultHomeFollowList = async { parseOrNull<TopFilter>(defaultHomeFollowListStr) ?: TopFilter.AllFollows }
val defaultStoriesFollowList = async { parseOrNull<TopFilter>(defaultStoriesFollowListStr) ?: TopFilter.Global }
val defaultNotificationFollowList = async { parseOrNull<TopFilter>(defaultNotificationFollowListStr) ?: TopFilter.Global }
val defaultDiscoveryFollowList = async { parseOrNull<TopFilter>(defaultDiscoveryFollowListStr) ?: TopFilter.Global }
val zapPaymentRequestServer = async { parseOrNull<Nip47WalletConnect.Nip47URI>(zapPaymentRequestServerStr) }
val defaultFileServer = async { parseOrNull<ServerName>(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] }
val pendingAttestations = async { parseOrNull<Map<HexKey, String>>(pendingAttestationsStr) ?: mapOf() }
val latestUserMetadata = async { parseEventOrNull<MetadataEvent>(latestUserMetadataStr) }
val latestContactList = async { parseEventOrNull<ContactListEvent>(latestContactListStr) }
val latestDmRelayList = async { parseEventOrNull<ChatMessageRelayListEvent>(latestDmRelayListStr) }
val latestNip65RelayList = async { parseEventOrNull<AdvertisedRelayListEvent>(latestNip65RelayListStr) }
val latestSearchRelayList = async { parseEventOrNull<SearchRelayListEvent>(latestSearchRelayListStr) }
val latestIndexRelayList = async { parseEventOrNull<IndexerRelayListEvent>(latestIndexRelayListStr) }
val latestRelayFeedsList = async { parseEventOrNull<RelayFeedsListEvent>(latestRelayFeedsListStr) }
val latestBlockedRelayList = async { parseEventOrNull<BlockedRelayListEvent>(latestBlockedRelayListStr) }
val latestTrustedRelayList = async { parseEventOrNull<TrustedRelayListEvent>(latestTrustedRelayListStr) }
val latestMuteList = async { parseEventOrNull<MuteListEvent>(latestMuteListStr) }
val latestPrivateHomeRelayList = async { parseEventOrNull<PrivateOutboxRelayListEvent>(latestPrivateHomeRelayListStr) }
val latestAppSpecificData = async { parseEventOrNull<AppSpecificDataEvent>(latestAppSpecificDataStr) }
val latestChannelList = async { parseEventOrNull<ChannelListEvent>(latestChannelListStr) }
val latestCommunityList = async { parseEventOrNull<CommunityListEvent>(latestCommunityListStr) }
val latestHashtagList = async { parseEventOrNull<HashtagListEvent>(latestHashtagListStr) }
val latestGeohashList = async { parseEventOrNull<GeohashListEvent>(latestGeohashListStr) }
val latestEphemeralList = async { parseEventOrNull<EphemeralChatListEvent>(latestEphemeralListStr) }
val latestTrustProviderList = async { parseEventOrNull<TrustProviderListEvent>(latestTrustProviderListStr) }
val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) }
val lastReadPerRoute =
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
MutableStateFlow(it.value)
} ?: mapOf()
async {
parseOrNull<Map<String, Long>>(lastReadPerRouteStr)?.mapValues {
MutableStateFlow(it.value)
} ?: mapOf()
}
val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray())
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
Log.d("LocalPreferences") { "Load account from file $npub - asyncs created" }
return@with AccountSettings(
keyPair = keyPair,
transientAccount = false,
externalSignerPackageName = externalSignerPackageName,
localRelayServers = MutableStateFlow(localRelayServers),
defaultFileServer = defaultFileServer,
defaultFileServer = defaultFileServer.await(),
stripLocationOnUpload = stripLocationOnUpload,
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
zapPaymentRequest = MutableStateFlow(zapPaymentRequestServer?.normalize()),
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList.await()),
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList.await()),
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList.await()),
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList.await()),
zapPaymentRequest = MutableStateFlow(zapPaymentRequestServer.await()?.normalize()),
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
backupUserMetadata = latestUserMetadata,
backupContactList = latestContactList,
backupNIP65RelayList = latestNip65RelayList,
backupDMRelayList = latestDmRelayList,
backupSearchRelayList = latestSearchRelayList,
backupIndexRelayList = latestIndexRelayList,
backupRelayFeedsList = latestRelayFeedsList,
backupBlockedRelayList = latestBlockedRelayList,
backupTrustedRelayList = latestTrustedRelayList,
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
backupMuteList = latestMuteList,
backupAppSpecificData = latestAppSpecificData,
backupChannelList = latestChannelList,
backupCommunityList = latestCommunityList,
backupHashtagList = latestHashtagList,
backupGeohashList = latestGeohashList,
backupEphemeralChatList = latestEphemeralList,
backupTrustProviderList = latestTrustProviderList,
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
backupUserMetadata = latestUserMetadata.await(),
backupContactList = latestContactList.await(),
backupNIP65RelayList = latestNip65RelayList.await(),
backupDMRelayList = latestDmRelayList.await(),
backupSearchRelayList = latestSearchRelayList.await(),
backupIndexRelayList = latestIndexRelayList.await(),
backupRelayFeedsList = latestRelayFeedsList.await(),
backupBlockedRelayList = latestBlockedRelayList.await(),
backupTrustedRelayList = latestTrustedRelayList.await(),
backupPrivateHomeRelayList = latestPrivateHomeRelayList.await(),
backupMuteList = latestMuteList.await(),
backupAppSpecificData = latestAppSpecificData.await(),
backupChannelList = latestChannelList.await(),
backupCommunityList = latestCommunityList.await(),
backupHashtagList = latestHashtagList.await(),
backupGeohashList = latestGeohashList.await(),
backupEphemeralChatList = latestEphemeralList.await(),
backupTrustProviderList = latestTrustProviderList.await(),
lastReadPerRoute = MutableStateFlow(lastReadPerRoute.await()),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
pendingAttestations = MutableStateFlow(pendingAttestations),
backupNipA3PaymentTargets = latestPaymentTargets,
pendingAttestations = MutableStateFlow(pendingAttestations.await()),
backupNipA3PaymentTargets = latestPaymentTargets.await(),
)
}
}
Log.d("LocalPreferences", "Loaded account from file $npub")
Log.d("LocalPreferences") { "Loaded account from file $npub" }
return result
}
private inline fun <reified T : Any> SharedPreferences.parseOrNull(key: String): T? {
val value = getString(key, null)
private inline fun <reified T : Any> parseOrNull(value: String?): T? {
if (value.isNullOrEmpty() || value == "null") {
return null
}
@@ -558,13 +590,12 @@ object LocalPreferences {
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e)
Log.w("LocalPreferences", "Error Decoding ${T::class.java} from Preferences with value $value", e)
null
}
}
private inline fun <reified T> SharedPreferences.parseEventOrNull(key: String): T? {
val value = getString(key, null)
private inline fun <reified T> parseEventOrNull(value: String?): T? {
if (value.isNullOrEmpty() || value == "null") {
return null
}
@@ -572,7 +603,7 @@ object LocalPreferences {
Event.fromJson(value) as T?
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e)
Log.w("LocalPreferences", "Error Decoding ${T::class.java} from Preferences with value $value", e)
null
}
}
@@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.nip51Lists.PinListState
import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.BlockPeopleListState
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
@@ -81,6 +82,7 @@ import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListD
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState
import com.vitorpamplona.amethyst.model.nip62Vanish.VanishRequestsState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryptionCache
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
@@ -134,7 +136,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.downloadFirstEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
@@ -143,7 +145,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
@@ -185,6 +187,7 @@ import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
@@ -197,7 +200,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
@@ -213,6 +216,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.containsAny
@@ -234,9 +238,9 @@ import kotlin.coroutines.cancellation.CancellationException
class Account(
val settings: AccountSettings = AccountSettings(KeyPair()),
override val signer: NostrSigner,
val geolocationFlow: StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: NWCPaymentFilterAssembler,
val otsResolverBuilder: OtsResolverBuilder,
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val otsResolverBuilder: () -> OtsResolver,
val cache: LocalCache,
val client: INostrClient,
val scope: CoroutineScope,
@@ -317,8 +321,11 @@ class Account(
val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope)
val bookmarkState = BookmarkListState(signer, cache, scope)
val pinState = PinListState(signer, cache, scope)
val emoji = EmojiPackState(signer, cache, scope)
val vanish = VanishRequestsState(signer, cache, client, scope)
val appSpecific = AppSpecificState(signer, cache, scope, settings)
val blossomServers = BlossomServerListState(signer, cache, scope, settings)
@@ -597,7 +604,7 @@ class Account(
onResponse: (Response?) -> Unit,
) {
val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse)
client.send(event, setOf(relay))
client.publish(event, setOf(relay))
}
suspend fun sendZapPaymentRequestFor(
@@ -606,7 +613,7 @@ class Account(
onResponse: (Response?) -> Unit,
) {
val (event, relay) = nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
client.send(event, setOf(relay))
client.publish(event, setOf(relay))
}
suspend fun createZapRequestFor(
@@ -655,7 +662,7 @@ class Account(
myRelayList.addAll(it.relays)
}
client.send(deletionEvent, myRelayList)
client.publish(deletionEvent, myRelayList)
cache.justConsumeMyOwnEvent(deletionEvent)
}
}
@@ -669,7 +676,7 @@ class Account(
if (event.pubKey != signer.pubKey) return
val deletionEvent = signer.sign(DeletionEvent.build(listOf(event)))
client.send(deletionEvent, outboxRelays.flow.value + additionalRelays)
client.publish(deletionEvent, outboxRelays.flow.value + additionalRelays)
cache.justConsumeMyOwnEvent(deletionEvent)
}
@@ -692,7 +699,7 @@ class Account(
suspend fun boost(note: Note) {
RepostAction.repost(note, signer)?.let { event ->
client.send(event, computeMyReactionToNote(note, event))
client.publish(event, computeMyReactionToNote(note, event))
cache.justConsumeMyOwnEvent(event)
}
}
@@ -714,7 +721,7 @@ class Account(
event: Event,
relays: Set<NormalizedRelayUrl>,
) {
client.send(event, relays)
client.publish(event, relays)
cache.justConsumeMyOwnEvent(event)
}
@@ -930,7 +937,7 @@ class Account(
// download the event and send it.
noteEvent.host?.let { host ->
client
.downloadFirstEvent(
.fetchFirst(
filters =
note.relays.associateWith { relay ->
listOf(
@@ -943,11 +950,11 @@ class Account(
},
)?.let { downloadedEvent ->
val toRelays = computeRelayListToBroadcast(downloadedEvent)
client.send(downloadedEvent, toRelays)
client.publish(downloadedEvent, toRelays)
}
}
} else {
client.send(noteEvent, computeRelayListToBroadcast(note))
client.publish(noteEvent, computeRelayListToBroadcast(note))
}
}
}
@@ -995,7 +1002,7 @@ class Account(
val relays = outboxRelays.flow.value + commEvent.relayUrls() + community.relays + (post.author?.inboxRelays() ?: emptyList())
cache.justConsumeMyOwnEvent(signedEvent)
client.send(signedEvent, relays)
client.publish(signedEvent, relays)
}
fun sendAutomatic(events: List<Event>) = events.forEach { sendAutomatic(it) }
@@ -1003,24 +1010,49 @@ class Account(
fun sendAutomatic(event: Event?) {
if (event == null) return
cache.justConsumeMyOwnEvent(event)
client.send(event, computeRelayListToBroadcast(event))
client.publish(event, computeRelayListToBroadcast(event))
}
suspend fun sendWebBookmark(
url: String,
title: String?,
description: String,
hashtags: List<String> = emptyList(),
) {
if (!isWriteable()) return
val template = WebBookmarkEvent.build(url, title, description, tags = hashtags)
val signedEvent = signer.sign(template)
cache.justConsumeMyOwnEvent(signedEvent)
client.publish(signedEvent, computeRelayListToBroadcast(signedEvent))
}
suspend fun deleteWebBookmark(event: WebBookmarkEvent) {
if (!isWriteable()) return
val template = DeletionEvent.build(listOf(event))
val signedEvent = signer.sign(template)
cache.justConsumeMyOwnEvent(signedEvent)
client.publish(signedEvent, computeRelayListToBroadcast(signedEvent))
}
fun sendMyPublicAndPrivateOutbox(event: Event?) {
if (event == null) return
cache.justConsumeMyOwnEvent(event)
client.send(event, outboxRelays.flow.value)
client.publish(event, outboxRelays.flow.value)
}
fun sendMyPublicAndPrivateOutbox(events: List<Event>) {
events.forEach {
client.send(it, outboxRelays.flow.value)
client.publish(it, outboxRelays.flow.value)
cache.justConsumeMyOwnEvent(it)
}
}
fun sendLiterallyEverywhere(event: Event) {
client.send(event, followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value)
client.publish(event, followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value)
cache.justConsumeMyOwnEvent(event)
}
@@ -1037,7 +1069,7 @@ class Account(
cache.justConsumeMyOwnEvent(signedEvent)
client.send(signedEvent, computeRelayListToBroadcast(signedEvent))
client.publish(signedEvent, computeRelayListToBroadcast(signedEvent))
}
}
@@ -1073,10 +1105,10 @@ class Account(
val relayList = computeRelayListToBroadcast(signedEvent)
client.send(data, relayList = relayList)
client.publish(data, relayList = relayList)
cache.justConsumeMyOwnEvent(data)
client.send(signedEvent, relayList = relayList)
client.publish(signedEvent, relayList = relayList)
cache.justConsumeMyOwnEvent(signedEvent)
return cache.getNoteIfExists(signedEvent.id)
@@ -1097,8 +1129,8 @@ class Account(
signedEvent: FileStorageHeaderEvent,
relayList: Set<NormalizedRelayUrl>,
) {
client.send(data, relayList = relayList)
client.send(signedEvent, relayList = relayList)
client.publish(data, relayList = relayList)
client.publish(signedEvent, relayList = relayList)
}
fun sendHeader(
@@ -1106,7 +1138,7 @@ class Account(
relayList: Set<NormalizedRelayUrl>,
onReady: (Note) -> Unit,
) {
client.send(signedEvent, relayList = relayList)
client.publish(signedEvent, relayList = relayList)
cache.justConsumeMyOwnEvent(signedEvent)
cache.getNoteIfExists(signedEvent.id)?.let { onReady(it) }
@@ -1253,7 +1285,7 @@ class Account(
) {
val event = signer.sign(template)
cache.justConsumeMyOwnEvent(event)
client.send(event, relayList)
client.publish(event, relayList)
}
suspend fun <T : Event> signAndSendPrivatelyOrBroadcast(
@@ -1264,9 +1296,9 @@ class Account(
cache.justConsumeMyOwnEvent(event)
val relays = relayList(event)
if (!relays.isNullOrEmpty()) {
client.send(event, relays.toSet())
client.publish(event, relays.toSet())
} else {
client.send(event, computeRelayListToBroadcast(event))
client.publish(event, computeRelayListToBroadcast(event))
}
return event
}
@@ -1286,9 +1318,9 @@ class Account(
val relayList = computeRelayListToBroadcast(note)
client.send(event, relayList)
client.publish(event, relayList)
broadcast.forEach { client.send(it, relayList) }
broadcast.forEach { client.publish(it, relayList) }
return event
}
@@ -1300,10 +1332,19 @@ class Account(
val anonymousSigner = NostrSignerInternal(KeyPair())
val event = anonymousSigner.sign(template)
val relayList = nip65RelayList.outboxFlow.value.toSet()
cache.justConsumeMyOwnEvent(event)
val note =
if (event is AddressableEvent) {
cache.getOrCreateAddressableNote(event.address())
} else {
cache.getOrCreateNote(event.id)
}
client.send(event, relayList)
broadcast.forEach { client.send(it, relayList) }
val relayList = computeRelayListToBroadcast(note)
client.publish(event, relayList)
broadcast.forEach { client.publish(it, relayList) }
return event
}
@@ -1335,7 +1376,7 @@ class Account(
extraNotesToBroadcast: List<Event>,
) {
cache.justConsumeMyOwnEvent(event)
extraNotesToBroadcast.forEach { client.send(it, relays) }
extraNotesToBroadcast.forEach { client.publish(it, relays) }
}
suspend fun createAndSendDraftIgnoreErrors(
@@ -1367,9 +1408,9 @@ class Account(
val relayList = (privateStorageRelayList.flow.value + localRelayList.flow.value + extraRelays).toSet()
if (relayList.isNotEmpty()) {
client.send(draftEvent, relayList)
client.publish(draftEvent, relayList)
broadcast.forEach {
client.send(it, relayList.toSet())
client.publish(it, relayList.toSet())
}
}
}
@@ -1396,8 +1437,8 @@ class Account(
cache.justConsumeMyOwnEvent(deletionEvent)
if (relayList.isNotEmpty()) {
client.send(deletedDraft, relayList)
client.send(deletionEvent, relayList)
client.publish(deletedDraft, relayList)
client.publish(deletionEvent, relayList)
}
}
@@ -1420,9 +1461,9 @@ class Account(
val relayList = privateStorageRelayList.flow.value + localRelayList.flow.value
if (relayList.isNotEmpty()) {
client.send(event, relayList + noteRelays)
client.publish(event, relayList + noteRelays)
} else {
client.send(event, outboxRelays.flow.value + noteRelays)
client.publish(event, outboxRelays.flow.value + noteRelays)
}
cache.justConsumeMyOwnEvent(event)
}
@@ -1446,9 +1487,9 @@ class Account(
val relayList = privateStorageRelayList.flow.value + localRelayList.flow.value
if (relayList.isNotEmpty()) {
client.send(event, relayList + noteRelays)
client.publish(event, relayList + noteRelays)
} else {
client.send(event, outboxRelays.flow.value + noteRelays)
client.publish(event, outboxRelays.flow.value + noteRelays)
}
cache.justConsumeMyOwnEvent(event)
}
@@ -1509,9 +1550,9 @@ class Account(
} else {
val it = signer.sign(template)
cache.justConsumeMyOwnEvent(it)
client.send(it, relayList = relayList)
client.publish(it, relayList = relayList)
mapEntitiesToNotes(quotes).forEach { it.event?.let { client.send(it, relayList = relayList) } }
mapEntitiesToNotes(quotes).forEach { it.event?.let { client.publish(it, relayList = relayList) } }
}
}
@@ -1554,9 +1595,9 @@ class Account(
} else {
val it = signer.sign(template)
cache.justConsumeMyOwnEvent(it)
client.send(it, relayList = relayList)
client.publish(it, relayList = relayList)
broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relayList) } }
broadcastNotes.forEach { it.event?.let { client.publish(it, relayList = relayList) } }
}
}
@@ -1581,8 +1622,8 @@ class Account(
val newEvent = signer.sign(template)
cache.justConsumeMyOwnEvent(newEvent)
client.send(newEvent, relayList = relays)
client.send(bountyEvent, relayList = relays)
client.publish(newEvent, relayList = relays)
client.publish(bountyEvent, relayList = relays)
}
suspend fun sendEdit(
@@ -1609,9 +1650,9 @@ class Account(
val note = cache.getOrCreateNote(event.id)
val relayList = computeRelayListToBroadcast(note)
client.send(event, relayList = relayList)
client.publish(event, relayList = relayList)
broadcast.forEach { client.send(it, relayList) }
broadcast.forEach { client.publish(it, relayList) }
}
override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate<PrivateDmEvent>) {
@@ -1622,7 +1663,7 @@ class Account(
val destinationRelays = recipient?.let { cache.getOrCreateUser(it).dmInboxRelays() } ?: emptyList()
cache.justConsumeMyOwnEvent(newEvent)
client.send(newEvent, outboxRelays.flow.value + destinationRelays)
client.publish(newEvent, outboxRelays.flow.value + destinationRelays)
}
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
@@ -1640,7 +1681,7 @@ class Account(
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
wraps.forEach { wrap ->
val relayList = computeRelayListToBroadcast(wrap)
client.send(wrap, relayList)
client.publish(wrap, relayList)
}
}
@@ -1661,7 +1702,7 @@ class Account(
}
val relayList = computeRelayListToBroadcast(wrap)
client.send(wrap, relayList)
client.publish(wrap, relayList)
}
}
@@ -1774,6 +1815,43 @@ class Account(
cache.justConsumeMyOwnEvent(event)
}
suspend fun addPin(note: Note) {
if (!isWriteable() || note.isDraft()) return
sendMyPublicAndPrivateOutbox(pinState.addPin(note))
}
suspend fun removePin(note: Note) {
if (!isWriteable() || note.isDraft()) return
val event = pinState.removePin(note)
if (event != null) {
sendMyPublicAndPrivateOutbox(event)
}
}
suspend fun createAddPinEvent(note: Note): Pair<Event, Set<NormalizedRelayUrl>>? {
if (!isWriteable() || note.isDraft()) return null
val event = pinState.addPin(note)
val relays = outboxRelays.flow.value
return event to relays
}
suspend fun createRemovePinEvent(note: Note): Pair<Event, Set<NormalizedRelayUrl>>? {
if (!isWriteable() || note.isDraft()) return null
val event = pinState.removePin(note) ?: return null
val relays = outboxRelays.flow.value
return event to relays
}
fun consumePinEvent(event: Event) {
cache.justConsumeMyOwnEvent(event)
}
suspend fun createAuthEvent(
relay: NormalizedRelayUrl,
challenge: String,
@@ -1803,7 +1881,7 @@ class Account(
onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit,
) {
val relays = nip65RelayList.inboxFlow.value.toSet()
val request = NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey.pubkeyHex, signer.pubKey, relays, signer)
val request = signer.sign<NIP90ContentDiscoveryRequestEvent>(NIP90ContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays))
val relayList =
dvmPublicKey.inboxRelays()?.toSet()?.ifEmpty { null }
@@ -1812,7 +1890,7 @@ class Account(
cache.justConsumeMyOwnEvent(request)
onReady(request)
delay(100)
client.send(request, relayList)
client.publish(request, relayList)
}
fun cachedDecryptContent(note: Note): String? = cachedDecryptContent(note.event)
@@ -1970,6 +2048,31 @@ class Account(
suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays))
suspend fun requestToVanish(
relays: List<NormalizedRelayUrl>,
reason: String,
createdAt: Long,
) {
if (!isWriteable() || relays.isEmpty()) return
val template = RequestToVanishEvent.build(relays, reason, createdAt)
val signedEvent = signer.sign(template)
cache.justConsumeMyOwnEvent(signedEvent)
client.publish(signedEvent, outboxRelays.flow.value + relays.toSet())
}
suspend fun requestToVanishFromEverywhere(
reason: String,
createdAt: Long,
) {
if (!isWriteable()) return
val template = RequestToVanishEvent.buildVanishFromEverywhere(reason, createdAt)
val signedEvent = signer.sign(template)
cache.justConsumeMyOwnEvent(signedEvent)
client.publish(signedEvent, followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value)
}
suspend fun sendNip65RelayList(relays: List<AdvertisedRelayInfo>) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays))
suspend fun sendBlossomServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers))
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
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.crypto.KeyPair
@@ -57,6 +56,7 @@ import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -87,7 +87,7 @@ class AntiSpamFilter {
val link1 = njumpLink(NAddress.create(existingAddress.kind, existingAddress.pubKeyHex, existingAddress.dTag, relay))
val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay))
Log.w("Duplicated/SPAM", "${relay?.url} $link1 $link2")
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
// Log down offenders
val spammer = logOffender(hash, event)
@@ -114,7 +114,7 @@ class AntiSpamFilter {
val link1 = njumpLink(NEvent.create(existingEvent, null, null, relay))
val link2 = njumpLink(NEvent.create(event.id, null, null, relay))
Log.w("Duplicated/SPAM", "${relay?.url} $link1 $link2")
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
// Log down offenders
val spammer = logOffender(hash, event)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.cache.CacheCollectors
@@ -18,6 +18,8 @@
* 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.
*/
@file:Suppress("DEPRECATION")
package com.vitorpamplona.amethyst.model
import android.util.LruCache
@@ -25,6 +27,7 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
@@ -59,9 +62,6 @@ import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
@@ -173,6 +173,9 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent
@@ -181,6 +184,8 @@ import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
@@ -192,22 +197,27 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
@@ -336,10 +346,10 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
fun observeEvents(filter: Filter): Flow<List<Event>> =
fun <T : Event> observeEvents(filter: Filter): Flow<List<T>> =
callbackFlow {
val cachedFilter =
EventListMatchingFilter(filter, this@LocalCache::filter) {
EventListMatchingFilter<T>(filter, this@LocalCache::filter) {
trySend(it)
}
@@ -352,7 +362,8 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents(filter).map { it.firstNotNullOfOrNull { it as? T } }
@Suppress("UNCHECKED_CAST")
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() }
fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() }
@@ -732,6 +743,12 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: RequestToVanishEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: NIP90UserDiscoveryRequestEvent,
relay: NormalizedRelayUrl?,
@@ -1157,6 +1174,18 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: RootSiteEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: NamedSiteEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: ChannelListEvent,
relay: NormalizedRelayUrl?,
@@ -1337,6 +1366,18 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
private fun consume(
event: RelayDiscoveryEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: RelayMonitorEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: StatusEvent,
relay: NormalizedRelayUrl?,
@@ -1433,6 +1474,12 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: WebBookmarkEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: CalendarDateSlotEvent,
relay: NormalizedRelayUrl?,
@@ -1899,7 +1946,7 @@ object LocalCache : ILocalCache, ICacheProvider {
if (new) {
val channel = checkGetOrCreatePublicChatChannel(channelId)
if (channel == null) {
Log.w("LocalCache", "Unable to create public chat channel for event ${event.toJson()}")
Log.w("LocalCache") { "Unable to create public chat channel for event ${event.toJson()}" }
return false
}
@@ -1987,7 +2034,7 @@ object LocalCache : ILocalCache, ICacheProvider {
val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) {
Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}")
Log.e("ZP") { "Zap Request not found. Unable to process Zap {${event.toJson()}}" }
return false
}
@@ -2110,6 +2157,12 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: ChatEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeRegularEvent(event, relay, wasVerified)
fun consume(
event: PollEvent,
relay: NormalizedRelayUrl?,
@@ -2484,7 +2537,7 @@ object LocalCache : ILocalCache, ICacheProvider {
suspend fun findEarliestOtsForNote(
note: Note,
otsVerifCache: VerificationStateCache,
otsVerifCacheBuilder: () -> VerificationStateCache,
): Long? {
checkNotInMainThread()
@@ -2495,7 +2548,7 @@ object LocalCache : ILocalCache, ICacheProvider {
notes.mapNotNull { _, item ->
val noteEvent = item.event
if ((noteEvent is OtsEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time))) {
val cachedTime = (otsVerifCache.justCache(noteEvent) as? VerificationState.Verified)?.verifiedTime
val cachedTime = (otsVerifCacheBuilder().justCache(noteEvent) as? VerificationState.Verified)?.verifiedTime
if (cachedTime != null) {
if (minTime == null || cachedTime < (minTime ?: Long.MAX_VALUE)) {
minTime = cachedTime
@@ -2511,7 +2564,7 @@ object LocalCache : ILocalCache, ICacheProvider {
}
candidates.forEach { noteEvent ->
(otsVerifCache.cacheVerify(noteEvent) as? VerificationState.Verified)?.verifiedTime?.let { stampedTime ->
(otsVerifCacheBuilder().cacheVerify(noteEvent) as? VerificationState.Verified)?.verifiedTime?.let { stampedTime ->
if (minTime == null || stampedTime < (minTime ?: Long.MAX_VALUE)) {
minTime = stampedTime
}
@@ -2550,17 +2603,17 @@ object LocalCache : ILocalCache, ICacheProvider {
}
fun cleanMemory() {
Log.d("LargeCache", "Notes cleanup started. Current size: ${notes.size()}")
Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" }
notes.cleanUp()
Log.d("LargeCache", "Notes cleanup completed. Remaining size: ${notes.size()}")
Log.d("LargeCache") { "Notes cleanup completed. Remaining size: ${notes.size()}" }
Log.d("LargeCache", "Addressables cleanup started. Current size: ${addressables.size()}")
Log.d("LargeCache") { "Addressables cleanup started. Current size: ${addressables.size()}" }
addressables.cleanUp()
Log.d("LargeCache", "Addressables cleanup completed. Remaining size: ${addressables.size()}")
Log.d("LargeCache") { "Addressables cleanup completed. Remaining size: ${addressables.size()}" }
Log.d("LargeCache", "Users cleanup started. Current size: ${users.size()}")
Log.d("LargeCache") { "Users cleanup started. Current size: ${users.size()}" }
users.cleanUp()
Log.d("LargeCache", "Users cleanup completed. Remaining size: ${users.size()}")
Log.d("LargeCache") { "Users cleanup completed. Remaining size: ${users.size()}" }
}
fun cleanObservers() {
@@ -2892,7 +2945,7 @@ object LocalCache : ILocalCache, ICacheProvider {
event.checkSignature()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("Event Verification Failed", "Kind: ${event.kind} from ${dateFormatter(event.createdAt, "", "")} with message ${e.message}")
Log.w("Event Verification Failed") { "Kind: ${event.kind} from ${dateFormatter(event.createdAt, "", "")} with message ${e.message}" }
}
false
} else {
@@ -2966,7 +3019,7 @@ object LocalCache : ILocalCache, ICacheProvider {
getNoteIfExists(deletionEvent.id)?.let { note ->
if (!note.hasRelay(relay.url)) {
if (isDebug) {
Log.d("LocalCache", "Updating ${relay.url.url} with a Deletion Event ${event.id} ${deletionEvent.id} because of ${event.toJson()} with ${deletionEvent.toJson()}")
Log.d("LocalCache") { "Updating ${relay.url.url} with a Deletion Event ${event.id} ${deletionEvent.id} because of ${event.toJson()} with ${deletionEvent.toJson()}" }
}
relay.sendIfConnected(EventCmd(deletionEvent))
note.addRelay(relay.url)
@@ -2983,7 +3036,7 @@ object LocalCache : ILocalCache, ICacheProvider {
note.event?.let { existingEvent ->
if (existingEvent.createdAt > event.createdAt && !note.hasRelay(relay.url) && !deletionIndex.hasBeenDeleted(event) && !event.isExpired()) {
if (isDebug) {
Log.d("LocalCache", "Updating ${relay.url.url} with a new version of ${event.kind} ${event.id} to ${existingEvent.id}")
Log.d("LocalCache") { "Updating ${relay.url.url} with a new version of ${event.kind} ${event.id} to ${existingEvent.id}" }
}
relay.sendIfConnected(EventCmd(existingEvent))
@@ -3146,6 +3199,8 @@ object LocalCache : ILocalCache, ICacheProvider {
is GitReplyEvent -> consume(event, relay, wasVerified)
is GitPatchEvent -> consume(event, relay, wasVerified)
is GitRepositoryEvent -> consume(event, relay, wasVerified)
is RootSiteEvent -> consume(event, relay, wasVerified)
is NamedSiteEvent -> consume(event, relay, wasVerified)
is ChessGameEvent -> consume(event, relay, wasVerified)
is RelayFeedsListEvent -> consume(event, relay, wasVerified)
is JesterEvent -> consume(event, relay, wasVerified)
@@ -3185,10 +3240,14 @@ object LocalCache : ILocalCache, ICacheProvider {
is PinListEvent -> consume(event, relay, wasVerified)
is PublicMessageEvent -> consume(event, relay, wasVerified)
is PeopleListEvent -> consume(event, relay, wasVerified)
is RequestToVanishEvent -> consume(event, relay, wasVerified)
is CodeSnippetEvent -> consume(event, relay, wasVerified)
is ZapPollEvent -> consume(event, relay, wasVerified)
is ChatEvent -> consume(event, relay, wasVerified)
is PollEvent -> consume(event, relay, wasVerified)
is PollResponseEvent -> consume(event, relay, wasVerified)
is RelayDiscoveryEvent -> consume(event, relay, wasVerified)
is RelayMonitorEvent -> consume(event, relay, wasVerified)
is ReactionEvent -> consume(event, relay, wasVerified)
is ContactCardEvent -> consume(event, relay, wasVerified)
is RelaySetEvent -> consume(event, relay, wasVerified)
@@ -3209,9 +3268,10 @@ object LocalCache : ILocalCache, ICacheProvider {
is VideoShortEvent -> consume(event, relay, wasVerified)
is VoiceEvent -> consume(event, relay, wasVerified)
is VoiceReplyEvent -> consume(event, relay, wasVerified)
is WebBookmarkEvent -> consume(event, relay, wasVerified)
is WikiNoteEvent -> consume(event, relay, wasVerified)
is PaymentTargetsEvent -> consume(event, relay, wasVerified)
else -> Log.w("Event Not Supported", "From ${relay?.url}: ${event.toJson()}").let { false }
else -> Log.w("Event Not Supported") { "From ${relay?.url}: ${event.toJson()}" }.let { false }
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -151,19 +151,19 @@ class UiSettingsFlow(
}
companion object {
fun build(torSettings: UiSettings): UiSettingsFlow =
fun build(uiSettings: UiSettings): UiSettingsFlow =
UiSettingsFlow(
MutableStateFlow(torSettings.theme),
MutableStateFlow(torSettings.preferredLanguage),
MutableStateFlow(torSettings.automaticallyShowImages),
MutableStateFlow(torSettings.automaticallyStartPlayback),
MutableStateFlow(torSettings.automaticallyShowUrlPreview),
MutableStateFlow(torSettings.automaticallyHideNavigationBars),
MutableStateFlow(torSettings.automaticallyShowProfilePictures),
MutableStateFlow(torSettings.dontShowPushNotificationSelector),
MutableStateFlow(torSettings.dontAskForNotificationPermissions),
MutableStateFlow(torSettings.featureSet),
MutableStateFlow(torSettings.gallerySet),
MutableStateFlow(uiSettings.theme),
MutableStateFlow(uiSettings.preferredLanguage),
MutableStateFlow(uiSettings.automaticallyShowImages),
MutableStateFlow(uiSettings.automaticallyStartPlayback),
MutableStateFlow(uiSettings.automaticallyShowUrlPreview),
MutableStateFlow(uiSettings.automaticallyHideNavigationBars),
MutableStateFlow(uiSettings.automaticallyShowProfilePictures),
MutableStateFlow(uiSettings.dontShowPushNotificationSelector),
MutableStateFlow(uiSettings.dontAskForNotificationPermissions),
MutableStateFlow(uiSettings.featureSet),
MutableStateFlow(uiSettings.gallerySet),
)
}
}
@@ -31,8 +31,9 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -44,10 +45,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
class AccountCacheState(
val geolocationFlow: StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: NWCPaymentFilterAssembler,
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val contentResolverFn: () -> ContentResolver,
val otsResolverBuilder: OtsResolverBuilder,
val otsResolverBuilder: () -> OtsResolver,
val cache: LocalCache,
val client: INostrClient,
) {
@@ -91,9 +92,11 @@ class AccountCacheState(
val cached = accounts.value[signer.pubKey]
if (cached != null) return cached
val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME)
return Account(
settings = accountSettings,
signer = signer,
signer = signerWithClientTag,
geolocationFlow = geolocationFlow,
nwcFilterAssembler = nwcFilterAssembler,
otsResolverBuilder = otsResolverBuilder,
@@ -122,4 +125,8 @@ class AccountCacheState(
emptyMap()
}
}
companion object {
const val CLIENT_TAG_NAME = "Amethyst"
}
}
@@ -91,7 +91,7 @@ class PrivateStorageRelayListState(
init {
settings.backupPrivateHomeRelayList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved private home relay list ${event.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved private home relay list ${event.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
@@ -101,7 +101,7 @@ class PrivateStorageRelayListState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Private Home Relay List Collector Start")
getPrivateOutboxRelayListFlow().collect { noteState ->
Log.d("AccountRegisterObservers", "Updating Private Home Relay List for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Updating Private Home Relay List for ${signer.pubKey}" }
(noteState.note.event as? PrivateOutboxRelayListEvent)?.let {
settings.updatePrivateHomeRelayList(it)
}
@@ -132,7 +132,7 @@ class UserMetadataState(
init {
settings.backupUserMetadata?.let {
Log.d("AccountRegisterObservers", "Loading saved user metadata ${it.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved user metadata ${it.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
@@ -142,7 +142,7 @@ class UserMetadataState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Kind 0 Collector Start")
getUserMetadataFlow().collect {
Log.d("AccountRegisterObservers", "Updating Kind 0 ${user.toBestDisplayName()}")
Log.d("AccountRegisterObservers") { "Updating Kind 0 ${user.toBestDisplayName()}" }
(it.note.event as? MetadataEvent)?.let {
settings.updateUserMetadata(it)
}
@@ -162,7 +162,7 @@ class Kind3FollowListState(
init {
settings.backupContactList?.let {
Log.d("AccountRegisterObservers", "Loading saved ${it.tags.size} contacts")
Log.d("AccountRegisterObservers") { "Loading saved ${it.tags.size} contacts" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
@@ -172,7 +172,7 @@ class Kind3FollowListState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Kind 3 Collector Start")
getFollowListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Kind 3 ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Updating Kind 3 ${signer.pubKey}" }
(it.note.event as? ContactListEvent)?.let {
settings.updateContactListTo(it)
}
@@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
class IncomingOtsEventVerifier(
private val otsVerifCache: VerificationStateCache,
private val otsVerifCache: () -> VerificationStateCache,
private val cache: LocalCache,
private val scope: CoroutineScope,
) {
@@ -52,7 +52,7 @@ class IncomingOtsEventVerifier(
suspend fun consume(note: Note) {
note.event?.let { event ->
if (event is OtsEvent) {
otsVerifCache.cacheVerify(event)
otsVerifCache().cacheVerify(event)
}
}
}
@@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
@@ -37,7 +37,7 @@ import java.util.Base64
class OtsState(
val signer: NostrSigner,
val cache: LocalCache,
val otsResolver: OtsResolverBuilder,
val otsResolver: () -> OtsResolver,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
@@ -55,10 +55,10 @@ class OtsState(
}
suspend fun updateAttestations(): List<OtsEvent> {
Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations")
Log.d("Pending Attestations") { "Updating ${settings.pendingAttestations.value.size} pending attestations" }
return settings.pendingAttestations.value.toList().mapNotNull { (key, value) ->
val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(value), key, otsResolver.build())
val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(value), key, otsResolver())
if (otsState != null) {
val hint = cache.getNoteIfExists(key)?.toEventHint<Event>()
@@ -96,7 +96,7 @@ class OtsState(
Base64.getEncoder().encodeToString(
OtsEvent.stamp(
id,
otsResolver.build(),
otsResolver(),
),
),
)
@@ -46,7 +46,7 @@ fun loadRelayInfo(
value = it
},
onError = { url, errorCode, exceptionMessage ->
Log.e("RelayInfo", "Error loading relay info for ${relay.url}: $errorCode - $exceptionMessage")
Log.e("RelayInfo") { "Error loading relay info for ${relay.url}: $errorCode - $exceptionMessage" }
},
)
}
@@ -88,7 +88,7 @@ class DmRelayListState(
init {
settings.backupDMRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved DM Relay List ${it.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved DM Relay List ${it.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(it)
@@ -98,7 +98,7 @@ class DmRelayListState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "NIP-17 Relay List Collector Start")
getDMRelayListFlow().collect {
Log.d("AccountRegisterObservers", "Updating DM Relay List for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Updating DM Relay List for ${signer.pubKey}" }
(it.note.event as? ChatMessageRelayListEvent)?.let {
settings.updateDMRelayList(it)
}
@@ -64,7 +64,7 @@ import kotlinx.coroutines.launch
*/
class NwcSignerState(
val signer: NostrSigner,
val nwcFilterAssembler: NWCPaymentFilterAssembler,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val cache: LocalCache,
val scope: CoroutineScope,
val nip47Setup: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?>,
@@ -163,11 +163,13 @@ class NwcSignerState(
relay = walletService.relayUri,
)
nwcFilterAssembler.subscribe(filter)
val assembler = nwcFilterAssembler()
assembler.subscribe(filter)
scope.launch(Dispatchers.IO) {
delay(60000)
nwcFilterAssembler.unsubscribe(filter)
assembler.unsubscribe(filter)
}
cache.consume(event, null, true, walletService.relayUri) {
@@ -204,11 +206,13 @@ class NwcSignerState(
relay = walletService.relayUri,
)
nwcFilterAssembler.subscribe(filter)
val assembler = nwcFilterAssembler()
assembler.subscribe(filter)
scope.launch(Dispatchers.IO) {
delay(60000) // waits 1 minute to complete payment.
nwcFilterAssembler.unsubscribe(filter)
assembler.unsubscribe(filter)
}
cache.consume(event, zappedNote, true, walletService.relayUri) {
@@ -20,282 +20,4 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
@Stable
class BookmarkListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
) {
class BookmarkList(
val public: List<Note> = emptyList(),
val private: List<Note> = emptyList(),
)
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val bookmarkList = cache.getOrCreateAddressableNote(getBookmarkListAddress())
fun getBookmarkListAddress() = BookmarkListEvent.createBookmarkAddress(signer.pubKey)
fun getBookmarkListFlow(): StateFlow<NoteState> = bookmarkList.flow().metadata.stateFlow
fun getBookmarkList(): BookmarkListEvent? = bookmarkList.event as? BookmarkListEvent
fun publicBookmarks(note: Note): List<BookmarkIdTag> {
val noteEvent = note.event as? BookmarkListEvent
return noteEvent?.publicBookmarks() ?: emptyList()
}
suspend fun privateBookmarks(note: Note): List<BookmarkIdTag> {
val noteEvent = note.event as? BookmarkListEvent
return noteEvent?.privateBookmarks(signer) ?: emptyList()
}
@OptIn(FlowPreview::class)
val publicBookmarks: StateFlow<List<BookmarkIdTag>> =
getBookmarkListFlow()
.map { noteState ->
publicBookmarks(noteState.note)
}.onStart {
emit(publicBookmarks(bookmarkList))
}.debounce(100)
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
@OptIn(FlowPreview::class)
val privateBookmarks: StateFlow<List<BookmarkIdTag>> =
getBookmarkListFlow()
.map { noteState ->
privateBookmarks(noteState.note)
}.onStart {
emit(privateBookmarks(bookmarkList))
}.debounce(100)
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
val publicBookmarkEventIdSet =
publicBookmarks
.map { bookmark ->
bookmark
.mapNotNull {
if (it is EventBookmark) it.eventId else null
}.toSet()
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
val publicBookmarkAddressIdSet =
publicBookmarks
.map { bookmark ->
bookmark
.mapNotNull {
if (it is AddressBookmark) it.address else null
}.toSet()
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
val privateBookmarkEventIdSet =
privateBookmarks
.map { bookmark ->
bookmark
.mapNotNull {
if (it is EventBookmark) it.eventId else null
}.toSet()
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
val privateBookmarkAddressIdSet =
privateBookmarks
.map { bookmark ->
bookmark
.mapNotNull {
if (it is AddressBookmark) it.address else null
}.toSet()
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
fun bookmarkList(
privateBookmarks: List<BookmarkIdTag>,
publicBookmarks: List<BookmarkIdTag>,
): BookmarkList =
BookmarkList(
public =
publicBookmarks
.mapNotNull {
when (it) {
is EventBookmark -> cache.checkGetOrCreateNote(it.eventId)
is AddressBookmark -> cache.getOrCreateAddressableNote(it.address)
}
}.reversed(),
private =
privateBookmarks
.mapNotNull {
when (it) {
is EventBookmark -> cache.checkGetOrCreateNote(it.eventId)
is AddressBookmark -> cache.getOrCreateAddressableNote(it.address)
}
}.reversed(),
)
@OptIn(FlowPreview::class)
val bookmarks: StateFlow<BookmarkList> =
combineTransform(privateBookmarks, publicBookmarks) { private, public ->
emit(bookmarkList(private, public))
}.onStart {
emit(bookmarkList(privateBookmarks.value, publicBookmarks.value))
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
BookmarkList(),
)
fun isInPrivateBookmarks(note: Note): Boolean {
if (!signer.isWriteable()) return false
return if (note is AddressableNote) {
privateBookmarkAddressIdSet.value.contains(note.address)
} else {
privateBookmarkEventIdSet.value.contains(note.idHex)
}
}
fun isInPublicBookmarks(note: Note): Boolean =
if (note is AddressableNote) {
publicBookmarkAddressIdSet.value.contains(note.address)
} else {
publicBookmarkEventIdSet.value.contains(note.idHex)
}
suspend fun addBookmark(
note: Note,
isPrivate: Boolean,
): BookmarkListEvent {
val bookmarkList = getBookmarkList()
return if (bookmarkList == null) {
if (note is AddressableNote) {
BookmarkListEvent.create(
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
isPrivate = isPrivate,
signer = signer,
)
} else {
BookmarkListEvent.create(
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
isPrivate = isPrivate,
signer = signer,
)
}
} else {
if (note is AddressableNote) {
BookmarkListEvent.add(
earlierVersion = bookmarkList,
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
isPrivate = isPrivate,
signer = signer,
)
} else {
BookmarkListEvent.add(
earlierVersion = bookmarkList,
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
isPrivate = isPrivate,
signer = signer,
)
}
}
}
suspend fun removeBookmark(
note: Note,
isPrivate: Boolean,
): BookmarkListEvent? {
val bookmarkList = getBookmarkList()
return if (bookmarkList != null) {
if (note is AddressableNote) {
BookmarkListEvent.remove(
earlierVersion = bookmarkList,
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
isPrivate = isPrivate,
signer = signer,
)
} else {
BookmarkListEvent.remove(
earlierVersion = bookmarkList,
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
isPrivate = isPrivate,
signer = signer,
)
}
} else {
null
}
}
suspend fun removeBookmark(note: Note): BookmarkListEvent? {
val bookmarkList = getBookmarkList()
return if (bookmarkList != null) {
if (note is AddressableNote) {
BookmarkListEvent.remove(
earlierVersion = bookmarkList,
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
signer = signer,
)
} else {
BookmarkListEvent.remove(
earlierVersion = bookmarkList,
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
signer = signer,
)
}
} else {
null
}
}
}
typealias BookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState
@@ -0,0 +1,130 @@
/*
* 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.model.nip51Lists
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
@Stable
class PinListState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
) {
val pinList = cache.getOrCreateAddressableNote(PinListEvent.createPinAddress(signer.pubKey))
fun getPinListFlow(): StateFlow<NoteState> = pinList.flow().metadata.stateFlow
fun getPinList(): PinListEvent? = pinList.event as? PinListEvent
fun pinnedEvents(note: Note): List<EventBookmark> {
val noteEvent = note.event as? PinListEvent
return noteEvent?.pinnedEvents() ?: emptyList()
}
@OptIn(FlowPreview::class)
val pinnedNotes: StateFlow<List<EventBookmark>> =
getPinListFlow()
.map { noteState ->
pinnedEvents(noteState.note)
}.onStart {
emit(pinnedEvents(pinList))
}.debounce(100)
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
val pinnedEventIdSet: StateFlow<Set<String>> =
pinnedNotes
.map { pins ->
pins.map { it.eventId }.toSet()
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
@OptIn(FlowPreview::class)
val pinnedNotesList: StateFlow<List<Note>> =
pinnedNotes
.map { pins ->
pins.mapNotNull { cache.checkGetOrCreateNote(it.eventId) }.reversed()
}.onStart {
emit(
pinnedNotes.value.mapNotNull { cache.checkGetOrCreateNote(it.eventId) }.reversed(),
)
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
emptyList(),
)
fun isPinned(note: Note): Boolean = pinnedEventIdSet.value.contains(note.idHex)
suspend fun addPin(note: Note): PinListEvent {
val currentList = getPinList()
val pin = EventBookmark(note.idHex, note.relayHintUrl())
return if (currentList == null) {
PinListEvent.create(
pin = pin,
signer = signer,
)
} else {
PinListEvent.add(
earlierVersion = currentList,
pin = pin,
signer = signer,
)
}
}
suspend fun removePin(note: Note): PinListEvent? {
val currentList = getPinList() ?: return null
val pin = EventBookmark(note.idHex, note.relayHintUrl())
return PinListEvent.remove(
earlierVersion = currentList,
pin = pin,
signer = signer,
)
}
}
@@ -93,7 +93,7 @@ class BlockedRelayListState(
init {
settings.backupBlockedRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved Blocked relay list ${it.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved Blocked relay list ${it.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
}
@@ -101,7 +101,7 @@ class BlockedRelayListState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Blocked Relay List Collector Start")
getBlockedRelayListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Blocked Relay List for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Updating Blocked Relay List for ${signer.pubKey}" }
(it.note.event as? BlockedRelayListEvent)?.let {
settings.updateBlockedRelayList(it)
}
@@ -106,7 +106,7 @@ class GeohashListState(
init {
settings.backupGeohashList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved Geohash list ${event.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved Geohash list ${event.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
@@ -116,7 +116,7 @@ class GeohashListState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Geohash List Collector Start")
getGeohashListFlow().collect { noteState ->
Log.d("AccountRegisterObservers", "Geohash List for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Geohash List for ${signer.pubKey}" }
(noteState.note.event as? GeohashListEvent)?.let {
settings.updateGeohashListTo(it)
}
@@ -106,7 +106,7 @@ class HashtagListState(
init {
settings.backupHashtagList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved Hashtag list ${event.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved Hashtag list ${event.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
@@ -116,7 +116,7 @@ class HashtagListState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Hashtag List Collector Start")
getHashtagListFlow().collect {
Log.d("AccountRegisterObservers", "Hashtag List for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Hashtag List for ${signer.pubKey}" }
(it.note.event as? HashtagListEvent)?.let {
settings.updateHashtagListTo(it)
}
@@ -103,7 +103,7 @@ class IndexerRelayListState(
init {
settings.backupIndexRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved index relay list ${it.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved index relay list ${it.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
}
@@ -111,7 +111,7 @@ class IndexerRelayListState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Index Relay List Collector Start")
getIndexerRelayListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Index Relay List for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Updating Index Relay List for ${signer.pubKey}" }
(it.note.event as? IndexerRelayListEvent)?.let {
settings.updateIndexRelayList(it)
}
@@ -141,7 +141,7 @@ class MuteListState(
init {
settings.backupMuteList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved mute list ${event.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved mute list ${event.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
@@ -151,7 +151,7 @@ class MuteListState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Mute List Collector Start")
getMuteListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Mute List for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Updating Mute List for ${signer.pubKey}" }
(it.note.event as? MuteListEvent)?.let {
settings.updateMuteList(it)
}
@@ -115,7 +115,7 @@ class RelayFeedListState(
init {
settings.backupRelayFeedsList?.let {
Log.d("AccountRegisterObservers", "Loading saved relay feeds list ${it.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved relay feeds list ${it.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
}
@@ -123,7 +123,7 @@ class RelayFeedListState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Relay feeds list Collector Start")
getRelayFeedsListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Relay feeds list for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Updating Relay feeds list for ${signer.pubKey}" }
(it.note.event as? RelayFeedsListEvent)?.let {
settings.updateRelayFeedList(it)
}
@@ -42,6 +42,7 @@ open class GenericRelayListCache<T : PrivateTagArrayEvent>(
suspend fun relays(event: T) = cachedPrivateLists.mergeTagList(event).relaySet()
@Suppress("UNCHECKED_CAST")
fun fastStartValueForRelayList(note: Note): RelayListCard {
val noteEvent = note.event as? T
return if (noteEvent != null) {
@@ -52,6 +53,7 @@ open class GenericRelayListCache<T : PrivateTagArrayEvent>(
}
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("UNCHECKED_CAST")
fun observeDecryptedRelayList(note: Note): Flow<RelayListCard> =
note
.flow()
@@ -103,7 +103,7 @@ class SearchRelayListState(
init {
settings.backupSearchRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved search relay list ${it.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved search relay list ${it.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
}
@@ -111,7 +111,7 @@ class SearchRelayListState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Search Relay List Collector Start")
getSearchRelayListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Search Relay List for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Updating Search Relay List for ${signer.pubKey}" }
(it.note.event as? SearchRelayListEvent)?.let {
settings.updateSearchRelayList(it)
}
@@ -90,7 +90,7 @@ class TrustedRelayListState(
init {
settings.backupTrustedRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved Trusted relay list ${it.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved Trusted relay list ${it.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
}
@@ -98,7 +98,7 @@ class TrustedRelayListState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Trusted Relay List Collector Start")
getTrustedRelayListFlow().collect {
Log.d("AccountRegisterObservers", "Updating Trusted Relay List for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Updating Trusted Relay List for ${signer.pubKey}" }
(it.note.event as? TrustedRelayListEvent)?.let {
settings.updateTrustedRelayList(it)
}
@@ -0,0 +1,133 @@
/*
* 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.model.nip62Vanish
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
@Stable
data class VanishEventItem(
val event: RequestToVanishEvent,
val relays: List<NormalizedRelayUrl>,
val isAllRelays: Boolean,
val complianceResults: MutableStateFlow<Map<NormalizedRelayUrl, ComplianceStatus>> =
MutableStateFlow(
relays.associateWith { ComplianceStatus.UNTESTED },
),
)
enum class ComplianceStatus {
UNTESTED,
TESTING,
COMPLIANT,
NON_COMPLIANT,
ERROR,
}
class VanishRequestsState(
val signer: NostrSigner,
val cache: LocalCache,
val client: INostrClient,
val scope: CoroutineScope,
) {
val noteFlow =
cache
.observeNotes(
Filter(
kinds = listOf(RequestToVanishEvent.KIND),
authors = listOf(signer.pubKey),
),
).stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyList(),
)
val testableFlow =
noteFlow
.map { notes ->
notes.mapNotNull {
val noteEvent = it.event
if (noteEvent is RequestToVanishEvent) {
VanishEventItem(noteEvent, noteEvent.vanishFromRelays(), noteEvent.vanishFromAllRelays())
} else {
null
}
}
}.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(10000),
initialValue = emptyList(),
)
suspend fun testVanishCompliance(
item: VanishEventItem,
relay: NormalizedRelayUrl,
) {
item.complianceResults.update {
it + (relay to ComplianceStatus.TESTING)
}
try {
val foundEvent =
withContext(Dispatchers.IO) {
client.fetchFirst(
relay = relay,
filter =
Filter(
authors = listOf(item.event.pubKey),
until = item.event.createdAt - 1,
limit = 1,
),
)
}
item.complianceResults.update {
it + (
relay to
if (foundEvent != null) {
ComplianceStatus.NON_COMPLIANT
} else {
ComplianceStatus.COMPLIANT
}
)
}
} catch (_: Exception) {
item.complianceResults.update {
it + (relay to ComplianceStatus.ERROR)
}
}
}
}
@@ -144,7 +144,7 @@ class Nip65RelayListState(
init {
settings.backupNIP65RelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved nip65 relay list ${it.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved nip65 relay list ${it.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
}
@@ -152,7 +152,7 @@ class Nip65RelayListState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "NIP-65 Relay List Collector Start")
getNIP65RelayListFlow().collect {
Log.d("AccountRegisterObservers", "Updating NIP-65 List for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Updating NIP-65 List for ${signer.pubKey}" }
(it.note.event as? AdvertisedRelayListEvent)?.let {
settings.updateNIP65RelayList(it)
}
@@ -155,7 +155,7 @@ class CommunityListState(
init {
settings.backupCommunityList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved Community list ${event.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved Community list ${event.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
@@ -165,7 +165,7 @@ class CommunityListState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Community List Collector Start")
getCommunityListFlow().collect {
Log.d("AccountRegisterObservers", "Community List for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Community List for ${signer.pubKey}" }
(it.note.event as? CommunityListEvent)?.let {
settings.updateCommunityListTo(it)
}
@@ -65,7 +65,7 @@ class AppSpecificState(
init {
if (settings.isWriteable()) {
settings.backupAppSpecificData?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved app specific data ${event.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved app specific data ${event.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
@@ -84,7 +84,7 @@ class AppSpecificState(
Log.d("AccountRegisterObservers", "AppSpecificData Collector Start")
getAppSpecificDataFlow().collect {
try {
Log.d("AccountRegisterObservers", "Updating AppSpecificData for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Updating AppSpecificData for ${signer.pubKey}" }
(it.note.event as? AppSpecificDataEvent)?.let {
val decrypted = signer.decrypt(it.content, it.pubKey)
try {
@@ -0,0 +1,88 @@
/*
* 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.model.nip86RelayManagement
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync
private const val CONTENT_TYPE_STRING = "application/nostr+json+rpc"
private val CONTENT_TYPE = CONTENT_TYPE_STRING.toMediaType()
class Nip86Retriever(
val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient,
) {
suspend fun execute(
client: Nip86Client,
request: Nip86Request,
): Nip86Response {
val jsonBody = client.serializeRequest(request)
val bodyBytes = jsonBody.encodeToByteArray()
val authToken = client.buildAuthHeader(bodyBytes)
val httpRequest =
Request
.Builder()
.url(client.httpUrl)
.header("Content-Type", CONTENT_TYPE_STRING)
.header("Accept", CONTENT_TYPE_STRING)
.header("Authorization", authToken)
.post(bodyBytes.toRequestBody(CONTENT_TYPE))
.build()
val httpClient = okHttpClient(client.relayUrl)
return withContext(Dispatchers.IO) {
try {
httpClient.newCall(httpRequest).executeAsync().use { response ->
val body = response.body.string()
if (response.code == 401) {
Nip86Response(error = "Unauthorized: relay rejected authentication")
} else if (!response.isSuccessful) {
Nip86Response(error = "HTTP ${response.code}: $body")
} else {
try {
client.parseResponse(body)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Nip86Retriever", "Failed to parse response ${client.httpUrl}: $body", e)
Nip86Response(error = "Failed to parse response: ${e.message}")
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Nip86Retriever", "Failed to reach relay ${client.relayUrl.url}", e)
Nip86Response(error = "Failed to reach relay: ${e.message}")
}
}
}
}
@@ -46,7 +46,7 @@ class NipA3PaymentTargetsState(
init {
settings.backupNipA3PaymentTargets?.let {
Log.d("AccountRegisterObservers", "Loading saved nipA3 Payment targets ${it.toJson()}")
Log.d("AccountRegisterObservers") { "Loading saved nipA3 Payment targets ${it.toJson()}" }
@OptIn(DelicateCoroutinesApi::class)
scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
}
@@ -54,7 +54,7 @@ class NipA3PaymentTargetsState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "nipA3 Payment targets Collector Start")
getNipA3PaymentTargetsFlow().collect {
Log.d("AccountRegisterObservers", "Updating nipA3 Payment targets for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "Updating nipA3 Payment targets for ${signer.pubKey}" }
(it.note.event as? PaymentTargetsEvent)?.let { paymentTargetsEvent ->
settings.updateNIPA3PaymentTargets(paymentTargetsEvent)
}
@@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
@@ -87,6 +88,10 @@ class BlossomServerListState(
mergeServerList(blossoms)
}.onStart {
emit(mergeServerList(flow.value))
}.onEach { servers ->
if (servers.none { it == settings.defaultFileServer }) {
settings.changeDefaultFileServer(servers.firstOrNull() ?: DEFAULT_MEDIA_SERVERS[0])
}
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
@@ -32,8 +32,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.encodeToString
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlin.coroutines.cancellation.CancellationException
@@ -58,17 +57,21 @@ class NamecoinSharedPreferences(
companion object {
val KEY_ENABLED = booleanPreferencesKey("namecoin.enabled")
val KEY_CUSTOM_SERVERS = stringPreferencesKey("namecoin.customServers")
val KEY_PINNED_CERTS = stringPreferencesKey("namecoin.pinnedCerts")
}
/**
* Current settings, loaded synchronously at init to avoid races.
*/
private val _settings =
MutableStateFlow(
runBlocking { loadFromDisk() ?: NamecoinSettings.DEFAULT },
)
private val _settings = MutableStateFlow(NamecoinSettings.DEFAULT)
val settings: StateFlow<NamecoinSettings> = _settings
init {
scope.launch {
_settings.tryEmit(loadFromDisk() ?: NamecoinSettings.DEFAULT)
}
}
/** Synchronous snapshot — safe to call from `serverListProvider` lambdas. */
val current: NamecoinSettings get() = _settings.value
@@ -99,8 +102,48 @@ class NamecoinSharedPreferences(
suspend fun reset() {
persist(NamecoinSettings.DEFAULT)
clearPinnedCerts()
}
/**
* Store a PEM-encoded certificate that the user accepted via Test Connection.
* The cert is appended to the existing list and synced to the ElectrumXClient.
*/
suspend fun addPinnedCert(pem: String) {
val existing = loadPinnedCertsFromDisk()
val updated = (existing + pem).distinct()
savePinnedCerts(updated)
}
/** Load all user-pinned certs from disk (for startup sync). */
suspend fun loadPinnedCerts(): List<String> = loadPinnedCertsFromDisk()
private suspend fun clearPinnedCerts() = savePinnedCerts(emptyList())
private suspend fun savePinnedCerts(certs: List<String>) {
try {
context.sharedPreferencesDataStore.edit { prefs ->
prefs[KEY_PINNED_CERTS] = json.encodeToString(certs)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("NamecoinPrefs") { "Error writing pinned certs: ${e.message}" }
}
}
private suspend fun loadPinnedCertsFromDisk(): List<String> =
try {
val prefs = context.sharedPreferencesDataStore.data.first()
val certsJson = prefs[KEY_PINNED_CERTS]
if (certsJson != null) {
json.decodeFromString<List<String>>(certsJson)
} else {
emptyList()
}
} catch (_: Exception) {
emptyList()
}
// ── Internal ───────────────────────────────────────────────────────
private suspend fun persist(settings: NamecoinSettings) {
@@ -115,7 +158,7 @@ class NamecoinSharedPreferences(
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("NamecoinPrefs", "Error writing DataStore: ${e.message}")
Log.e("NamecoinPrefs") { "Error writing DataStore: ${e.message}" }
}
}
@@ -137,7 +180,7 @@ class NamecoinSharedPreferences(
NamecoinSettings(enabled = enabled, customServers = servers)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("NamecoinPrefs", "Error reading DataStore: ${e.message}")
Log.e("NamecoinPrefs") { "Error reading DataStore: ${e.message}" }
null
}
}
@@ -86,7 +86,7 @@ class OtsSharedPreferences(
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("OtsPrefs", "Error writing DataStore: ${e.message}")
Log.e("OtsPrefs") { "Error writing DataStore: ${e.message}" }
}
}
@@ -97,7 +97,7 @@ class OtsSharedPreferences(
OtsSettings(customExplorerUrl = url)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("OtsPrefs", "Error reading DataStore: ${e.message}")
Log.e("OtsPrefs") { "Error reading DataStore: ${e.message}" }
null
}
}
@@ -40,14 +40,31 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.runBlocking
import kotlin.coroutines.cancellation.CancellationException
@Stable
class TorSharedPreferences(
prefs: TorSettings,
val context: Context,
val scope: CoroutineScope,
) {
// Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs
val value = TorSettingsFlow.build(prefs)
@OptIn(FlowPreview::class)
val saving =
value.propertyWatchFlow
.debounce(1000)
.distinctUntilChanged()
.onEach {
save(it, context)
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
value.toSettings(),
)
companion object {
// loads faster when individualized
val TOR_TYPE_KEY = stringPreferencesKey("tor.torType")
@@ -63,74 +80,58 @@ class TorSharedPreferences(
val MONEY_OPERATIONS_VIA_TOR_KEY = booleanPreferencesKey("tor.moneyOperationsViaTor")
val NIP05_VERIFICATIONS_VIA_TOR_KEY = booleanPreferencesKey("tor.nip05VerificationsViaTor")
val MEDIA_UPLOADS_VIA_TOR_KEY = booleanPreferencesKey("tor.mediaUploadsViaTor")
}
// Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs
val value =
runBlocking {
TorSettingsFlow.build(torPreferences() ?: TorSettings())
}
@OptIn(FlowPreview::class)
val saving =
value.propertyWatchFlow
.debounce(1000)
.distinctUntilChanged()
.onEach(::save)
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
value.toSettings(),
)
suspend fun torPreferences(): TorSettings? =
try {
// Get the preference flow and take the first value.
val preferences = context.sharedPreferencesDataStore.data.first()
TorSettings(
torType = preferences[TOR_TYPE_KEY]?.let { TorType.valueOf(it) } ?: TorType.INTERNAL,
externalSocksPort = preferences[EXTERNAL_SOCKS_PORT_KEY] ?: 9050,
onionRelaysViaTor = preferences[ONION_RELAYS_VIA_TOR_KEY] ?: true,
dmRelaysViaTor = preferences[DM_RELAYS_VIA_TOR_KEY] ?: true,
newRelaysViaTor = preferences[NEW_RELAYS_VIA_TOR_KEY] ?: true,
trustedRelaysViaTor = preferences[TRUSTED_RELAYS_VIA_TOR_KEY] ?: false,
urlPreviewsViaTor = preferences[URL_PREVIEWS_VIA_TOR_KEY] ?: false,
profilePicsViaTor = preferences[PROFILE_PICS_VIA_TOR_KEY] ?: false,
imagesViaTor = preferences[IMAGES_VIA_TOR_KEY] ?: false,
videosViaTor = preferences[VIDEOS_VIA_TOR_KEY] ?: false,
moneyOperationsViaTor = preferences[MONEY_OPERATIONS_VIA_TOR_KEY] ?: false,
nip05VerificationsViaTor = preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] ?: false,
mediaUploadsViaTor = preferences[MEDIA_UPLOADS_VIA_TOR_KEY] ?: false,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}")
null
}
suspend fun save(torSettings: TorSettings) {
try {
context.sharedPreferencesDataStore.edit { preferences ->
preferences[TOR_TYPE_KEY] = torSettings.torType.name
preferences[EXTERNAL_SOCKS_PORT_KEY] = torSettings.externalSocksPort
preferences[ONION_RELAYS_VIA_TOR_KEY] = torSettings.onionRelaysViaTor
preferences[DM_RELAYS_VIA_TOR_KEY] = torSettings.dmRelaysViaTor
preferences[NEW_RELAYS_VIA_TOR_KEY] = torSettings.newRelaysViaTor
preferences[TRUSTED_RELAYS_VIA_TOR_KEY] = torSettings.trustedRelaysViaTor
preferences[URL_PREVIEWS_VIA_TOR_KEY] = torSettings.urlPreviewsViaTor
preferences[PROFILE_PICS_VIA_TOR_KEY] = torSettings.profilePicsViaTor
preferences[IMAGES_VIA_TOR_KEY] = torSettings.imagesViaTor
preferences[VIDEOS_VIA_TOR_KEY] = torSettings.videosViaTor
preferences[MONEY_OPERATIONS_VIA_TOR_KEY] = torSettings.moneyOperationsViaTor
preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] = torSettings.nip05VerificationsViaTor
preferences[MEDIA_UPLOADS_VIA_TOR_KEY] = torSettings.mediaUploadsViaTor
suspend fun torPreferences(context: Context): TorSettings? =
try {
// Get the preference flow and take the first value.
val preferences = context.sharedPreferencesDataStore.data.first()
TorSettings(
torType = preferences[TOR_TYPE_KEY]?.let { TorType.valueOf(it) } ?: TorType.INTERNAL,
externalSocksPort = preferences[EXTERNAL_SOCKS_PORT_KEY] ?: 9050,
onionRelaysViaTor = preferences[ONION_RELAYS_VIA_TOR_KEY] ?: true,
dmRelaysViaTor = preferences[DM_RELAYS_VIA_TOR_KEY] ?: true,
newRelaysViaTor = preferences[NEW_RELAYS_VIA_TOR_KEY] ?: true,
trustedRelaysViaTor = preferences[TRUSTED_RELAYS_VIA_TOR_KEY] ?: false,
urlPreviewsViaTor = preferences[URL_PREVIEWS_VIA_TOR_KEY] ?: false,
profilePicsViaTor = preferences[PROFILE_PICS_VIA_TOR_KEY] ?: false,
imagesViaTor = preferences[IMAGES_VIA_TOR_KEY] ?: false,
videosViaTor = preferences[VIDEOS_VIA_TOR_KEY] ?: false,
moneyOperationsViaTor = preferences[MONEY_OPERATIONS_VIA_TOR_KEY] ?: false,
nip05VerificationsViaTor = preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] ?: false,
mediaUploadsViaTor = preferences[MEDIA_UPLOADS_VIA_TOR_KEY] ?: false,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences") { "Error reading DataStore preferences: ${e.message}" }
null
}
suspend fun save(
torSettings: TorSettings,
context: Context,
) {
try {
context.sharedPreferencesDataStore.edit { preferences ->
preferences[TOR_TYPE_KEY] = torSettings.torType.name
preferences[EXTERNAL_SOCKS_PORT_KEY] = torSettings.externalSocksPort
preferences[ONION_RELAYS_VIA_TOR_KEY] = torSettings.onionRelaysViaTor
preferences[DM_RELAYS_VIA_TOR_KEY] = torSettings.dmRelaysViaTor
preferences[NEW_RELAYS_VIA_TOR_KEY] = torSettings.newRelaysViaTor
preferences[TRUSTED_RELAYS_VIA_TOR_KEY] = torSettings.trustedRelaysViaTor
preferences[URL_PREVIEWS_VIA_TOR_KEY] = torSettings.urlPreviewsViaTor
preferences[PROFILE_PICS_VIA_TOR_KEY] = torSettings.profilePicsViaTor
preferences[IMAGES_VIA_TOR_KEY] = torSettings.imagesViaTor
preferences[VIDEOS_VIA_TOR_KEY] = torSettings.videosViaTor
preferences[MONEY_OPERATIONS_VIA_TOR_KEY] = torSettings.moneyOperationsViaTor
preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] = torSettings.nip05VerificationsViaTor
preferences[MEDIA_UPLOADS_VIA_TOR_KEY] = torSettings.mediaUploadsViaTor
}
} catch (e: Exception) {
if (e is CancellationException) throw e
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" }
}
} catch (e: Exception) {
if (e is CancellationException) throw e
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error saving DataStore preferences: ${e.message}")
}
}
}
@@ -49,36 +49,18 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.runBlocking
import kotlin.coroutines.cancellation.CancellationException
val Context.sharedPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(name = "shared_settings")
@Stable
class UiSharedPreferences(
prefs: UiSettings,
val context: Context,
val scope: CoroutineScope,
) {
companion object {
// loads faster when individualized
val UI_THEME = stringPreferencesKey("ui.theme")
val UI_LANGUAGE = stringPreferencesKey("ui.language")
val UI_SHOW_IMAGES = stringPreferencesKey("ui.show_images")
val UI_START_PLAYBACK = stringPreferencesKey("ui.start_playback")
val UI_SHOW_URL_PREVIEW = stringPreferencesKey("ui.show_url_preview")
val UI_HIDE_NAVIGATION_BARS = stringPreferencesKey("ui.hide_navigation_bars")
val UI_SHOW_PROFILE_PICTURES = stringPreferencesKey("ui.show_profile_pictures")
val UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR = booleanPreferencesKey("ui.dont_show_push_notification_selector")
val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions")
val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set")
val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set")
}
// UI Preferences. Makes sure to wait for it to avoid blinking themes and language preferences
val value =
runBlocking {
UiSettingsFlow.build(uiPreferences() ?: UiSettings())
}
val value = UiSettingsFlow.build(prefs)
val languageUpdate =
value.preferredLanguage
@@ -98,68 +80,87 @@ class UiSharedPreferences(
value.propertyWatchFlow
.debounce(1000)
.distinctUntilChanged()
.onEach(::save)
.flowOn(Dispatchers.IO)
.onEach {
save(it, context)
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
value.toSettings(),
)
suspend fun uiPreferences(): UiSettings? =
try {
// Get the preference flow and take the first value.
val preferences = context.sharedPreferencesDataStore.data.first()
UiSettings(
theme = preferences[UI_THEME]?.let { ThemeType.valueOf(it) } ?: ThemeType.SYSTEM,
preferredLanguage = preferences[UI_LANGUAGE]?.ifBlank { null },
automaticallyShowImages = preferences[UI_SHOW_IMAGES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyStartPlayback = preferences[UI_START_PLAYBACK]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyShowUrlPreview = preferences[UI_SHOW_URL_PREVIEW]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyHideNavigationBars = preferences[UI_HIDE_NAVIGATION_BARS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
automaticallyShowProfilePictures = preferences[UI_SHOW_PROFILE_PICTURES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
dontShowPushNotificationSelector = preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] ?: false,
dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false,
featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED,
gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}")
companion object {
// loads faster when individualized
val UI_THEME = stringPreferencesKey("ui.theme")
val UI_LANGUAGE = stringPreferencesKey("ui.language")
val UI_SHOW_IMAGES = stringPreferencesKey("ui.show_images")
val UI_START_PLAYBACK = stringPreferencesKey("ui.start_playback")
val UI_SHOW_URL_PREVIEW = stringPreferencesKey("ui.show_url_preview")
val UI_HIDE_NAVIGATION_BARS = stringPreferencesKey("ui.hide_navigation_bars")
val UI_SHOW_PROFILE_PICTURES = stringPreferencesKey("ui.show_profile_pictures")
val UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR = booleanPreferencesKey("ui.dont_show_push_notification_selector")
val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions")
val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set")
val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set")
suspend fun uiPreferences(context: Context): UiSettings? =
try {
val oldVersion = LocalPreferences.loadSharedSettings()
if (oldVersion != null) {
save(oldVersion)
}
oldVersion
// Get the preference flow and take the first value.
val preferences = context.sharedPreferencesDataStore.data.first()
UiSettings(
theme = preferences[UI_THEME]?.let { ThemeType.valueOf(it) } ?: ThemeType.SYSTEM,
preferredLanguage = preferences[UI_LANGUAGE]?.ifBlank { null },
automaticallyShowImages = preferences[UI_SHOW_IMAGES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyStartPlayback = preferences[UI_START_PLAYBACK]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyShowUrlPreview = preferences[UI_SHOW_URL_PREVIEW]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
automaticallyHideNavigationBars = preferences[UI_HIDE_NAVIGATION_BARS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
automaticallyShowProfilePictures = preferences[UI_SHOW_PROFILE_PICTURES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS,
dontShowPushNotificationSelector = preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] ?: false,
dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false,
featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED,
gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences") { "Error reading DataStore preferences: ${e.message}" }
suspend fun save(sharedSettings: UiSettings) {
try {
context.sharedPreferencesDataStore.edit { preferences ->
preferences[UI_THEME] = sharedSettings.theme.name
preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: ""
preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name
preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name
preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name
preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name
preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name
preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector
preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions
preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name
preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name
try {
val oldVersion = LocalPreferences.loadSharedSettings()
if (oldVersion != null) {
save(oldVersion, context)
}
oldVersion
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
}
suspend fun save(
sharedSettings: UiSettings,
context: Context,
) {
try {
context.sharedPreferencesDataStore.edit { preferences ->
preferences[UI_THEME] = sharedSettings.theme.name
preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: ""
preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name
preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name
preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name
preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name
preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name
preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector
preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions
preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name
preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name
}
} catch (e: Exception) {
if (e is CancellationException) throw e
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" }
}
} catch (e: Exception) {
if (e is CancellationException) throw e
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error saving DataStore preferences: ${e.message}")
}
}
}
@@ -41,6 +41,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
@Suppress("UNCHECKED_CAST")
class MergedFollowListsState(
val kind3List: Kind3FollowListState,
val peopleList: PeopleListsState,
@@ -51,7 +51,7 @@ class FeedTopNavFilterState(
val feedFilterListName: MutableStateFlow<TopFilter>,
val kind3Follows: StateFlow<Kind3FollowListState.Kind3Follows>,
val allFollows: StateFlow<MergedFollowListsState.AllFollows>,
val locationFlow: StateFlow<LocationState.LocationResult>,
val locationFlow: () -> StateFlow<LocationState.LocationResult>,
val followsRelays: StateFlow<Set<NormalizedRelayUrl>>,
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>>,
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
@@ -78,7 +78,7 @@ class FeedTopNavFilterState(
}
TopFilter.AroundMe -> {
AroundMeFeedFlow(locationFlow, followsRelays, proxyRelays)
AroundMeFeedFlow(locationFlow(), followsRelays, proxyRelays)
}
TopFilter.Chess -> {
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.torState
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.ui.tor.TorType
@@ -35,6 +36,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import okhttp3.OkHttpClient
@Stable
class TorRelayState(
val okHttpClient: DualHttpClientManager,
val torSettingsFlow: TorSettingsFlow,
@@ -20,11 +20,11 @@
*/
package com.vitorpamplona.amethyst.model.trustedAssertions
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.serviceProviderSet
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.serviceProviders
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviderSet
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders
class TrustProviderListDecryptionCache(
val signer: NostrSigner,
@@ -24,10 +24,10 @@ import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ProviderTypes
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -124,7 +124,7 @@ class TrustProviderListState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "TrustProviderList Collector Start")
getTrustProviderListFlow().collect { noteState ->
Log.d("AccountRegisterObservers", "TrustProviderList List for ${signer.pubKey}")
Log.d("AccountRegisterObservers") { "TrustProviderList List for ${signer.pubKey}" }
(noteState.note.event as? TrustProviderListEvent)?.let {
settings.updateTrustProviderListTo(it)
}
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.broadcast
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
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.OkMessage
@@ -85,12 +85,12 @@ class BroadcastTracker {
// Add to active broadcasts and cache event for retries
_activeBroadcasts.update { (it + broadcast).toImmutableList() }
Log.d(TAG, "Starting broadcast $trackingId (kind ${event.kind}) to ${relays.size} relays")
Log.d(TAG) { "Starting broadcast $trackingId (kind ${event.kind}) to ${relays.size} relays" }
val resultChannel = Channel<RelayResponse>(UNLIMITED)
val subscription =
object : IRelayClientListener {
object : RelayConnectionListener {
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
@@ -102,7 +102,7 @@ class BroadcastTracker {
result = RelayResult.Error(errorMessage),
),
)
Log.d(TAG, "[$trackingId] Cannot connect to ${relay.url}: $errorMessage")
Log.d(TAG) { "[$trackingId] Cannot connect to ${relay.url}: $errorMessage" }
}
}
@@ -114,7 +114,7 @@ class BroadcastTracker {
result = RelayResult.Error("Relay disconnected before completion"),
),
)
Log.d(TAG, "[$trackingId] Disconnected from ${relay.url}")
Log.d(TAG) { "[$trackingId] Disconnected from ${relay.url}" }
}
}
@@ -135,62 +135,65 @@ class BroadcastTracker {
RelayResult.Error(msg.message)
}
resultChannel.trySend(RelayResponse(relay.url, result))
Log.d(TAG, "[$trackingId] Response from ${relay.url}: success=${msg.success} message=${msg.message}")
Log.d(TAG) { "[$trackingId] Response from ${relay.url}: success=${msg.success} message=${msg.message}" }
}
}
}
}
}
client.subscribe(subscription)
try {
client.addConnectionListener(subscription)
val finalBroadcast =
coroutineScope {
val resultCollector =
async {
val receivedRelays = mutableSetOf<NormalizedRelayUrl>()
var currentBroadcast = broadcast
val finalBroadcast =
coroutineScope {
val resultCollector =
async {
val receivedRelays = mutableSetOf<NormalizedRelayUrl>()
var currentBroadcast = broadcast
withTimeoutOrNull(TIMEOUT_SECONDS * 1000) {
while (receivedRelays.size < relays.size) {
val response = resultChannel.receive()
withTimeoutOrNull(TIMEOUT_SECONDS * 1000) {
while (receivedRelays.size < relays.size) {
val response = resultChannel.receive()
// Skip if already received (don't override success)
if (response.relay in receivedRelays) continue
// Skip if already received (don't override success)
if (response.relay in receivedRelays) continue
receivedRelays.add(response.relay)
currentBroadcast = currentBroadcast.withResult(response.relay, response.result)
receivedRelays.add(response.relay)
currentBroadcast = currentBroadcast.withResult(response.relay, response.result)
// Update active broadcasts with new progress
_activeBroadcasts.update { list ->
list.map { if (it.id == trackingId) currentBroadcast else it }.toImmutableList()
// Update active broadcasts with new progress
_activeBroadcasts.update { list ->
list.map { if (it.id == trackingId) currentBroadcast else it }.toImmutableList()
}
}
}
// Mark remaining relays as timeout
relays.filter { it !in receivedRelays }.forEach { relay ->
currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout)
}
currentBroadcast
}
// Mark remaining relays as timeout
relays.filter { it !in receivedRelays }.forEach { relay ->
currentBroadcast = currentBroadcast.withResult(relay, RelayResult.Timeout)
}
// Send after setting up listener
client.publish(event, relays)
currentBroadcast
}
resultCollector.await()
}
// Send after setting up listener
client.send(event, relays)
resultChannel.close()
resultCollector.await()
// Remove from active, emit to completed
_activeBroadcasts.update { list ->
list.map { if (it.id == trackingId) finalBroadcast else it }.toImmutableList()
}
client.unsubscribe(subscription)
resultChannel.close()
// Remove from active, emit to completed
_activeBroadcasts.update { list ->
list.map { if (it.id == trackingId) finalBroadcast else it }.toImmutableList()
Log.d(TAG) { "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success" }
} finally {
client.removeConnectionListener(subscription)
}
Log.d(TAG, "Broadcast $trackingId complete: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
}
/**
@@ -263,7 +266,7 @@ class BroadcastTracker {
val resultChannel = Channel<RelayResponse>(UNLIMITED)
val subscription =
object : IRelayClientListener {
object : RelayConnectionListener {
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
@@ -275,7 +278,7 @@ class BroadcastTracker {
result = RelayResult.Error(errorMessage),
),
)
Log.d(TAG, "[${broadcast.id}] Retry cannot connect to ${relay.url}: $errorMessage")
Log.d(TAG) { "[${broadcast.id}] Retry cannot connect to ${relay.url}: $errorMessage" }
}
}
@@ -287,7 +290,7 @@ class BroadcastTracker {
result = RelayResult.Error("Relay disconnected before completion"),
),
)
Log.d(TAG, "[${broadcast.id}] Retry disconnected from ${relay.url}")
Log.d(TAG) { "[${broadcast.id}] Retry disconnected from ${relay.url}" }
}
}
@@ -308,14 +311,14 @@ class BroadcastTracker {
RelayResult.Error(msg.message)
}
resultChannel.trySend(RelayResponse(relay.url, result))
Log.d(TAG, "[${broadcast.id}] Retry response from ${relay.url}: success=${msg.success}")
Log.d(TAG) { "[${broadcast.id}] Retry response from ${relay.url}: success=${msg.success}" }
}
}
}
}
}
client.subscribe(subscription)
client.addConnectionListener(subscription)
val finalBroadcast =
coroutineScope {
@@ -367,12 +370,12 @@ class BroadcastTracker {
currentBroadcast.copy(status = newStatus)
}
client.send(event, relaysToRetry)
client.publish(event, relaysToRetry)
resultCollector.await()
}
client.unsubscribe(subscription)
client.removeConnectionListener(subscription)
resultChannel.close()
// Update in active broadcasts
@@ -380,7 +383,7 @@ class BroadcastTracker {
list.map { if (it.id == broadcast.id) finalBroadcast else it }.toImmutableList()
}
Log.d(TAG, "Retry complete for ${broadcast.id}: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success")
Log.d(TAG) { "Retry complete for ${broadcast.id}: ${finalBroadcast.successCount}/${finalBroadcast.totalRelays} success" }
return finalBroadcast
}
@@ -48,7 +48,7 @@ class ConnectivityFlow(
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
Log.d("ConnectivityFlow", "onAvailable ${network.networkHandle}")
Log.d("ConnectivityFlow") { "onAvailable ${network.networkHandle}" }
connectivityManager.getNetworkCapabilities(network)?.let {
trySend(ConnectivityStatus.Active(network.networkHandle, it.isMeteredOrMobileData()))
}
@@ -60,13 +60,13 @@ class ConnectivityFlow(
) {
super.onCapabilitiesChanged(network, networkCapabilities)
val isMobile = networkCapabilities.isMeteredOrMobileData()
Log.d("ConnectivityFlow", "onCapabilitiesChanged ${network.networkHandle} $isMobile")
Log.d("ConnectivityFlow") { "onCapabilitiesChanged ${network.networkHandle} $isMobile" }
trySend(ConnectivityStatus.Active(network.networkHandle, isMobile))
}
override fun onLost(network: Network) {
super.onLost(network)
Log.d("ConnectivityFlow", "onLost ${network.networkHandle} ")
Log.d("ConnectivityFlow") { "onLost ${network.networkHandle} " }
trySend(ConnectivityStatus.Off)
}
}
@@ -41,12 +41,12 @@ import kotlin.coroutines.cancellation.CancellationException
class BlossomFetcher(
private val options: Options,
private val data: Uri,
private val blossomServerResolver: BlossomServerResolver,
private val blossomServerResolver: () -> BlossomServerResolver,
private val networkFetcher: (url: String) -> Fetcher,
) : Fetcher {
override suspend fun fetch(): FetchResult? =
try {
val urlResult = blossomServerResolver.findServers(data.toString())
val urlResult = blossomServerResolver().findServers(data.toString())
networkFetcher(urlResult?.serverUrl ?: data.toString()).fetch()
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -55,7 +55,7 @@ class BlossomFetcher(
@OptIn(ExperimentalCoilApi::class)
class Factory(
val blossomServerResolver: BlossomServerResolver,
val blossomServerResolver: () -> BlossomServerResolver,
val networkClient: (url: String) -> Call.Factory,
) : Fetcher.Factory<Uri> {
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
@@ -64,7 +64,7 @@ class ImageLoaderSetup {
app: Context,
diskCache: () -> DiskCache,
memoryCache: () -> MemoryCache,
blossomServerResolver: BlossomServerResolver,
blossomServerResolver: () -> BlossomServerResolver,
callFactory: (url: String) -> Call.Factory,
) {
SingletonImageLoader.setUnsafe(
@@ -188,7 +188,7 @@ class LightningAddressResolver {
}
if (errorMessage == null) {
Log.d("LightningAddressResolver", "Error parsing LNResponse: $body")
Log.d("LightningAddressResolver") { "Error parsing LNResponse: $body" }
}
return errorMessage
@@ -48,13 +48,13 @@ class LocationFlow(
val locationCallback =
LocationListener { location ->
Log.d("LocationFlow", "onLocationChanged $location")
Log.d("LocationFlow") { "onLocationChanged $location" }
launch { send(location) }
}
locationManager.allProviders.forEach {
val location = locationManager.getLastKnownLocation(it)
Log.d("LocationFlow", "Last Known location is $location")
Log.d("LocationFlow") { "Last Known location is $location" }
if (location != null) {
send(location)
}
@@ -65,7 +65,7 @@ class LocationState(
}
@OptIn(ExperimentalCoroutinesApi::class)
val geohashStateFlow =
val geohashStateFlow by lazy {
hasLocationPermission
.transformLatest {
if (it) {
@@ -92,4 +92,5 @@ class LocationState(
SharingStarted.WhileSubscribed(5000),
latestLocation,
)
}
}
@@ -29,6 +29,7 @@ import androidx.annotation.RequiresApi
import com.vitorpamplona.quartz.utils.Log
import java.io.IOException
@Suppress("DEPRECATION")
class ReverseGeolocation {
companion object {
fun execute(
@@ -52,18 +53,18 @@ class ReverseGeolocation {
val locationCallback =
object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: List<Address>) {
Log.d("ReverseGeoLocation", "Found ${addresses.size} new addresses")
Log.d("ReverseGeoLocation") { "Found ${addresses.size} new addresses" }
onReady(addresses)
}
override fun onError(errorMessage: String?) {
super.onError(errorMessage)
Log.w("ReverseGeoLocation", "Failure $errorMessage")
Log.w("ReverseGeoLocation") { "Failure $errorMessage" }
onReady(null)
}
}
Log.d("ReverseGeoLocation", "Execute Async $location")
Log.d("ReverseGeoLocation") { "Execute Async $location" }
Geocoder(context).getFromLocation(
location.latitude,
location.longitude,
@@ -76,7 +77,7 @@ class ReverseGeolocation {
location: Location,
context: Context,
): List<Address>? {
Log.d("ReverseGeoLocation", "Execute Sync $location")
Log.d("ReverseGeoLocation") { "Execute Sync $location" }
return try {
Geocoder(context).getFromLocation(
location.latitude,
@@ -41,7 +41,7 @@ object ChoreographerHelper {
if (diff > 35) {
// Follow the frame number
val droppedCount = (diff / 16.6).toInt()
Log.w("block-canary", "Dropped $droppedCount frames. Skipped $diff ms")
Log.w("block-canary") { "Dropped $droppedCount frames. Skipped $diff ms" }
}
lastFrameTimeNanos = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this)
@@ -60,7 +60,7 @@ class LogMonitor : Printer {
val endTime = System.currentTimeMillis()
if (x.indexOf("com.vitorpamplona.amethyst") > 0) {
Log.d("block-canary", "Looper ${endTime - mStartTimestamp}ms for $x")
Log.d("block-canary") { "Looper ${endTime - mStartTimestamp}ms for $x" }
}
mPrintingStarted = false
@@ -66,7 +66,7 @@ data class NamecoinSettings(
* TLS is the default protocol. Append `:tcp` for plaintext
* (useful for `.onion` addresses and local servers).
*
* `.onion` addresses automatically get `trustAllCerts = true`
* `.onion` addresses automatically get `usePinnedTrustStore = true`
* since certificate verification is meaningless over Tor.
*/
fun parseServerString(s: String): ElectrumxServer? {
@@ -77,11 +77,15 @@ data class NamecoinSettings(
if (host.isEmpty() || port <= 0 || port > 65535) return null
val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp"
val isOnion = host.endsWith(".onion")
// All custom servers use the pinned trust store. ElectrumX
// servers almost universally use self-signed certs, so we
// route them through our pinned SSLSocketFactory (hardcoded
// defaults + TOFU-pinned certs + system CAs).
return ElectrumxServer(
host = host,
port = port,
useSsl = useSsl,
trustAllCerts = isOnion || !useSsl,
usePinnedTrustStore = true,
)
}
@@ -67,14 +67,14 @@ class EventNotificationConsumer(
LocalPreferences.allSavedAccounts().forEach {
if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner)) {
LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { acc ->
Log.d(TAG, "New Notification Testing if for ${it.npub}")
Log.d(TAG) { "New Notification Testing if for ${it.npub}" }
try {
val account = Amethyst.instance.accountsCache.loadAccount(acc)
consumeIfMatchesAccount(event, account)
matchAccount = true
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.d(TAG, "Message was not for user ${it.npub}: ${e.message}")
Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" }
}
}
}
@@ -94,14 +94,14 @@ class EventNotificationConsumer(
account: Account,
) {
val consumed = LocalCache.hasConsumed(notificationEvent)
Log.d(TAG, "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${account.signer.pubKey} consumed= $consumed")
Log.d(TAG) { "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${account.signer.pubKey} consumed= $consumed" }
if (!consumed) {
Log.d(TAG, "New Notification was verified")
if (!notificationManager().areNotificationsEnabled()) return
Log.d(TAG, "Notifications are enabled")
unwrapAndConsume(notificationEvent, account.signer)?.let { innerEvent ->
Log.d(TAG, "Unwrapped consume ${innerEvent.javaClass.simpleName}")
Log.d(TAG) { "Unwrapped consume ${innerEvent.javaClass.simpleName}" }
when (innerEvent) {
is PrivateDmEvent -> notify(innerEvent, account)
@@ -124,14 +124,14 @@ class EventNotificationConsumer(
LocalPreferences.allSavedAccounts().forEach {
if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner) && it.npub in npubs) {
LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { accountSettings ->
Log.d(TAG, "New Notification Testing if for ${it.npub}")
Log.d(TAG) { "New Notification Testing if for ${it.npub}" }
try {
val account = Amethyst.instance.accountsCache.loadAccount(accountSettings)
consumeNotificationEvent(event, account)
matchAccount = true
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.d(TAG, "Message was not for user ${it.npub}: ${e.message}")
Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" }
}
}
}
@@ -365,7 +365,7 @@ class EventNotificationConsumer(
account: Account,
) {
Log.d(TAG, "New Zap to Notify")
Log.d(TAG, "Notify Start ${event.toNostrUri()}")
Log.d(TAG) { "Notify Start ${event.toNostrUri()}" }
LocalCache.getNoteIfExists(event.id) ?: return
Log.d(TAG, "Notify Not Notified Yet")
@@ -378,7 +378,7 @@ class EventNotificationConsumer(
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
Log.d(TAG, "Notify ZapRequest $noteZapRequest zapped $noteZapped")
Log.d(TAG) { "Notify ZapRequest $noteZapRequest zapped $noteZapped" }
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
@@ -387,11 +387,11 @@ class EventNotificationConsumer(
if (event.isTaggedUser(account.signer.pubKey)) {
val amount = showAmount(event.amount)
Log.d(TAG, "Notify Amount $amount")
Log.d(TAG) { "Notify Amount $amount" }
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
decryptZapContentAuthor(event, account.signer)?.let { decryptedEvent ->
Log.d(TAG, "Notify Decrypted if Private Zap ${event.id}")
Log.d(TAG) { "Notify Decrypted if Private Zap ${event.id}" }
val author = LocalCache.getOrCreateUser(decryptedEvent.pubKey)
val senderInfo = Pair(author, decryptedEvent.content.ifBlank { null })
@@ -428,7 +428,7 @@ class EventNotificationConsumer(
.hexToByteArray()
.toNpub()
Log.d(TAG, "Notify ${event.id} $content $title $noteUri")
Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" }
notificationManager()
.sendZapNotification(
@@ -463,7 +463,7 @@ class EventNotificationConsumer(
.hexToByteArray()
.toNpub()
Log.d(TAG, "Notify ${event.id} $title $noteUri")
Log.d(TAG) { "Notify ${event.id} $title $noteUri" }
notificationManager()
.sendZapNotification(
@@ -85,7 +85,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
notificationManager.cancel(notificationId)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("NotificationReply", "Failed to send reply: ${e.message}")
Log.e("NotificationReply") { "Failed to send reply: ${e.message}" }
} finally {
pendingResult.finish()
@@ -69,7 +69,7 @@ class PokeyReceiver : BroadcastReceiver() {
) {
if (intent.action == POKEY_ACTION) { // it's best practice to verify intent action before performing any operation
val eventStr = intent.getStringExtra("EVENT")
Log.d(TAG, "New Pokey Notification Arrived $eventStr")
Log.d(TAG) { "New Pokey Notification Arrived $eventStr" }
if (eventStr == null) return
@@ -78,7 +78,7 @@ class RegisterAccounts(
accounts
.mapNotNull { account ->
if (account.hasPrivKey || account.loggedInWithExternalSigner) {
Log.d(tag, "Register Account ${account.npub}")
Log.d(tag) { "Register Account ${account.npub}" }
val acc = LocalPreferences.loadAccountConfigFromEncryptedStorage(account.npub)
if (acc != null && acc.isWriteable()) {
@@ -87,10 +87,10 @@ class RegisterAccounts(
if (isDebug) {
val readRelays = nip65Read.joinToString(", ") { it.url }
Log.d(tag, "Register Account ${account.npub} NIP65 Reads $readRelays")
Log.d(tag) { "Register Account ${account.npub} NIP65 Reads $readRelays" }
val dmRelays = nip17Read.joinToString(", ") { it.url }
Log.d(tag, "Register Account ${account.npub} NIP17 Reads $dmRelays")
Log.d(tag) { "Register Account ${account.npub} NIP17 Reads $dmRelays" }
}
val relays = (nip65Read + nip17Read)
@@ -132,7 +132,7 @@ class RegisterAccounts(
val client = client(url)
client.newCall(request).executeAsync().use { response ->
Log.i(tag, "Server registration ${response.isSuccessful}")
Log.i(tag) { "Server registration ${response.isSuccessful}" }
}
}
@@ -33,9 +33,9 @@ class EncryptedBlobInterceptor(
val body = peekBody(Long.MAX_VALUE)
// Only tries to decrypt if the content-type is a byte array
if (body.contentType().toString() != "application/octet-stream") {
return null
}
// if (body.contentType().toString() != "application/octet-stream") {
// return null
// }
val bytes = body.bytes()
@@ -41,7 +41,7 @@ class LoggingInterceptor : Interceptor {
val response: Response = chain.proceed(request)
val t2 = System.nanoTime()
Log.d("OkHttpLog", "Req $port ${request.url} in ${(t2 - t1) / 1e6}ms")
Log.d("OkHttpLog") { "Req $port ${request.url} in ${(t2 - t1) / 1e6}ms" }
return response
}
@@ -47,7 +47,7 @@ fun GetVideoController(
keepPlaying = mediaItem.src.keepPlaying,
context = context,
).onEach { state ->
Log.d("PlaybackService", "Controller instance: ${state.controller}")
Log.d("PlaybackService") { "Controller instance: ${state.controller}" }
if (BackgroundMedia.isPlaying()) {
// There is a video playing, start this one on mute.
@@ -57,7 +57,7 @@ fun GetVideoController(
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
state.controller.volume = if (muted) 0f else 1f
Log.d("PlaybackService", "OnEach $muted")
Log.d("PlaybackService") { "OnEach $muted" }
}
if (play) {
@@ -24,7 +24,6 @@ import androidx.annotation.OptIn
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
@@ -60,14 +60,22 @@ class PipVideoActivity : ComponentActivity() {
}
}
override fun onStop() {
super.onStop()
finishAndRemoveTask()
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
if (!isInPictureInPictureMode) {
// User dismissed PiP (swiped away or expanded).
finishAndRemoveTask()
}
}
override fun finish() {
finishAndRemoveTask()
super.finish()
override fun onStop() {
super.onStop()
if (!isInPictureInPictureMode) {
// Only finish if we're not in PiP mode.
// When the screen locks while in PiP, we stay alive
// so the PlaybackService can continue audio playback.
finishAndRemoveTask()
}
}
companion object {
@@ -32,7 +32,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.content.ContextCompat
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.ContentFrame
import androidx.media3.ui.compose.state.rememberMuteButtonState
@@ -51,6 +53,8 @@ fun RenderPipVideo(
controller: MediaControllerState,
waveformData: WaveformData?,
) {
KeepScreenOnWhilePlaying(controller)
val modifier =
remember {
val ratio =
@@ -78,6 +82,31 @@ fun RenderPipVideo(
}
}
@Composable
fun KeepScreenOnWhilePlaying(controller: MediaControllerState) {
val view = LocalView.current
DisposableEffect(controller.controller, view) {
val listener =
object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (view.keepScreenOn != isPlaying) {
view.keepScreenOn = isPlaying
}
}
}
// Set initial state
view.keepScreenOn = controller.controller.isPlaying
controller.controller.addListener(listener)
onDispose {
controller.controller.removeListener(listener)
view.keepScreenOn = false
}
}
}
@Composable
fun RegisterControllerReceiver(controllerState: MediaControllerState) {
val context = LocalContext.current
@@ -67,14 +67,14 @@ object PlaybackServiceClient {
.setConnectionHints(bundle)
.buildAsync()
Log.d("PlaybackService", "Preparing Controller $id $videoUri")
Log.d("PlaybackService") { "Preparing Controller $id $videoUri" }
controllerFuture.addListener(
{
try {
val controller = controllerFuture.get(5, TimeUnit.SECONDS)
Log.d("PlaybackService", "Controller Ready $id $videoUri")
Log.d("PlaybackService") { "Controller Ready $id $videoUri" }
// checks if the player is still active before engaging further
trySend(
@@ -92,11 +92,11 @@ object PlaybackServiceClient {
)
awaitClose {
Log.d("PlaybackService", "Releasing Controller $id $videoUri")
Log.d("PlaybackService") { "Releasing Controller $id $videoUri" }
try {
MediaController.releaseFuture(controllerFuture)
} catch (e: Exception) {
Log.e("Playback Client", "Failed to release Playback Client for $id $videoUri ${e.message}")
Log.e("Playback Client") { "Failed to release Playback Client for $id $videoUri ${e.message}" }
}
}
}
@@ -74,7 +74,7 @@ class RelayProxyClientConnector(
if (it.connectivity is ConnectivityStatus.StartingService) {
// ignore
} else if (it.connectivity is ConnectivityStatus.Off) {
Log.d("ManageRelayServices", "Connectivity Off: Pausing Relay Services ${it.connectivity}")
Log.d("ManageRelayServices") { "Connectivity Off: Pausing Relay Services ${it.connectivity}" }
if (client.isActive()) {
client.disconnect()
}
@@ -80,7 +80,7 @@ class AuthCoordinator(
if (account == null) return
if (isDebug) {
Log.d("AuthCoordinator", "Watch $account")
Log.d("AuthCoordinator") { "Watch $account" }
}
authWithAccounts.add(account)
@@ -91,7 +91,7 @@ class AuthCoordinator(
if (account == null) return
if (isDebug) {
Log.d("AuthCoordinator", "Unwatch $account")
Log.d("AuthCoordinator") { "Unwatch $account" }
}
authWithAccounts.remove(account)
@@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -68,7 +68,7 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
open fun newSub(key: T): Subscription =
requestNewSubscription(
object : IRequestListener {
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
@@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -69,7 +69,7 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
open fun newSub(key: T): Subscription =
requestNewSubscription(
object : IRequestListener {
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
@@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -67,7 +67,7 @@ abstract class PerUserEoseManager<T>(
open fun newSub(key: T): Subscription =
requestNewSubscription(
object : IRequestListener {
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
@@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -41,7 +41,7 @@ abstract class SingleSubNoEoseCacheEoseManager<T>(
) : BaseEoseManager<T>(client, allKeys) {
val sub =
requestNewSubscription(
object : IRequestListener {
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
@@ -37,8 +37,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfoNip66FilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -61,6 +63,7 @@ class RelaySubscriptionsCoordinator(
val chatroomList = ChatroomListFilterAssembler(client)
val video = VideoFilterAssembler(client)
val discovery = DiscoveryFilterAssembler(client)
val polls = PollsFilterAssembler(client)
// loaders of content that is not yet in the device.
// they are active when looking at events, users, channels.
@@ -80,6 +83,7 @@ class RelaySubscriptionsCoordinator(
val hashtags = HashtagFilterAssembler(client)
val geohashes = GeoHashFilterAssembler(client)
val relayFeed = RelayFeedFilterAssembler(client)
val relayInfoNip66 = RelayInfoNip66FilterAssembler(client)
val followPacks = FollowPackFeedFilterAssembler(client)
val chess = ChessFilterAssembler(client)
@@ -93,6 +97,7 @@ class RelaySubscriptionsCoordinator(
chatroomList,
video,
discovery,
polls,
channelFinder,
eventFinder,
userFinder,
@@ -105,6 +110,7 @@ class RelaySubscriptionsCoordinator(
hashtags,
geohashes,
relayFeed,
relayInfoNip66,
chess,
nwc,
)
@@ -24,7 +24,6 @@ import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCache.users
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.BundledUpdate
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
@@ -35,7 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -107,7 +106,7 @@ class AccountFollowsLoaderSubAssembler(
val sub =
orchestrator.requestNewSubscription(
if (isDebug) logTag + newSubId() else newSubId(),
object : IRequestListener {
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metada
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -43,6 +42,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -38,6 +37,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
@@ -24,13 +24,17 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
val ReportsAndBookmarksFromKeyKinds =
listOf(
ReportEvent.KIND,
BookmarkListEvent.KIND,
PinListEvent.KIND,
RequestToVanishEvent.KIND,
)
fun filterBookmarksAndReportsFromKey(
@@ -18,6 +18,8 @@
* 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.
*/
@file:Suppress("DEPRECATION")
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
@@ -25,7 +27,6 @@ import com.vitorpamplona.quartz.experimental.attestations.request.AttestationReq
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -50,6 +51,7 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
val SummaryKinds =
listOf(
@@ -18,6 +18,8 @@
* 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.
*/
@file:Suppress("DEPRECATION")
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.model.Note
@@ -39,8 +41,8 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
import com.vitorpamplona.quartz.utils.mapOfSet
val RepliesAndReactionsKinds =
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import kotlinx.collections.immutable.ImmutableList
@@ -276,6 +277,30 @@ fun observeUserBookmarkCount(
return flow.collectAsStateWithLifecycle(0)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserPinnedNotesCount(
user: User,
accountViewModel: AccountViewModel,
): State<Int> {
UserFinderFilterAssemblerSubscription(user, accountViewModel)
val flow =
remember(user) {
accountViewModel
.pinnedNotes(user)
.flow()
.metadata.stateFlow
.sample(200)
.mapLatest { noteState ->
(noteState.note.event as? PinListEvent)?.countPins() ?: 0
}.distinctUntilChanged()
.flowOn(Dispatchers.IO)
}
return flow.collectAsStateWithLifecycle(0)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserIsFollowing(
@@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
@@ -68,7 +68,7 @@ class UserOutboxFinderSubAssembler(
val sub =
requestNewSubscription(
object : IRequestListener {
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,

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