Compare commits

..

216 Commits

Author SHA1 Message Date
tyiu 31e281ce73 Fix localization issues and export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2026-03-09 21:08:29 -04:00
transifex-integration[bot] 963da2d4eb Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2026-03-07 00:23:32 -05:00
alltheseas af3bb212c3 Show client tags on events
Display "via ClientName" indicator beside timestamps when an event has
a client tag, allowing users to see which nostr app published it.

Changelog-Added: Show client name on events when available
Closes: https://github.com/damus-io/damus/issues/3323
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
2026-02-27 13:19:55 -08:00
alltheseas f3361e6eae Add client tag accessor to NdbNote
Add a clientTag property to NdbNote that parses and returns the client
tag metadata from an event's tags, enabling display of which app
published an event.

Ref: https://github.com/damus-io/damus/issues/3323
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
2026-02-27 13:19:55 -08:00
alltheseas 7f2c575f20 Expose client tag toggle in settings
Add a Privacy section in Appearance settings allowing users to disable
client tag publishing if they prefer not to identify Damus when posting.

Ref: https://github.com/damus-io/damus/issues/3323
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
2026-02-27 13:19:55 -08:00
alltheseas ec28822451 Add Damus client tag emission
- Add ClientTagMetadata struct with parsing helpers and documentation
- Append Damus client tags when posting across app, share, and drafts flows
- Gate the behavior behind a new publish_client_tag setting (default on)

Changelog-Added: Add client tag to published events to identify Damus
Ref: https://github.com/damus-io/damus/issues/3323
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
2026-02-27 13:19:55 -08:00
Daniel D’Aquino 795fce1b65 Add storage usage stats settings view
This commit implements a new Storage settings view that displays storage
usage statistics for NostrDB, snapshot database, and Kingfisher image cache.

Key features:
- Interactive pie chart visualization (iOS 17+) with tap-to-select functionality
- Pull-to-refresh gesture to recalculate storage
- Categorized list showing each storage type with size and percentage
- Total storage sum displayed at bottom
- Conditional compilation for iOS 16/17+ compatibility
- All calculations run on background thread to avoid blocking main thread
- NostrDB storage breakdown

Changelog-Added: Storage usage statistics view in Settings
Changelog-Changed: Moved clear cache button to storage settings
Closes: https://github.com/damus-io/damus/issues/3649
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-25 15:45:37 -08:00
copilot-swe-agent[bot] 65e767b774 fix: refresh balance and transactions in WalletModel.connect() on wallet switch
When a new wallet is connected, clear stale balance and transaction data
from the previous wallet immediately so the view does not display outdated
information while fresh data is being fetched.

Closes: https://github.com/damus-io/damus/issues/3644
Changelog-Fixed: Wallet view now immediately clears stale data when switching wallets
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Co-authored-by: Daniel D’Aquino <daniel@daquino.me>
Tested-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-25 13:24:11 -08:00
Daniel D’Aquino af9956de8a v1.16.1 changelog
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-23 18:33:55 -08:00
Copilot 7be75f37c6 Fix: Gracefully ignore unsupported NWC response types (e.g. get_info)
When another NWC client (e.g. Alby) connected to the same relay calls
`get_info`, Damus receives the response and previously threw a
DecodingError.typeMismatch, causing an "Oops" error dialog to be shown.

Fix: Make `result_type` optional in `WalletConnect.Response`. Unknown
result types now decode without throwing — `result_type` and `result`
are set to `nil`, and the rest of the existing nil-guarded code paths
handle this silently.

Adds a test to verify `get_info` (and any future unknown result type)
is decoded gracefully.

Closes: #2204
Changelog-Fixed: Fixed issue where the app could display an error message when using another NWC wallet in parallel
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: danieldaquino <24692108+danieldaquino@users.noreply.github.com>
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-23 12:50:25 -08:00
ericholguin 84ef5ecf53 gifs: Tenor GIFs
This PR adds GIFs to Damus using Tenor as the service.
This is a Damus Labs feature to begin with.
In the future we should be able to also query nostr for gif media.

Changelog-Added: Added GIF keyboard support (Damus Labs only)
Signed-off-by: ericholguin <ericholguin@apache.org>
2026-02-20 17:56:04 -08:00
yse f440f37cbf tests: wallet: add encoding test for list_transactions request
This adds a test to verify that the `getTransactionList` behaves as
expected when passing a nil and not-nil type as argument.

Signed-off-by: Hydra Yse <hydra_yse@proton.me>
2026-02-18 22:52:09 -08:00
yse b59816e180 wallet: models: unset type field for list transactions request
Removes the empty `type` field from the wallet's list_transactions
request in favor of a null field, which is parsable by serializers.

Signed-off-by: Hydra Yse <hydra_yse@proton.me>
2026-02-18 22:52:09 -08:00
yse 609cdcc5f9 wallet: errors: add %@ string formatting instead of %s
This fixes a UI issue where the error message from the NWC response
would be incorrectly displayed due to utf-8 formatting.

Signed-off-by: Hydra Yse <hydra_yse@proton.me>
2026-02-18 22:33:33 -08:00
Daniel D’Aquino 59498e3256 Fix double-free crash when creating empty NdbFilter
When ndb_filter_end processes an empty filter (no fields added), it calls
realloc(filter->elem_buf.start, 0) which frees the memory and returns NULL.
The existing code only updated the pointer if realloc
returned non-NULL, leaving elem_buf.start pointing to freed memory. This
caused a double-free crash when ndb_filter_destroy later called free() on
the dangling pointer.

Fix by explicitly setting filter->elem_buf.start to NULL when realloc
returns NULL due to zero-size allocation, and update the assertion to
allow NULL pointers for empty filters. ndb_filter_destroy already checks
for NULL before freeing.

Closes: https://github.com/damus-io/damus/issues/3634
Changelog-Fixed: Fix memory corruption crash when creating empty filters
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-18 16:28:00 -08:00
Daniel D’Aquino 546e9eec32 v1.16 changelog
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-17 04:19:23 -08:00
alltheseas cfafcffde2 fix: wait for relay connection before loading nevent URLs
LoadableNostrEventViewModel.load() now calls awaitConnection() before
executeLoadingLogic(), preventing premature "not found" when opening
nevent URLs or search results before relays finish connecting.

Closes: https://github.com/damus-io/damus/pull/3559

Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
Tested-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-16 19:51:21 -08:00
Daniel D’Aquino 4099827169 Fix fulfillment call in testActionBarModel to use its async version
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-16 19:12:35 -08:00
Daniel D’Aquino 32c0177049 Fix off-by-one-error in testTimerRestartsAfterSave
No user-facing change

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-16 19:12:35 -08:00
Daniel D’Aquino 2e1a98ff19 Add useful view for note-not-found state in quoted notes
Changelog-Added: Added a view for quotes notes that could not be loaded, including actionable items
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-16 13:16:21 -08:00
Daniel D’Aquino 7fa044d205 Fix infinite loading spinner regression
Root cause:
1. `lookup` looks up a note by its note id, and saving its note key
2. `lookup` then returns early (i.e. does not loading anything from the
   network) since it found the note
3. On the view, once it borrows the note from NostrDB (a query using its
   NoteKey), the query fails (Most likely due to transaction inheritance
   and the fact that the inherited transaction may be an older snapshot
   of the database without the note), causing the view loading logic to
   fail silently, leading to the infinite loading spinner

The issue was addressed by performing a single query during lookup and
copying the note contents directly at that point to avoid this
transaction inheritance issue.

In the future we should consider a more comprehensive fix to address
other instances where this may happen. I opened
https://github.com/damus-io/damus/issues/3607 for this future work.

Changelog-Fixed: Fixed an issue where notes would keep loading indefinitely in some cases
Closes: https://github.com/damus-io/damus/issues/3498
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-16 13:16:21 -08:00
William Casarin be6b0e2702 Move note language computation off the main thread
NLLanguageRecognizer.processString() is an expensive NLP operation that
was moved to @MainActor in 5058fb33, causing UI jank when scrolling.

This moves language detection back to the async preload path where it
runs off the main thread. get_preload_plan is synchronous again and
defers language computation to preload_event. Translations still happen
on the first preload pass since preload_event now checks .havent_tried
directly rather than relying on the load_translations flag from the plan.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:16:25 -08:00
Daniel D’Aquino 2d5460b654 Bump version to 1.17
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-06 18:12:02 -08:00
Daniel D’Aquino e271fa90d9 Fix issue that would cause RelayPool to close after ephemeral lease release
Issue was not released, so a changelog item is not needed.

Changelog-None
Closes: https://github.com/damus-io/damus/issues/3604
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-06 17:44:17 -08:00
William Casarin 8c5027248b Show "Favorites" label below logo when viewing favorites timeline
Adds a visual indicator under the Damus logo when the favorites
timeline is active. Uses fixed height with opacity to prevent
layout bouncing when switching timelines.
2026-02-06 17:30:11 -08:00
William Casarin 434c54f98e Fix favorites timeline not showing events when switching
The favorites timeline was empty because:
1. The @StateObject filter in InnerTimelineView was captured once at init
2. Favorite events were mixed with follows events and got drowned out

Fixed by:
- Adding viewId parameter to TimelineView to force view recreation on switch
- Creating separate favoriteEvents EventHolder for favorites
- Adding dedicated subscribe_to_favorites() subscription that inserts
  directly into favoriteEvents when contact cards are loaded
2026-02-06 17:30:11 -08:00
alltheseas 9a1ae6f9b5 Consume NIP-19 relay hints for event fetching
Extract and use relay hints from bech32 entities (nevent, nprofile, naddr)
and event tag references (e, q tags) to fetch events from hinted relays
not in the user's relay pool.

Changes:
- Parse relay hints from bech32 TLV data in URLHandler
- Pass relay hints through SearchType and NoteReference enums
- Add ensureConnected() to RelayPool for ephemeral relay connections
- Implement ephemeral relay lease management with race condition protection
- Add repostTarget() helper to extract relay hints from repost e tags
- Add QuoteRef struct to preserve relay hints from q tags (NIP-10/NIP-18)
- Support relay hints in replies with author pubkey in e-tags (NIP-10)
- Implement fallback broadcast when hinted relays don't respond
- Add comprehensive test coverage for relay hint functionality
- Add DEBUG logging for relay hint tracing during development

Implementation details:
- Connect to hinted relays as ephemeral, returning early when first connects
- Use total deadline to prevent timeout accumulation across hint attempts
- Decrement lease count before suspension points to ensure atomicity
- Fall back to broadcast if hints don't resolve or respond

Closes: https://github.com/damus-io/damus/issues/1147
Changelog-Added: Added relay hint support for nevent, nprofile, naddr links and event tag references (reposts, quotes, replies)
Signed-off-by: alltheseas
Signed-off-by: Daniel D'Aquino <daniel@daquino.me>
Co-authored-by: alltheseas <alltheseas@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Daniel D'Aquino <daniel@daquino.me
2026-02-02 18:52:41 -08:00
Daniel D’Aquino 6f8e2d3064 Reintroduce invoice tests that have been previously disabled
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-02-02 16:10:09 -08:00
alltheseas 2c3fba5f90 Add test cases for invoices with longer HRP prefixes
Tests for gh-3456 MAX_PREFIX fix:
- lnbc100u (10,000 sats) - 7 char HRP baseline
- lnbc130130n (13,013 sats) - 11 char HRP, requires MAX_PREFIX > 10

Root cause: Invoices with "odd" sat amounts use nano-BTC encoding
which produces longer HRPs that exceeded old MAX_PREFIX limit.

Related: #3456

Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:10:09 -08:00
alltheseas 1505a8f2e4 Simplify Swift invoice handling with non-optional return types
- Mentions.swift: convert_invoice_description now returns non-optional
  InvoiceDescription, returning empty description for BOLT11 compliance
  (both description and description_hash are optional per spec)

- Block.swift, NdbBlock.swift, NostrEvent.swift, NoteContent.swift:
  Updated call sites to use non-optional invoice conversion

- InvoiceTests.swift: Added test for specific failing invoice

Signed-off-by: alltheseas <alltheseas@noreply.github.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:10:09 -08:00
alltheseas 845089bed1 Fix Lightning invoice parsing and fetching
Three issues were causing invoices to not render or fetch:

1. bech32.c: Hardcoded MAX_PREFIX limited HRP length, but BOLT11 HRPs
   can be arbitrarily long depending on amount. Now derives max HRP
   length dynamically from input length (len-6 to match bolt11.c buffer).

2. content_parser.c: bolt11_decode_minimal was passed a pointer into
   the content buffer without null-termination. When a note contained
   multiple invoices, the decoder would read past the first invoice
   into newlines and the second invoice, causing checksum failure.
   Fixed by creating a null-terminated copy using strndup.

3. bolt11.c: bech32_decode_alloc allocated buffers using strlen(str)-6
   and strlen(str)-8 without checking minimum length first. For inputs
   shorter than 8 chars, this caused size_t underflow leading to huge
   allocations and potential crash. Added early length guard.

IMPORTANT: bech32_decode callers must allocate hrp buffer of at least
strlen(input) - 6 bytes. This matches existing bolt11.c usage.

Changelog-Fixed: Fixed Lightning invoice parsing and fetching for all amounts

Closes: https://github.com/damus-io/damus/issues/3456
Closes: https://github.com/damus-io/damus/issues/3151
Signed-off-by: alltheseas <alltheseas@noreply.github.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:10:09 -08:00
Daniel D’Aquino c88d881801 Fix wallet view hanging on loading placeholder indefinitely
Resolves a race condition in wallet data fetching that caused views to
hang on loading placeholders. The issue occurred due to:

1. Multiple state updates triggering view re-renders mid-fetch
2. Refreshable tasks getting cancelled before completion

Changes:
- Remove premature state reset in refreshWalletInformation()
- Atomically update balance and transactions together after fetching
- Replace onAppear + manual task cancellation with SwiftUI .task modifier
- Simplify refresh flow to use proper async/await without explicit task management

This ensures the wallet view completes data loading in a single atomic
operation, preventing intermediate loading states from persisting.

Closes: https://github.com/damus-io/damus/issues/2999
Changelog-Fixed: Wallet view no longer hangs on loading placeholder
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-28 19:01:05 -08:00
Daniel D’Aquino fa4b7a7518 Wait for app to load the relay list and connect before loading universe view
This commit addresses a race condition that happened when the user
initializes the app on the universe view, where the loading function
would run before the relay list was fully loaded and connected, causing
the loading function to connect to an empty relay list.

The issue was fixed by introducing a call that allows callers to wait
for the app to connect to the network

Changelog-Fixed: Fixed issue where the app would occasionally launch an empty universe view
Closes: https://github.com/damus-io/damus/issues/3528
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-28 16:58:30 -08:00
Daniel D’Aquino 438d537ff6 Add EntityPreloader for batched profile metadata preloading
Implements an actor-based preloading system to efficiently fetch profile
metadata for note authors and referenced users. The EntityPreloader queues
requests and batches them intelligently (500 pubkeys or 1 second timeout)
to avoid network overload while improving UX by ensuring profiles are
available when rendering notes.

Key changes:
- Add EntityPreloader actor with queue-based batching logic
- Integrate with SubscriptionManager via PreloadStrategy enum
- Add lifecycle management (start/stop on app foreground/background)
- Skip preload for pubkeys already cached in ndb
- Include comprehensive test suite with 11 test cases covering batching,
  deduplication, and edge cases
- Optimize ProfilePicView to load from ndb before first render

Closes: https://github.com/damus-io/damus/issues/gh-3511
Changelog-Added: Profile metadata preloading for improved timeline performance
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-28 13:16:06 -08:00
Daniel D’Aquino 4eac3c576f Fix profile action sheet button alignment and improve layout logic
Refactor ProfileActionSheetView to use an enum-based approach for managing
action buttons. Buttons are now conditionally rendered through a
visibleActionButtonTypes computed property, which determines visibility
based on settings and profile state.

Key changes:
- Add ActionButtonType enum to represent button variants
- Create visibleActionButtonTypes to build the list of visible buttons
- Add renderButton ViewBuilder for type-safe button rendering
- Center-align buttons when fewer than 5 are visible, otherwise use
  horizontal ScrollView for overflow

Closes: https://github.com/damus-io/damus/issues/3436
Changelog-Fixed: Profile action sheet buttons now center properly when fewer than 5 buttons are displayed
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-26 16:49:00 -08:00
Daniel D’Aquino c22c819bc0 Update tests to the new npub abbreviation format
Changelog-None
Closes: https://github.com/damus-io/damus/issues/3501
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-26 16:17:14 -08:00
Daniel D’Aquino b39996a6a7 ndb: Optimize snapshot storage
This commit improves the ndb snapshot logic by only transferring desired
notes instead of copying the entire database, which could be as big as
10GB.

Closes: https://github.com/damus-io/damus/issues/3502
Changelog-Changed: Improved storage efficiency for NostrDB on extensions
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-22 20:18:26 -08:00
Daniel D’Aquino 96fb909d83 Add pull to refresh feature in DMs
Closes: https://github.com/damus-io/damus/issues/3352
Changelog-Added: Added a pull to refresh feature on DMs that allows users to resync DMs with their relays
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-22 15:56:30 -08:00
Daniel D’Aquino ce461b58e6 Move DM subscription to a dedicated stream
This commit splits a subscription that previously gathered DMs as well
as new notes from the follow list, moving the DM subscription to is own
dedicated stream.

This prevents the issue of DM notes getting clipped off when the user
follows too many other users.

Changelog-Fixed: Fixed an issue where DMs may not appear for users with a large contact list
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-22 15:56:30 -08:00
Daniel D’Aquino 89a56eebcd Add missing timeout task to advanced stream
Changelog-Fixed: Fixed an issue that could cause certain networking operations to hang indefinitely
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-22 15:56:30 -08:00
Daniel D’Aquino d8f4dbb2aa Integrate Negentropy with Subscription Manager
This makes negentropy optimizations available to the rest of the app via
Subscription Manager.

Changelog not needed because this should not have user-facing changes

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-22 14:20:57 -08:00
Daniel D’Aquino 95d38fa802 Implement initial negentropy base functions
This implements some useful functions to use negentropy from RelayPool,
but does not integrate them with the rest of the app.

No changelog for the negentropy support right now as it is not hooked up
to any user-facing feature

Changelog-Fixed: Fixed a race condition in the networking logic that could cause notes to get missed in certain rare scenarios
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-22 14:20:57 -08:00
Daniel D’Aquino ac05b83772 Make RelayPool actor a global actor
This allows us to use the same actor for related classes to help with
thread safety.

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-22 14:20:57 -08:00
Askeew ed9971f84f Show Timeline Tip in same way and after other Damus Tips.
Changelog-None:
Signed-off-by: Askeew <askeew@hotmail.com>
2026-01-22 11:06:40 -08:00
Daniel D’Aquino 650d4af504 Fix crash on iOS 17
This fixes an arithmetic overflow crash on iOS 17 caused by the fallback
lock.

In iOS 17, Swift Mutexes are not available, so we have a fallback class
for NdbUseLock that does not make use of them. This allows some thread
safety for iOS 17 users, but unfortunately not as much as iOS 18+ users.

This attempts to fix those remaining race conditions and subsequent
crashes by using `NSLock` in the fallback class, which is available on
iOS 17.

Closes: https://github.com/damus-io/damus/issues/3512
Changelog-Fixed: Fixed a crash on iOS 17 that would happen on startup
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-12 11:41:01 -08:00
ericholguin 114dde7883 ui: Improved Load Media UI
This PR improves the load media UI when a user has media previews off.

Changelog-Changed: Changed load media UI
Signed-off-by: ericholguin <ericholguin@apache.org>
2026-01-07 19:59:08 -08:00
alltheseas b105dadd14 longform: fix note URLs not opening from nevent references
Previously, clicking nevent links pointing to longform notes (kind 30023)
showed "Can't display note" error because .longform was listed as an
unsupported kind in LoadableNostrEventView. This fix adds .longform to
the supported kinds alongside .text and .highlight, routing them to
ThreadModel for proper display.

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

Closes: https://github.com/damus-io/damus/pull/3487
Closes: https://github.com/damus-io/damus/issues/3003
Closes: https://github.com/damus-io/damus/issues/3485
Changelog-Fixed: Longform article links now open correctly when shared as nevent URLs
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2026-01-07 17:12:28 -08:00
alltheseas 078042546b longform: fix opening midway instead of at top
The scroll_to_event function defaults to anchor: .bottom, which positions
the selected event at the bottom of the viewport. For longform notes,
this causes the article to open midway or at the bottom instead of the
top where the title is.

Changed the initial scroll anchor to .top only for longform articles
(kind 30023), preserving the existing .bottom behavior for regular notes
which keeps parent context visible in reply threads.

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

Closes: https://github.com/damus-io/damus/issues/2481
Closes: https://github.com/damus-io/damus/pull/3488
Changelog-Fixed: Longform articles now open at the top instead of midway through
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Tested-by: William Casarin <jb55@jb55.com>
Signed-off-by: alltheseas
Signed-off-by: William Casarin <jb55@jb55.com>
2026-01-07 17:12:28 -08:00
alltheseas 93834f8de2 longform: simplify redundant boolean conditions in LongformPreview blur logic
The conditions !blur_images || (!blur_images && X) simplify to just
!blur_images, and the else branch covers the blur_images case.

Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
2026-01-07 17:12:28 -08:00
alltheseas 760d0a8126 longform: change focus mode to only hide chrome on scroll down, tap to restore
Previously scrolling up would restore the nav/tab bars. Now only tapping
restores chrome, giving a cleaner reading experience without accidental
restoration while scrolling.

Changelog-Changed: Changed focus mode to only hide navigation on scroll down
Signed-off-by: alltheseas
2026-01-07 17:12:28 -08:00
alltheseas c934bc7653 longform: fix tab bar staying hidden when switching to non-longform event
Restore chrome (nav bar + tab bar) when user taps to select a different
event in thread view. Previously the tab bar could stay hidden because
updateChromeVisibility short-circuits when isLongformEvent is false.

Changelog-Fixed: Fixed tab bar staying hidden when switching from longform to non-longform event
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
2026-01-07 17:12:28 -08:00
alltheseas 527b53a7c8 longform: add focus mode with auto-hide chrome
When reading longform articles, scrolling down hides the navigation bar and
tab bar for a distraction-free reading experience. Tap anywhere to restore
the chrome, or it automatically restores when leaving the view.

Closes: https://github.com/damus-io/damus/issues/3493
Changelog-Added: Added focus mode with auto-hide navigation for longform reading
Signed-off-by: alltheseas
2026-01-07 17:12:28 -08:00
alltheseas ef262b3c22 longform: remove card styling from preview in full article view
When viewing the full article (not truncated), remove the card border and
background styling for a cleaner reading experience. Card styling is now
only shown in truncated preview mode (timeline, search results).

Changelog-Changed: Removed card styling from longform preview in full article view
Signed-off-by: alltheseas
2026-01-07 17:12:28 -08:00
alltheseas 28a2c23a76 longform: add sepia mode and line height settings
Add settings for longform article reading experience:
- Sepia mode toggle for comfortable reading with warm tones
- Line height slider (1.2-2.0x) for adjustable text spacing
Both settings persist and apply to the full longform article view.

Closes: https://github.com/damus-io/damus/issues/3495
Changelog-Added: Added sepia mode and line height settings for longform articles
Signed-off-by: alltheseas
2026-01-07 17:12:28 -08:00
alltheseas e8e2653316 longform: add estimated read time to longform preview
Display estimated reading time in minutes alongside word count for longform
articles. Uses standard 200 words per minute reading rate calculation.

Closes: https://github.com/damus-io/damus/issues/3492
Changelog-Added: Added estimated read time to longform preview
Signed-off-by: alltheseas
2026-01-07 17:12:28 -08:00
alltheseas 0233f2ae48 longform: add reading progress bar
Display a thin purple progress bar at top of longform articles (kind 30023)
that tracks scroll position through the content. Uses top/bottom GeometryReader
trackers to measure content bounds and calculates progress linearly.

Closes: https://github.com/damus-io/damus/issues/3494
Changelog-Added: Added reading progress bar for longform articles
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
2026-01-07 17:12:28 -08:00
alltheseas 767b318763 longform: fix stretched/cut-off images in longform notes
Pre-process markdown with ensureBlockLevelImages() to add paragraph breaks
around standalone images, forcing proper block-level parsing. Creates
KingfisherImageProvider for MarkdownUI to handle proper aspect ratio and
image caching.

Changelog-Fixed: Fixed stretched/cut-off images in longform notes
Closes: https://github.com/damus-io/damus/pull/3489
Closes: https://github.com/damus-io/damus/pull/3496
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2026-01-07 17:12:28 -08:00
alltheseas 4f401c6ce9 input: convert pasted npub/nprofile to mention with async profile fetch
When pasting an npub or nprofile into the post composer, automatically
convert it to a human-readable mention link. If the profile isn't
cached locally, fetch it from relays and update the mention display
name when it arrives.

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

Changelog-Added: Added automatic conversion of pasted npub/nprofile to human-readable mentions in post composer
Closes: https://github.com/damus-io/damus/issues/2289
Closes: https://github.com/damus-io/damus/pull/3473
Co-Authored-By: Claude Opus 4.5
Tested-by: William Casarin <jb55@jb55.com>
Signed-off-by: alltheseas
Reviewed-by: William Casarin <jb55@jb55.com>
2026-01-07 17:12:28 -08:00
alltheseas a4ad4960c4 input: preserve mention links when inserting text before them
Previously, inserting text right before a mention (@user) would remove
the link attribute, breaking the mention. This was because the
intersection check in shouldChangeTextIn would trigger and remove the
link for any edit that touched the link boundary.

Added a new condition to handle insertion at the left edge of a link
separately, similar to the existing handling for the right edge. This
allows users to type before a mention without breaking it.

Added UI test that creates a real mention via autocomplete selection,
then verifies text can be typed before it without corrupting the
mention. The test uses predicate-based waits for reliability and
properly marks the UserView as an accessibility element. Link attribute
preservation is verified in unit tests.

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

Changelog-Fixed: Fixed mentions unlinking when typing text before them
Closes: https://github.com/damus-io/damus/pull/3473
Closes: https://github.com/damus-io/damus/issues/3460
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Tested-by: William Casarin <jb55@jb55.com>
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
Reviewed-by: William Casarin <jb55@jb55.com>
2026-01-07 17:12:28 -08:00
alltheseas 4941b502d5 input: fix cursor jumping to position 0 after typing first character
When composing a new note, the cursor would jump in front of the first
letter after typing it. This occurred because multiple SwiftUI view
updates (text change, placeholder removal, height change) could cause
the cursor position to be incorrectly restored.

The fix explicitly tracks the cursor position after each text change
by calling updateCursorPosition, ensuring the correct position is
always used regardless of view update timing.

Refactored textViewDidChange to use early return pattern for clarity.

Added UI test to guard against cursor position regressions in the
post composer.

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

Changelog-Fixed: Fixed cursor jumping behind first letter when typing a new note
Closes: https://github.com/damus-io/damus/pull/3473
Closes: https://github.com/damus-io/damus/issues/3461
Co-Authored-By: Claude Opus 4.5
Tested-by: William Casarin <jb55@jb55.com>
Signed-off-by: alltheseas
Signed-off-by: William Casarin <jb55@jb55.com>
2026-01-07 17:12:28 -08:00
alltheseas f506f9cfe8 docs: update AGENTS.md - dont block main thread
updated AGENTS.md with don't cause freezes, hangs requirement

Closes: https://github.com/damus-io/damus/pull/3486
Signed-off-by: William Casarin <jb55@jb55.com>
2026-01-07 17:12:28 -08:00
Daniel D’Aquino 81251ee88a Fix app freeze
This commit fixes an issue where the app would occasionally freeze.

The filtered holders were being initialized and registered directly from a SwiftUI
initializer, which would sometimes cause hundreds of instances to be
initialized and registered and never removed by `onDisappear`.

The issue was fixed by initializing such objects with `StateObject`,
which brings it a more stable identity that lives as long as the SwiftUI
view it is in, and by placing the init/deinit registration/clean-up logic
in the filtered holder object itself, better matching the lifecycle and
preventing resource leakage.

Changelog-Fixed: Fixed an issue that would occasionally cause the app to freeze
Closes: https://github.com/damus-io/damus/issues/3383
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-07 12:18:41 -08:00
alltheseas cddee92f3a thread: trust own replies in threads
Changelog-Fixed: Fix issue where your own replies were sometimes not trusted
Closes: https://github.com/damus-io/damus/issues/3404
Closes: https://github.com/damus-io/damus/pull/3416
Signed-off-by: alltheseas
Signed-off-by: William Casarin <jb55@jb55.com>
2026-01-06 12:20:47 -08:00
alltheseas 67e61417d9 search: sort search results by recency
Closes: https://github.com/damus-io/damus/issues/3407
Closes: https://github.com/damus-io/nostrdb/pull/102
Changelog-Fixed: Fix issue where search results were out of order
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
2026-01-06 12:20:47 -08:00
alltheseas be7a23bea8 filter: option to mute posts with too many hashtags
Posts with more than the configured number of hashtags (default: 3) are
now automatically filtered from timelines. This helps reduce hashtag spam.

- Add hide_hashtag_spam and max_hashtags settings to UserSettingsStore
- Add hashtag_spam_filter that counts hashtags in content text
- Add toggle and slider UI in Appearance > Content filters settings

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

Changelog-Added: Added hashtag spam filter setting to hide posts with too many hashtags
Closes: https://github.com/damus-io/damus/pull/3425
Closes: https://github.com/damus-io/damus/issues/1677
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: William Casarin <jb55@jb55.com>
Signed-off-by: alltheseas
Signed-off-by: William Casarin <jb55@jb55.com>
2026-01-06 12:20:47 -08:00
alltheseas f7fcb2cb91 test: add regression tests for repost notification bug
Adds comprehensive tests to prevent regression of issue #3165 where
repost notifications were incorrectly blocked by home feed deduplication.

Tests cover:
- Regression test: notifications not blocked by home dedup (main fix)
- Home feed deduplication still works correctly
- Dedup tracks inner event ID, not repost event ID
- Context isolation (.other context doesn't affect dedup)

Each test documents the expected behavior and provides clear failure
messages to aid debugging if the bug reoccurs.

Signed-off-by: alltheseas
Signed-off-by: William Casarin <jb55@jb55.com>
2026-01-06 12:20:38 -08:00
alltheseas d27d4e65cb fix: move repost dedup inside home context to fix notifications
The repost deduplication logic was incorrectly placed before the context
switch in handle_text_event(), causing notification events to be filtered
out when the same note had already been reposted in the home feed.

This fix moves the dedup logic inside the .home case where it belongs.
Notifications should always show reposts of YOUR posts, even if the same
note was already reposted by someone else in your home feed.

Root cause: commit bed4e00 added home feed dedup but placed the logic
before the context switch, affecting all contexts instead of just home.

Note on nostrdb: This bug is purely in application routing logic and
does not require database-level changes. The existing nostrdb TODO
(about inner event validation) is unrelated to this notification issue.

Changelog-Fixed: Fixed repost notifications not appearing in notifications tab
Closes: #3165
Closes: https://github.com/damus-io/damus/pull/3448
Signed-off-by: alltheseas
Signed-off-by: William Casarin <jb55@jb55.com>
2026-01-06 12:20:04 -08:00
Daniel D’Aquino 71c36052e2 Fix onboarding crash
This commit fixes a crash that occurred when clicking "follow all"
during onboarding.

This fix works by making `Contacts` and `PostBox` isolated into a
specific Swift Actor, and updating direct and indirect usages
accordingly.

Changelog-Fixed: Fixed a crash that occurred when clicking "follow all" during onboarding.
Closes: https://github.com/damus-io/damus/issues/3422
Co-authored-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-05 17:28:06 -08:00
Daniel D’Aquino 368f94a209 Background 0xdead10cc crash fix
This commit fixes the background crashes with termination code
0xdead10cc.

Those crashes were caused by the fact that NostrDB was being stored on
the shared app container (Because our app extensions need NostrDB
data), and iOS kills any process that holds a file lock after the
process is backgrounded.

Other developers in the field have run into similar problems in the past
(with shared SQLite databases or shared SwiftData), and they generally
recommend not to place those database in shared containers at all,
mentioning that 0xdead10cc crashes are almost inevitable otherwise:

- https://ryanashcraft.com/sqlite-databases-in-app-group-containers/
- https://inessential.com/2020/02/13/how_we_fixed_the_dreaded_0xdead10cc_cras.html

Since iOS aggressively backgrounds and terminates processes with tight
timing constraints that are mostly outside our control (despite using
Apple's recommended mechanisms, such as requesting more time to perform
closing operations), this fix aims to address the issue by a different
storage architecture.

Instead of keeping NostrDB data on the shared app container and handling
the closure/opening of the database with the app lifecycle signals, keep
the main NostrDB database file in the app's private container, and instead
take periodic read-only snapshots of NostrDB in the shared container, so as
to allow extensions to have recent NostrDB data without all the
complexities of keeping the main file in the shared container.

This does have the tradeoff that more storage will be used by NostrDB
due to file duplication, but that can be mitigated via other techniques
if necessary.

Closes: https://github.com/damus-io/damus/issues/2638
Closes: https://github.com/damus-io/damus/issues/3463
Changelog-Fixed: Fixed background crashes with error code 0xdead10cc
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2026-01-02 20:49:13 -08:00
alltheseas 0cbeaf8ea8 Update AGENTS.md
- add nesting rules
- add nostrdb consideration
- change code commentary to docstring coverage requirement
2025-12-29 12:41:56 -08:00
Daniel D’Aquino 20dc672dbf Add sync mechanism to prevent background crashes and fix ndb reopen order
This adds a sync mechanism in Ndb.swift to coordinate certain usage of
nostrdb.c calls and the need to close nostrdb due to app lifecycle
requirements. Furthermore, it fixes the order of operations when
re-opening NostrDB, to avoid race conditions where a query uses an older
Ndb generation.

This sync mechanism allows multiple queries to happen simultaneously
(from the Swift-side), while preventing ndb from simultaneously closing
during such usages. It also does that while keeping the Ndb interface
sync and nonisolated, which keeps the API easy to use from
Swift/SwiftUI and allows for parallel operations to occur.

If Swift Actors were to be used (e.g. creating an NdbActor), the Ndb.swift
interface would change in such a way that it would propagate the need for
several changes throughout the codebase, including loading logic in
some ViewModels. Furthermore, it would likely decrease performance by
forcing Ndb.swift operations to run sequentially when they could run in
parallel.

Changelog-Fixed: Fixed crashes that happened when the app went into background mode
Closes: https://github.com/damus-io/damus/issues/3245
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-12-29 11:01:23 -08:00
alltheseas 6d9107f662 Persist mute list across cold start
Closes: https://github.com/damus-io/damus/issues/3389
Changelog-Fixed: Added more guards to prevent accidental overrides of the user's mutelist
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
Co-authored-by: Daniel D’Aquino <daniel@daquino.me>
2025-12-22 17:19:50 -08:00
alltheseas a0cecdc8ad Fix missing profile names and pictures due to stream timing
When a view subscribes to profile updates via streamProfile() or
streamProfiles(), the stream now immediately yields any existing
profile data from NostrDB before waiting for network updates.

Previously, subscribers had to wait up to ~1 second for the
subscriptionSwitcherTask to restart the profile listener before
receiving any data. During this window, views would display
abbreviated pubkeys (e.g., "npub1abc...") or robohash placeholders
instead of the cached profile name and picture.

The fix adds a simple NDB lookup when creating the stream. This has
negligible performance impact since:
- It's a one-time operation per subscription (not per update)
- The same lookup was already happening in view bodies anyway
- NDB lookups are fast local queries

A new `yieldCached` parameter (default: true) allows callers to opt
out of the initial cached emission. NoteContentView uses this to
avoid redundant artifact re-renders — it only needs network updates
since its initial render already uses cached profile data.

Furthermore, when a profile has no metadata, the display name now shows
"npub1yrse...q9ye" instead of "1yrsedhw:8q0pq9ye" for a better UX.

Closes: https://github.com/damus-io/damus/issues/3454
Closes: https://github.com/damus-io/damus/issues/3455
Changelog-Changed: Changed abbreviated pubkey format to npub1...xyz for better readability
Changelog-Fixed: Fixed instances where a profile would not display profile name and picture for a few seconds
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
Co-authored-by: Daniel D’Aquino <daniel@daquino.me>
2025-12-19 17:21:49 -08:00
alltheseas 5058fb33d7 Restore translated note rendering
Changelog-Fixed: Fixed broken automatic translations
Closes: https://github.com/damus-io/damus/issues/3406
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
2025-12-15 18:15:34 -08:00
Daniel D’Aquino 48143f859a Move profile update handling from notes to the background
During profiling, I found that some large hangs were being caused by a
large number of `notify` calls (and their handling functions) keeping
the main thread overly busy.

We cannot move the `notify` mechanism to a background thread (It has to
be done on the main actor or else runtime warnings/errors appear), so
instead this commit removes a very large source of notify calls/handling around
NoteContentView, and replaces it with a background task that streams
for profile updates and only updates its view when a relevant profile is
updated.

Changelog-Changed: Improved performance around note content views to prevent hangs
Closes: https://github.com/damus-io/damus/issues/3439
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-12-15 17:18:19 -08:00
alltheseas d3a54458f5 search: highlight terms in note search results
Changelog-Changed: Highlight note search results
Signed-off-by: alltheseas
2025-12-10 18:29:31 -08:00
Daniel D’Aquino 9eda7e5886 Improve draft saving mechanism to start timer on first edit
Modified AutoSaveViewModel.needsSaving() to not reset the timer if already
counting down. This ensures the timer starts when the user begins typing and
continues counting even if they keep typing continuously, leading to auto-save
every few seconds instead of waiting for the user to stop typing.

Added automated tests for the new behavior.

Fixes the issue where drafts would only save after user stops typing,
potentially leading to data loss if the app is closed too quickly.

Closes: https://github.com/damus-io/damus/issues/3164
Changelog-Changed: Improved draft saving feature to prevent data loss if app closes too quickly
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-12-10 16:12:10 -08:00
alltheseas 674d4683c3 Fix blank notifications on app startup
The notifications stream used streamIndefinitely(), discarding EOSE signals. This caused a race condition where:

1. NotificationsView.onAppear fires and calls flush().
2. flush() finds an empty queue (events haven’t arrived yet).
3. Events arrive and queue up in incoming_events.
4. No second flush happens, so notifications stay blank.

Fix: Switch to advancedStream() and handle .ndbEose to flush queued notifications once the local database finishes loading. This mirrors how the Home timeline handles initial data loading.

The ndbEose handler also disables queuing (set_should_queue(false)), so any events arriving after the initial load display immediately.

When the Notifications tab becomes visible, disable queuing so any events arriving afterward insert immediately into the displayed list.

This defensive measure complements the ndbEose flush in HomeModel. Together, they provide belt-and-suspenders protection:

1. ndbEose flush (primary): Triggers when local DB finishes loading, flushing queued events and disabling queuing.
2. onAppear disable (safety net): If the user navigates to notifications before ndbEose fires, this ensures new events aren’t queued forever.

Whichever fires first wins - both set should_queue=false, and the second call is a harmless no-op.

Closes: https://github.com/damus-io/damus/issues/3399
Changelog-Fixed: Fixed an issue where notifications view would occasionally appear blank when the app started.
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
2025-12-10 15:26:18 -08:00
Daniel D’Aquino f5e5da25eb Remove accidental code comment
This commit removes an accidentally placed code comment. Behavior has
been tested during the original work related to that commit.

Fixes: b562b930cc
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-12-08 17:17:43 -08:00
alltheseas 5066a39ffb Fix jumpy cursor bug
This fixes jumpy cursor bug by clamping cursor restoration and consuming tag diff only once.

Closes: https://github.com/damus-io/damus/issues/747
Changelog-Fixed: Fixed incorrect behaviour on the post editor that would cause the text cursor to occasionally jump beyond the correct location in some editing operations.
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
Co-authored-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-12-08 16:32:38 -08:00
askeew f1b81a3e5c Fix center alignment and larger hit test area for Timeline switcher
Changelog-None
Signed-off-by: Askeew <askeew@hotmail.com>
2025-12-08 12:43:34 -08:00
Daniel D’Aquino f844ed9931 Redesign Ndb.swift interface with build safety
This commit redesigns the Ndb.swift interface with a focus on build-time
safety against crashes.

It removes the external usage of NdbTxn and SafeNdbTxn, restricting it
to be used only in NostrDB internal code.

This prevents dangerous and crash prone usages throughout the app, such
as holding transactions in a variable in an async function (which can
cause thread-based reference counting to incorrectly deinit inherited
transactions in use by separate callers), as well as holding unsafe
unowned values longer than the lifetime of their corresponding
transactions.

Closes: https://github.com/damus-io/damus/issues/3364
Changelog-Fixed: Fixed several crashes throughout the app
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-12-07 11:02:45 -08:00
Daniel D’Aquino b562b930cc Prevent new NostrDB streaming tasks from opening when NostrDB has begun to close
We have mechanisms in place to close NostrDB streams when the database needs to
close; however, there is a short time window where those streams are
closing down but the database still has its "open" property set to `true`,
which means that new NostrDB streams may open. If that happens, those
new streams will still be active when NostrDB gets closed down,
potentially causing memory crashes.

This was found by inspecting several crash logs and noticing that:
- most of the `ndb.close` calls are coming from the general
  backgrounding task (not the last resort backgrounding task),
  where all old tasks are guaranteed to have closed (we wait for all of
  them to close before proceeding to closing NostrDB).
- the stack traces of the crashed threads show that, in most cases, the
  stream crashes while they are in the query stage (which means that
  those must have been very recently opened).

The issue was mitigated by signalling that NostrDB has closed (without
actually closing it) before cancelling any streaming tasks and officially
closing NostrDB. This way, new NostrDB streaming tasks will notice that
the database is closed and will wait for it to reopen.

No changelog entry is needed as this issue was introduced after our last public
release.

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-12-07 11:02:45 -08:00
Daniel D’Aquino 2f7a40bd50 Remove typed throws in some Ndb functions
Those are unused and it causes awkward implementations when different
error types need to be used.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-12-07 11:02:45 -08:00
alltheseas 498af9bc3a Hide empty chat action bar overlays
Changelog-Fixed: Fixed an issue where an empty dot would appear on some thread chat views
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
Closes: https://github.com/damus-io/damus/issues/3368
2025-12-05 18:35:55 -08:00
alltheseas 066b5ff379 Fix mention profile fetch for mention_index blocks
Closes: https://github.com/damus-io/damus/issues/3344
Changelog-Fixed: Ensure mention profile prefetch covers mention_index blocks
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
2025-12-03 10:56:13 -08:00
Daniel D’Aquino b8de67dcae Revert "Temporarily disable zaps"
This reverts commit 0879fa39dc.
2025-12-01 11:07:56 -08:00
Daniel D’Aquino 44dfda8d33 Fix AttributeGraph cycle
Closes: https://github.com/damus-io/damus/issues/3342
Changelog-Fixed: Fixed an issue where the mute list view may occasionally freeze the app
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-11-26 16:06:35 -08:00
alltheseas 7eafe973d9 Ensure mention profiles render with display names
Co-authored-by: Daniel D’Aquino <daniel@daquino.me>
Closes: https://github.com/damus-io/damus/issues/3331
Changelog-Fixed: Fix mention pills falling back to @npub text when profile metadata is missing
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
2025-11-26 11:05:42 -08:00
Daniel D’Aquino 44071e9d75 Fix thread UI jumpiness
Since 991a4a8, the `make_actionbar_model` function introduced an async
call to populate the action bar data.

This surfaced a pre-existing problem where the action bar model would
reinstantiate in any SwiftUI render pass for the chat bubbles in
`ChatroomThreadView`. This issue was not visible before because the whole
computation happened directly on the main actor during the render,
maintaining the illusion of a stable entity. Since the computation was
moved to an async task (for performance and concurrency design reasons),
it caused the action bar items to reload in each render pass, causing
multiple re-renders and the jumpiness witnessed in the ticket.

The issue was addressed by making the action bar model initialization
happen within ChatEventView itself, and wrapping it on `StateObject` to
make that entity stable across re-renders.

This fixes an issue for an unreleased change, so no changelog entry is
necessary.

Changelog-None
Fixes: 991a4a8
Closes: https://github.com/damus-io/damus/issues/3270
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-11-26 10:10:08 -08:00
Daniel D’Aquino 52115d07c2 Fix profile crash
This fixes a crash that would occasionally occur when visiting profiles.

NdbTxn objects were being deinitialized on different threads from their
initialization, causing incorrect reference count decrements in thread-local
transaction dictionaries. This led to premature destruction of shared ndb_txn
C objects still in use by other tasks, resulting in use-after-free crashes.

The root cause is that Swift does not guarantee tasks resume on the same
thread after await suspension points, while NdbTxn's init/deinit rely on
thread-local storage to track inherited transaction reference counts.

This means that `NdbTxn` objects cannot be used in async functions, as
that may cause the garbage collector to deinitialize `NdbTxn` at the end
of such function, which may be running on a different thread at that
point, causing the issue explained above.

The fix in this case is to eliminate the `async` version of the
`NdbNoteLender.borrow` method, and update usages to utilize other
available methods.

Note: This is a rewrite of the fix in https://github.com/damus-io/damus/pull/3329

Note 2: This relates to the fix of an unreleased feature, so therefore no
changelog is needed.

Changelog-None
Co-authored-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
Closes: https://github.com/damus-io/damus/issues/3327
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-11-21 14:59:00 -08:00
alltheseas d651084465 Add AGENTS.md
Changelog-None
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
2025-11-21 12:48:41 -08:00
ericholguin 8c4783c622 ui: Improve Damus Purple presentation in side view
This PR simply replaces the purple ostrich in the side view with the Damus Logo.
As well as adding a gradient to the Purple text.
I think this better represents Damus Purple.

Changelog-Changed: Changed Damus Purple Side View logo and text

Signed-off-by: ericholguin <ericholguin@apache.org>
2025-11-19 11:30:34 -08:00
Daniel D’Aquino 48d3049f3f Improve accessibility and localization support on Damus Labs screen
Note: This is an improvement on an unreleased feature, so no changelog
entry is needed.

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-11-17 17:42:06 -08:00
Daniel D’Aquino 529bb0dca0 Add more toggles in Labs
No changelog is needed because we already have changelog messages for
the features added.

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-11-17 17:42:06 -08:00
Daniel D’Aquino 0879fa39dc Temporarily disable zaps
Changelog-Removed: Temporarily disabled note zaps
Closes: https://github.com/damus-io/damus/issues/3314
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-11-17 17:42:06 -08:00
ericholguin b8c664d354 Damus Live
This PR adds Live Streaming and Live Chat to Damus via Damus Labs.

Changelog-Added: Added live stream timeline
Changelog-Added: Added live chat timeline
Changelog-Added: Added ability to create live chat event
Changelog-Added: Damus Labs Toggle

Signed-off-by: ericholguin <ericholguin@apache.org>
2025-11-14 15:36:43 -08:00
Daniel D’Aquino a31f6bce0e Fix foreground crash caused by a race condition on ProfileModel
`seen_event` set was not isolated, which lead to occasional race
conditions between different actors accessing it simultaneously, leading
to crashes.

Closes: https://github.com/damus-io/damus/issues/3311
Changelog-Fixed: Fixed an occasional random crash related to viewing profiles
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-11-06 10:34:43 -08:00
Daniel D’Aquino 58f4988237 Avoid note subscription clipping in HomeModel
This commit adds a new event subscription task in HomeModel, one which
streams low volume but important filters from NostrDB.

This was done to address an issue where the contact filters in the
general handler task could yield too many notes from NostrDB, hitting
its limits and clipping off important events such as mute-lists, leading
to downstream issues such as unintended mute-list overrides.

This issue was not present since the last public release, therefore no
changelog entry is needed.

Changelog-None
Closes: https://github.com/damus-io/damus/issues/3256
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-11-06 10:29:17 -08:00
Daniel D’Aquino bd1eae5f26 Add more contribution guidelines
Changelog-None
Closes: https://github.com/damus-io/damus/issues/3301
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-11-06 10:27:16 -08:00
Daniel D’Aquino 5380918b15 Bump up the version to 1.16
Closes: https://github.com/damus-io/damus/issues/3085
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-11-05 15:04:35 -08:00
Daniel D’Aquino 1015b1cb08 1.15 changelog
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-11-05 14:58:07 -08:00
Daniel D’Aquino 9ca6b5e9ab Hide the Favourites feature behind a feature flag
Some issues were encountered with this feature. Disabling it for now.

Once we have the full Damus Labs UI, we will add the feature there.

Changelog-Changed: Placed the Favorites feature behind a feature flag
Closes: https://github.com/damus-io/damus/issues/3304
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-31 15:09:46 -07:00
Daniel D’Aquino 56a7d1ed78 Merge pull request #3204 from damus-io/local-relay-model
This integrates all the local relay model work done in PR #3204.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-31 13:23:04 -07:00
Daniel D’Aquino 01150155ab Add missing ProfileObserver to EventProfileName view
It was noticed that occasionally the profile name at the event view
would not load to the user's display name.

The issue was fixed by adding a missing profile observer.

Issue introduced after our last public release, no need for changelog
entries.

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-31 10:43:33 -07:00
Daniel D’Aquino a8202d89f8 Remove unnecessary wait in note rendering
Previously, the note content rendering logic would wait for the note
and its blocks to be available in NostrDB before displaying the parsed
version of the note to the user.

However, it is now possible to parse those on demand, and there are code
paths to ensure that it will do so if those parsed blocks are not
readily available from NostrDB.

Therefore, the wait is not necessary, and removing it fixes the delay we
have been experiencing.

The issue was likely introduced after the last public release, so no
changelog item is needed

Changelog-None
Closes: https://github.com/damus-io/damus/issues/3296
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-31 10:33:40 -07:00
Daniel D’Aquino d4402b0afc Merge branch 'master' into local-relay-model
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-29 16:43:09 -07:00
ericholguin 036afbf5b8 fix: Don't show onboarding when logged in with npub
This PR makes sure to not show the onboarding screens
when a user is logged in with an npub as it doesn't
make sense for them to see that.

Changelog-Fixes: Fixes issue where onboarding views are shown to npub users

Signed-off-by: ericholguin <ericholguin@apache.org>
2025-10-29 12:14:34 -07:00
Daniel D’Aquino 7ba2ec6713 Add Damus Labs fast-track review process to PR templates
Closes: https://github.com/damus-io/damus/issues/3271
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-27 17:11:07 -07:00
ericholguin 36b40f53af Damus Labs
This PR adds the new Damus Labs view.
This will allow us to make the new things we work on more prominent.
Any new features we want to iterate fast on and get to our users a lot faster
are perfect for Damus Labs. This will be exclusive to Damus Purple Subscribers.

Changelog-Added: Added Damus Labs

Signed-off-by: ericholguin <ericholguin@apache.org>
2025-10-26 15:07:39 -07:00
Daniel D’Aquino 58e6a49bcf Fix race condition leading to intermittent issues with ndb streaming and related tests
A race condition was identified where notes would get dropped if they
get indexed in the time window between when a query is made and the subscription is made.

The issue was fixed by making the subscribe call before making the query
call, to ensure we get all notes from that time when we perform the
query.

This dropped the failure rate for ndb subscription tests from about 20%
down to about 4%.

Local relay model issue was not publicly released, which is why the
changelog entry is "none".

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-24 18:40:32 -07:00
Daniel D’Aquino 7cf9a07099 Add more automated tests around ndb streaming
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-24 17:15:04 -07:00
Daniel D’Aquino 7afcaa99fe Reduce race condition probability in Ndb streaming functions
This attempts to reduce race conditions coming from Ndb streaming
functions that could lead to lost notes or crashes.

It does so by making two improvements:
1. Instead of callbacks, now the callback handler uses async streams,
   which reduces the chances of a callback being called before the last
   item was processed by the consumer.
2. The callback handler will now queue up received notes if there are
   no listeners yet. This is helpful because we need to issue the
   subscribe call to nostrdb before getting the subscription id and
   setting up a listener, but in between that time nostrdb may still
   send notes which would effectively get dropped without this queuing
   mechanism.

Changelog-Fixed: Improved robustness in the part of the code that streams notes from nostrdb
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-24 16:25:44 -07:00
Daniel D’Aquino 10b4d804f8 Shift since optimization filter by two minutes
Changelog-Changed: Tweaked since optimization filter to capture notes that would otherwise be lost
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-24 14:30:58 -07:00
Daniel D’Aquino e3d27ae472 Turn off network optimization for ProfilesManager
This may negatively impact performance, but improves accuracy and
prevents profile loading issues

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-24 14:27:06 -07:00
Daniel D’Aquino 02296d7752 Configure UI to be in compatibility mode
This temporarily addresses iOS 26 UI issues by setting a UI
configuration called "compatibility mode" until we implement full iOS 26
support.

See https://developer.apple.com/documentation/BundleResources/Information-Property-List/UIDesignRequiresCompatibility

Furthermore, it addresses one remaining UI issue with the timeline top
padding by altering the padding calculation for iOS 26 targets.

Changelog-None
Closes: https://github.com/damus-io/damus/issues/3283
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-22 17:48:44 -07:00
Daniel D’Aquino 9dfd338077 Fix hang on sign-up
This fixes a hang during sign-up, which was caused by a change in
RelayPool handling code that would only send data to handlers with
matching subscription IDs.

It so happens that some handlers want to receive all notes, and they set
the filters to `nil` to achieve that.

Furthermore, some sign-up networking code was moved to prevent race conditions.

No changelog entry because the behaviour was not changed since the last
public release.

Changelog-None
Closes: https://github.com/damus-io/damus/issues/3254
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-22 14:50:11 -07:00
Daniel D’Aquino fe09f9da99 Add signup UI end to end test
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-22 14:25:38 -07:00
Daniel D’Aquino 67d2b249b6 Merge branch 'master' into local-relay-model
Logical merge errors fixed manually

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-22 11:56:58 -07:00
Daniel D’Aquino 9555145359 Fix automated test issues
Closes: https://github.com/damus-io/damus/issues/3275
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-20 17:35:23 -07:00
Daniel D’Aquino 8122a8a580 Revert "Removes notifications from muted npubs"
This reverts commit 6605c5e583.
2025-10-15 14:41:20 -07:00
Daniel D’Aquino 690f8b891e Implement timestamp-based network subscription optimization
Changelog-Changed: Optimized network bandwidth usage and improved timeline performance
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-15 14:15:55 -07:00
Daniel D’Aquino 91426a79b9 Add performance profiling requirement to PRs
Changelog-None
Closes: https://github.com/damus-io/damus/issues/3247
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-15 09:30:03 -07:00
Askia Linder 61f695b7c6 Add Timeline switcher button in PostingTimelineView. Switch between your following or NIP-81 favorites. User can favorite a user via ProfileActionSheetView or ProfileView.
Closes: https://github.com/damus-io/damus/issues/2438
Changelog-Added: Add Timeline switcher button for NIP-81-favorites
Signed-off-by: Askeew <askeew@hotmail.com>
2025-10-15 09:13:37 -07:00
alltheseas 6605c5e583 Removes notifications from muted npubs 2025-10-15 00:03:20 -05:00
Daniel D’Aquino ab2c16288b Fix test compilation issues
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-13 15:16:54 -07:00
Daniel D’Aquino 991a4a86e6 Move most of RelayPool away from the Main Thread
This is a large refactor that aims to improve performance by offloading
RelayPool computations into a separate actor outside the main thread.

This should reduce congestion on the main thread and thus improve UI
performance.

Also, the internal subscription callback mechanism was changed to use
AsyncStreams to prevent race conditions newly found in that area of the
code.

Changelog-Fixed: Added performance improvements to timeline scrolling
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-10 16:38:48 -07:00
Daniel D’Aquino 7c1594107f Perform LNURL computation on the background in EventActionBar
This is to reduce the amount of computation it takes to create the
EventActionBar view

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-08 17:23:59 -07:00
Daniel D’Aquino 05c02f7dc4 Initialize AVPlayerItem on the background to avoid hitches
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-08 15:51:59 -07:00
Daniel D’Aquino 70d0d9dacf Offload note filtering computations from the view body render function
This attempts to improve the performance of InnerTimelineView by
performing event filtering computations on "EventHolder.insert" instead
of on each view body re-render, to improve SwiftUI performance.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-08 15:10:47 -07:00
Daniel D’Aquino c80d4f146c Unpublish incoming notes to prevent unnecessary redraws
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-08 13:21:25 -07:00
Daniel D’Aquino 9311a767c8 Speed up quote reposts view loading
Closes: https://github.com/damus-io/damus/issues/3252
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-08 10:39:51 -07:00
Daniel D’Aquino 588ef46402 Hide "Load new content" behind feature flag
This feature is not production-ready, and is not essential for the
current scope of work, so descoping it and hiding it behind a feature
flag until it is ready.

Changelog-Removed: Removed "Load new content" button
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-08 10:20:10 -07:00
Daniel D’Aquino 4f479d0280 Fix RelayPool connection race condition without time delays
This improves upon a temporary fix we had for the RelayPool race
condition that would cause timeline staleness.

The root cause was that during app launch, the HomeModel would subscribe
to some filters, and the subscribe function would filter out any relays
not yet connected to avoid unnecessary waiting for EOSEs from disconnected relays.
However, that filtering would cause the subscribe request to not be
queued up or sent back to the relays once connected, causing the relays
to never receive those subscription requests and causing timeline
staleness.

This was fixed by separating the relay list used for the subcription
request from the relay list used for waiting for network EOSEs. This
allows other mechanisms to ensure the subscription will go through even
when the app is initializing and relays are not yet fully connected.

Fixes: 61eb833239
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-06 15:19:05 -07:00
Daniel D’Aquino 7691b48fb6 Fix testDecodingPayInvoiceRequest test failure
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-06 11:48:58 -07:00
Daniel D’Aquino 01ec05ab32 Fix test build error
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-06 11:33:45 -07:00
Daniel D’Aquino 61eb833239 Add temporary experimental delay to check hypothesis on occasional init timeline staleness
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-05 16:52:56 -07:00
Daniel D’Aquino d9306d4153 Modify NostrNetworkManager pipeline architecture
Previously, we combined the ndb and network stream within a "session
subscription" stream, which was teared down and rebuilt every time the
app went into the background and back to the foreground (This was done to
prevent crashes related to access to Ndb memory when Ndb is closed).

However, this caused complications and instability on the network
stream, leading to timeline staleness.

To address this, the pipeline was modified to merge the ndb and network
streams further upstream, on the multi-session stage, allowing the
session subscription streams to be completely split between Ndb and the
network.

For the ndb stream, we still tear it down and bring it up along the app
foreground state, to prevent memory crashes. However, the network stream
is kept intact between sessions, since RelayPool will now automatically
handle resubscription on websocket reconnection. This prevents
complexity and potential race conditions that could lead to timeline
staleness.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-05 15:28:16 -07:00
Daniel D’Aquino 3437cf5347 Further improvements to app lifecycle handling
- Resend subscription requests to relays when websocket connection is
  re-established
- More safeguard checks on whether Ndb is opened before accessing its
  memory
- Cancel queued unsubscribe requests on app backgrounding to avoid race
  conditions with subscribe requests when app enters the foreground
- Call Ndb re-open when Damus is active (not only on active notify), as
  experimentally there have been instances where active notify code has
  not been run. The operation is idempotent, so there should be no risk
  of it being called twice.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-05 13:18:59 -07:00
Daniel D’Aquino 667a228e1a Ensure to publish object changes on the main thread
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-03 10:29:26 -07:00
Daniel D’Aquino 84c4594d30 Fix timeline staleness
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-03 10:14:32 -07:00
Daniel D’Aquino 32e8c1b6e1 Improve logging in SubscriptionManager
Use Apple's unified logging system, and specify proper privacy levels
for each piece of information.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-10-01 11:18:15 -07:00
Daniel D’Aquino 1b5f107ac6 Add more safeguards to prevent RUNNINGBOARD 0xdead10cc crashes
This commit adds more safeguards to prevent RUNNINGBOARD 0xdead10cc
crashes, by:
1. Using the `beginBackgroundTask(withName:expirationHandler:)` to
   request additional background execution time before completely
   suspending the app. See https://developer.apple.com/documentation/xcode/sigkill
2. Reorganizing app closing/cleanup tasks to be done in parallel when
   possible to decrease time needed to cleanup resources.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-29 16:39:14 -07:00
Daniel D’Aquino fe62aea08a Stop ProfileManager when app is being backgrounded
This should prevent RUNNINGBOARD 0xdead10cc crashes related to
ProfileManager and app background states.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-26 13:01:05 -07:00
Daniel D’Aquino 258d08723f Check if Ndb is closed before running subscribe and query operations
This should prevent background crashes caused by race conditions between
usages of Ndb and the Ndb/app lifecycle operations.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-26 12:04:51 -07:00
Daniel D’Aquino 9153a912b0 Cancel timeout task on stream cancellation
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 17:55:35 -07:00
Daniel D’Aquino fe491bf694 Increase MAX_CONCURRENT_SUBSCRIPTION_LIMIT
Through some local experimentation, it seems that network relays can support higher subscription limits.

Increase internal limits to avoid hitting issues with subscriptions
waiting on subscription pool to clear and appearing stale.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 17:52:38 -07:00
Daniel D’Aquino e55675a336 Optimize HomeModel subscription usage
This reduces the overall subscription usage throughout the app, thus
reducing issues associated with too many subscriptions being used at
once, and the resulting staleness.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 16:15:52 -07:00
Daniel D’Aquino eda4212aa7 Disable refreshable on Universe view
Updates are streamed from the network, removing the need for a refresh
action

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:11 -07:00
Daniel D’Aquino 798f9ec7b4 Improve loading speeds for home timeline and universe view
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:11 -07:00
Daniel D’Aquino a09e22df24 Improve streaming interfaces and profile loading logic
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:11 -07:00
Daniel D’Aquino a3ef36120e Fix OS 26 build errors
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:10 -07:00
Daniel D’Aquino de528f3f70 Improve loading speed on home timeline
This commit improves the loading speed for the home timeline (and likely
other areas of the app) by employing various techniques and changes:
- Network EOSE timeout reduced from 10 seconds down to 5 seconds
- Network EOSE does not wait on relays with broken connections
- Offload HomeModel handler event processing to separate tasks to
  avoid a large backlog
- Give SubscriptionManager streamers more fine-grained EOSE signals for
  local optimization
- Only wait for Ndb EOSE on the home timeline for faster loading
- Add logging with time elapsed measurements for easier identification of
  loading problems

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:10 -07:00
Daniel D’Aquino 8164eee479 Return network EOSE in normal mode if device is offline
This is done to prevent hang ups when the device is offline.

Changelog-Added: Added the ability to load saved notes if device is offline
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:10 -07:00
Daniel D’Aquino 0582892cae Improve Follow pack timeline loading logic in the Universe view
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:10 -07:00
Daniel D’Aquino 2185984ed7 Stream from both NDB and network relays
This commit takes a step back from the full local relay model by
treating NostrDB as one of the many relays streamed from, instead of the
one exclusive relay that other classes rely on.

This was done to reduce regression risk from the local relay model
migration, without discarding the migration work already done.

The full "local relay model" behavior (exclusive NDB streaming) was
hidden behind a feature flag for easy migration later on.

Closes: https://github.com/damus-io/damus/issues/3225
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:10 -07:00
Daniel D’Aquino 1caad24364 Add note provenance filter support to SubscriptionManager
Closes: https://github.com/damus-io/damus/issues/3222
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:10 -07:00
Daniel D’Aquino ecbfb3714b Fix incompatibilities with new nostrdb version
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:03 -07:00
William Casarin d565eb20f7 nostrdb: query: enforce author matches in author_kind queries
before we weren't checking this, meaning we were getting
results from other keys. oops.

Reported-by: Jeff Gardner
Fixes: #84
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin a040a0244b nostrdb: search: fix memleak in profile search
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin 387af198d6 nostrdb: win: fix heap corruption with flatbuf 2025-09-24 14:06:03 -07:00
William Casarin 66e10db6b2 nostrdb: mem: re-enable profile freeing
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin 42a0f2c08d nostrdb: Revert "mem: search cursor close"
this is causing heap corruption on the windows
build

This reverts commit a8d6925a5b33ddbdd4306423527b5d8314f7dd36.
2025-09-24 14:06:03 -07:00
William Casarin aa8ce31941 nostrdb: mem: close cursors in print helpers 2025-09-24 14:06:03 -07:00
William Casarin 8014d772ba nostrdb: mem: builder clear before free 2025-09-24 14:06:03 -07:00
William Casarin 4d8313c788 nostrdb: mem: relay iter cleanup 2025-09-24 14:06:03 -07:00
William Casarin 342067640f nostrdb: mem: reaction stats cleanup 2025-09-24 14:06:03 -07:00
William Casarin 84839d1c43 nostrdb: mem: search cursor close 2025-09-24 14:06:03 -07:00
William Casarin b5079c42d5 nostrdb: memory: fix a bunch of memory leaks
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin 0847c53a39 nostrdb: add ndb_builder_push_tag_id
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin fa2d240ddf nostrdb: nostrdb: calculate id in ndb_note_verify
Rogue relays could in theory attack nostrdb by replaying ids and
signatures from other notes. This fixes this weakness by calculating the
id again in ndb_note_verify.

There is no known relays exploiting this, but lets get ahead of it
before we switch to the outbox model in damus iOS/notedeck

Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin 3a37a6c18e nostrdb: change <=10 author search queries to ==1
These queries are broken anyways. Rely on scans until we fix this

Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin 5c75e87ed5 nostrdb: eq: fix fallthrough bug
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin 64c16e7cc8 nostrdb: filter: add initial custom filtering logic
This adds some helpers for adding custom filtering logic
to nostr filters. These are just a callback and a closure.
There can only be one custom callback filter per filter.

Fixes: https://github.com/damus-io/nostrdb/issues/33
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin 0b8090cb28 nostrdb: query: implement profile search query plans
The basic idea of this is to allow you to use the standard
nip50 query interface to search for profiles using our profile
index.

query: {"search":"jb55", "kinds":[0]}

will result in a profile_search query plan that searches kind0 profiles
for the corresponding `name` or `display_name`.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin 9cff8608f6 nostrdb: fix build on macos 2025-09-24 14:06:03 -07:00
William Casarin c728210be8 nostrdb: query: implement author_kind query plan
This should help author kind query performance
2025-09-24 14:06:03 -07:00
William Casarin 0f66e87faf nostrdb: Relay queries
Add support for relay-based filtering in nostr queries.

Filters can now include a "relays" field. Optimal performance when
you include a kind as well:

{"relays":["wss://pyramid.fiatjaf.com/"], "kinds":[1]}

This corresponds to a `ndb` query like so:

$ ndb query -r wss://pyramid.fiatjaf.com/ -k 1 -l 1
using filter '{"relays":["wss://pyramid.fiatjaf.com/"],"kinds":[1],"limit":1}'
1 results in 0.094929 ms
{"id":"277dd4ed26d0b44576..}

Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin af2298dcb7 nostrdb: relay: fix potential relay index corruption
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin a0b85129d4 nostrdb: relay: fix race condition bug
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin e42b14cc6f nostrdb: debug: add a print for debugging rust integration
Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin f0521ba406 nostrdb: relay-index: fix a few bugs
There were a few race conditions and lmdb bugs in the
relay index implementation. Fix those!

Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin c29027ff5b nostrdb: note: always write relay index
This fixes a race condition where if multiple of the same note
is processed at the same time, we still manage to write the
note relays

Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin c6674199de nostrdb: win: fix build on windows 2025-09-24 14:06:03 -07:00
William Casarin 5961bf7958 nostrdb: ndb: add print-relay-kind-index-keys
for debugging

Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin a877a19c25 nostrdb: relay: add note relay iteration
This is a simple cursor that walks the NDB_DB_NOTE_RELAYS db

Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin 684701931d nostrdb: Initial relay index implementation
Add relay indexing for existing notes

This patch introduces a relay index for new notes and notes that have
already been stored, allowing the database to track additional relay
sources for a given note.

Changes:

- Added `NDB_WRITER_NOTE_RELAY` to handle relay indexing separately from
  new note ingestion.

- Implemented `ndb_write_note_relay()` and
  `ndb_write_note_relay_kind_index()` to store relay URLs.

- Modified `ndb_ingester_process_event()` to check for existing notes
  and append relay info if necessary.

- Introduced `ndb_note_has_relay()` to prevent duplicate relay entries.

- Updated LMDB schema with `NDB_DB_NOTE_RELAYS` (note_id -> relay) and
  `NDB_DB_NOTE_RELAY_KIND` (relay + kind + created_at -> note).

- Refactored `ndb_process_event()` to use `ndb_ingest_meta` for tracking
  relay sources.

- Ensured proper memory management for relay strings in writer thread.

With this change, nostrdb can better track where notes are seen across
different relays, improving query capabilities for relay-based data
retrieval.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
William Casarin fcd8131063 nostrdb: config: custom writer scratch size
making more things configurable if you have memory constraints

Signed-off-by: William Casarin <jb55@jb55.com>
2025-09-24 14:06:03 -07:00
Daniel D’Aquino 3290e1f9d2 Improve NostrNetworkManager interfaces
This commit improves NostrNetworkManager interfaces to be easier to use,
and with more options on how to read data from the Nostr network

This reduces the amount of duplicate logic in handling streams, and also
prevents possible common mistakes when using the standard subscribe method.

This fixes an issue with the mute list manager (which prompted for this
interface improvement, as the root cause is similar to other similar
issues).

Closes: https://github.com/damus-io/damus/issues/3221
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 2bea2faf3f Add load more content button to the top bar
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 9bcee298d4 Fix forever-loading hashtag view
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 7eb759a8a0 Fix issue with wallet loading
Changelog-Changed: Increased transaction list limit to 50 transactions
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 2550d613b2 Fix test compilation issues
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 9fb7ed741e Fix race condition on app swap that would cause ndb to remain closed
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino d766029f2b Improve loading UX in the home timeline
Changelog-Changed: Improved loading UX in the home timeline
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 4478672c10 Fix occasional stale timeline issue
Changelog-Changed: Added UX hint to make it easier to load new notes
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino c43a37d2d3 Fix forever-loading quote repost view
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino ab22206093 Fix broken Follow Pack timeline
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino de70d19135 Fix NIP-05 timeline crash
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 0f26d50e08 Prevent publishing changes to Observable outside the main thread
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 9709e69dda Fix forever loading Universe view
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 809c8c80ac Fix missing relay list from profile
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino c4c3656f90 Multi-session subscriptions and RelayPool reopening
This commit implements nostr network subscriptions that survive between
sessions, as well as improved handling of RelayPool opening/closing with
respect to the app lifecycle.

This prevents stale data after users swap out and back into Damus.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 46c3667ec3 Update setting on main actor to avoid crashes
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 739a3a0b8c Add more test cases to SubscriptionManager tests
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino ab6ea7a9c1 Fix issue where repost and like counts would not appear
Previously, HomeModel could listen to all subscriptions throughout the
app, and it would handle reaction and repost counting.

Once moved to the local relay model, HomeModel no longer had access to
all subscriptions, causing those counts to disappear.

The issue was fixed by doing the counting from ThreadModel itself, which
better isolates concerns throughout the app.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 9620dcf6ef Fix crash when loading all follows
This commit fixes a crash that caused the app to crash when getting all
the follows from a profile.

This issue was caused by a use-after-free memory error on inherited
transactions after the original transaction is deinitialized.

The issue was fixed by introducing a reference count on all transactions
and only deallocating the C transaction when the ref count goes to zero.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino a5aff15491 Improve task cancellation management in SubscriptionManager
The widespread usage of the SubscriptionManager caused new crashes to
occur when swapping apps.

This was caused due to an access to Ndb memory after Ndb has been closed
from the app background signal.

The issue was fixed with improved task management logic and ensuring all
subscription tasks are finished before closing Ndb.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 76b6d5c545 Update published items on the main actor
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 940b83f5c4 Add ndb subscription tests
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino e113dee95e Publish "loading" variable update on the main thread to avoid undefined behaviour
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino abd797b7b3 Fix another race condition that leads to a memory error
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 8083269709 Switch to local relay model
Changelog-Changed: Switched to the local relay model
Changelog-Added: Notes now load offline
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 5f3ce30826 Fix memory race condition
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino 578d47356d Make RelayPool private to NostrNetworkManager and migrate usages
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-24 14:06:02 -07:00
Daniel D’Aquino f2870b9a38 Fix OS 26 build errors
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-09-22 10:39:35 -07:00
307 changed files with 22997 additions and 3655 deletions
View File
+30 -1
View File
@@ -4,8 +4,37 @@ _[Please provide a summary of the changes in this PR.]_
## Checklist
<!--
CHOOSE YOUR CHECKLIST:
- If this is an EXPERIMENTAL DAMUS LABS FEATURE, follow the "Experimental Feature Checklist" below and DELETE the "Standard PR Checklist"
- If this is a STANDARD PR, follow the "Standard PR Checklist" below and DELETE the "Experimental Feature Checklist"
-->
### Experimental Feature Checklist
<!-- DELETE THIS SECTION if this is a standard PR -->
> [!TIP]
> This Pull Request is an experimental feature for Damus Labs, and follows a fast-track review process.
> The overall requirements are lowered and the review process is not as strict as usual. However, the feature will only be available for Purple users who opt-in.
- [ ] I have read (or I am familiar with) the [Contribution Guidelines](../docs/CONTRIBUTING.md).
- [ ] I have done some testing on the changes in this PR to ensure it is at least functional.
- [ ] I made sure that this new feature is only available when the user opts-in from the Damus Labs screen, and does not affect the rest of the app when turned off.
- [ ] My PR is either small, or I have split it into smaller logical commits that are easier to review.
- [ ] I have added the signoff line to all my commits. See [Signing off your work](../docs/CONTRIBUTING.md#sign-your-work---the-developers-certificate-of-origin).
- [ ] I have added an appropriate changelog entry to my commit in this PR. See [Adding changelog entries](../docs/CONTRIBUTING.md#add-changelog-changed-changelog-fixed-etc).
- Example changelog entry: `Changelog-Added: Added experimental feature <X> to Damus Labs`
### Standard PR Checklist
<!-- DELETE THIS SECTION if this is an experimental Damus Labs feature -->
- [ ] I have read (or I am familiar with) the [Contribution Guidelines](../docs/CONTRIBUTING.md)
- [ ] I have tested the changes in this PR
- [ ] I have profiled the changes to ensure there are no performance regressions, or I do not need to profile the changes.
- Utilize Xcode profiler to measure performance impact of code changes. See https://developer.apple.com/videos/play/wwdc2025/306
- If not needed, provide reason:
- [ ] I have opened or referred to an existing github issue related to this change.
- [ ] My PR is either small, or I have split it into smaller logical commits that are easier to review
- [ ] I have added the signoff line to all my commits. See [Signing off your work](../docs/CONTRIBUTING.md#sign-your-work---the-developers-certificate-of-origin)
@@ -34,4 +63,4 @@ _Please provide a test report for the changes in this PR. You can use the templa
## Other notes
_[Please provide any other information that you think is relevant to this PR.]_
_[Please provide any other information that you think is relevant to this PR.]_
+47
View File
@@ -0,0 +1,47 @@
# Agents
## Damus Overview
Damus is an iOS client built around a local relay model ([damus-io/damus#3204](https://github.com/damus-io/damus/pull/3204)) to keep interactions snappy and resilient. The app operates on `nostrdb` ([source](https://github.com/damus-io/damus/tree/master/nostrdb)), and agents working on Damus should maximize usage of `nostrdb` facilities whenever possible.
## Codebase Layout
- `damus/` contains the SwiftUI app. Key subdirectories: `Core` (protocol, storage, networking, nostr primitives), `Features` (feature-specific flows like Timeline, Wallet, Purple), `Shared` (reusable UI components and utilities), `Models`, and localized resources (`*.lproj`, `en-US.xcloc`).
- `nostrdb/` hosts the embedded database. Swift bindings (`Ndb.swift`, iterators) wrap a C/LMDB core; prefer these abstractions when working with persistence or queries.
- `damus-c/` bridges C helpers (e.g., WASM runner) into Swift; check `damus-Bridging-Header.h` before adding new bridges.
- `nostrscript/` contains AssemblyScript sources compiled to WASM via the top-level `Makefile`.
- Tests live in `damusTests/` (unit/snapshot coverage) and `damusUITests/` (UI smoke tests). Keep them running before submitting changes.
## Development Workflow
- Use `just build` / `just test` for simulator builds and the primary test suite (requires `xcbeautify`). Update or add `just` recipes if new repeatable workflows emerge.
- Xcode project is `damus.xcodeproj`; the main scheme is `damus`. Ensure new targets or resources integrate cleanly with this scheme.
- Rebuild WASM helpers with `make` when touching `nostrscript/` sources.
- Follow `docs/DEV_TIPS.md` for debugging (enabling Info logging, staging push notification settings) and keep tips updated when discovering new workflows.
## Testing Expectations
- Provide a concrete test report in each PR (see `.github/pull_request_template.md`). Document devices, OS versions, and scenarios exercised.
- Add or update unit tests in `damusTests/` alongside feature changes, especially when touching parsing, storage, or replay logic.
- UI regressions should include `damusUITests/` coverage or rationale when automation is impractical.
- Snapshot fixtures under `damusTests/__Snapshots__` must be regenerated deliberately; explain updates in commit messages.
## Contribution Standards
- Sign all commits (`git commit -s`) and include appropriate `Changelog-*`, `Closes:`, or `Fixes:` tags as described in `docs/CONTRIBUTING.md`.
- Keep patches scoped: one logical change per commit, ensuring the app builds and runs after each step.
- Favor Swift-first solutions that lean on `nostrdb` types (`Ndb`, `NdbNote`, iterators) before introducing new storage mechanisms.
- Update documentation when workflows change, especially this file, `README.md`, or developer notes.
## Agent Requirements
1. Code should tend toward simplicity.
2. Commits should be logically distinct.
3. Commits should be standalone.
4. Code should be human readable.
5. Code should be human reviewable.
6. Ensure docstring coverage for any code added, or modified.
7. Review and follow `pull_request_template.md` when creating PRs for iOS Damus.
8. Ensure nevernesting: favor early returns and guard clauses over deeply nested conditionals; simplify control flow by exiting early instead of wrapping logic in multiple layers of `if` statements.
9. Before proposing changes, please **review and analyze if a change or upgrade to nostrdb** is beneficial to the change at hand.
10. **Never block the main thread**: All network requests, database queries, and expensive computations must run on background threads/queues. Use `Task { }`, `DispatchQueue.global()`, or Swift concurrency (`async/await`) appropriately. UI updates must dispatch back to `@MainActor`. Test for hangs and freezes before submitting.
+179
View File
@@ -1,3 +1,182 @@
## [1.16.1] - 2026-02-17
### Added
- Added a view for quotes notes that could not be loaded, including actionable items (Daniel DAquino)
- Added relay hint support for nevent, nprofile, naddr links and event tag references (reposts, quotes, replies) (alltheseas)
### Fixed
- Fixed an issue where notes would keep loading indefinitely in some cases (Daniel DAquino)
- Fixed Lightning invoice parsing and fetching for all amounts (alltheseas)
[1.16.1]: https://github.com/damus-io/damus/releases/tag/v1.16.1
## [1.16] - 2026-01-28
### Added
- Added live stream timeline (ericholguin)
- Added live chat timeline (ericholguin)
- Added ability to create live chat event (ericholguin)
- Damus Labs Toggle (ericholguin)
- Added Damus Labs (ericholguin)
- Add Timeline switcher button for NIP-81-favorites (Askia Linder)
- Added the ability to load saved notes if device is offline (Daniel DAquino)
- Notes now load offline (Daniel DAquino)
- Added support for scanning nprofile QR codes (Terry Yiu)
- Add nip50 search filters and queries (William Casarin)
- Add ndb_filter_init_with (William Casarin)
- Add ndb_filter_is_subset_of (William Casarin)
- Add ndb_filter_eq for filter equality testing (William Casarin)
- Add method for parsing filter json (William Casarin)
- Add ndb_filter_json method for creating json filters (William Casarin)
- Add ndb_unsubscribe to unsubscribe from subscriptions (William Casarin)
- Add general created_at query plan for timelines (William Casarin)
- Add ndb_poll_for_notes (William Casarin)
- Added filter subscriptions (William Casarin)
- Add initial rust library (William Casarin)
- Added relay count and relay view to events (Terry Yiu)
- Add relay hints to tags and identifiers (Terry Yiu)
- Added focus mode with auto-hide navigation for longform reading (alltheseas)
- Added sepia mode and line height settings for longform articles (alltheseas)
- Added estimated read time to longform preview (alltheseas)
- Added reading progress bar for longform articles (alltheseas)
- Added automatic conversion of pasted npub/nprofile to human-readable mentions in post composer (alltheseas)
- Added hashtag spam filter setting to hide posts with too many hashtags (alltheseas)
- Profile metadata preloading for improved timeline performance (Daniel DAquino)
- Added a pull to refresh feature on DMs that allows users to resync DMs with their relays (Daniel DAquino)
### Changed
- Improved performance around note content views to prevent hangs (Daniel DAquino)
- Highlight note search results (alltheseas)
- Improved draft saving feature to prevent data loss if app closes too quickly (Daniel DAquino)
- Changed Damus Purple Side View logo and text (ericholguin)
- Placed the Favorites feature behind a feature flag (Daniel DAquino)
- Tweaked since optimization filter to capture notes that would otherwise be lost (Daniel DAquino)
- Optimized network bandwidth usage and improved timeline performance (Daniel DAquino)
- Increased transaction list limit to 50 transactions (Daniel DAquino)
- Improved loading UX in the home timeline (Daniel DAquino)
- Added UX hint to make it easier to load new notes (Daniel DAquino)
- Switched to the local relay model (Daniel DAquino)
- Reduced default zap amount and deduplicated from preset zap amount items (Terry Yiu)
- Use NostrDB for rendering note contents (Daniel DAquino)
- Changed abbreviated pubkey format to npub1...xyz for better readability (alltheseas)
- Changed focus mode to only hide navigation on scroll down (alltheseas)
- Removed card styling from longform preview in full article view (alltheseas)
- Improved storage efficiency for NostrDB on extensions (Daniel DAquino)
- Changed load media UI (ericholguin)
### Fixed
- Fixed broken automatic translations (alltheseas)
- Fixed an issue where notifications view would occasionally appear blank when the app started. (alltheseas)
- Fixed incorrect behaviour on the post editor that would cause the text cursor to occasionally jump beyond the correct location in some editing operations. (alltheseas)
- Fixed several crashes throughout the app (Daniel DAquino)
- Fixed an issue where an empty dot would appear on some thread chat views (alltheseas)
- Ensure mention profile prefetch covers mention_index blocks (alltheseas)
- Fixed an issue where the mute list view may occasionally freeze the app (Daniel DAquino)
- Fix mention pills falling back to @npub text when profile metadata is missing (alltheseas)
- Fixed an occasional random crash related to viewing profiles (Daniel DAquino)
- Improved robustness in the part of the code that streams notes from nostrdb (Daniel DAquino)
- Added performance improvements to timeline scrolling (Daniel DAquino)
- Improved security around note validation (Daniel DAquino)
- Fixed an issue where the app would crash when swapping between apps (Daniel DAquino)
- Fixed memory error in nostrdb (Daniel DAquino)
- Fixed bug where non-bech32 damus io urls would cause corruption (William Casarin)
- Fix aspect ratio on pasted or uploaded images (askeew)
- Fixed note content rendering to not remove whitespace before hashtag (Terry Yiu)
- Fixed background crashes with error code 0xdead10cc (Daniel DAquino)
- Fixed crashes that happened when the app went into background mode (Daniel DAquino)
- Added more guards to prevent accidental overrides of the user's mutelist (alltheseas)
- Fixed instances where a profile would not display profile name and picture for a few seconds (alltheseas)
- Longform article links now open correctly when shared as nevent URLs (alltheseas)
- Longform articles now open at the top instead of midway through (alltheseas)
- Fixed tab bar staying hidden when switching from longform to non-longform event (alltheseas)
- Fixed stretched/cut-off images in longform notes (alltheseas)
- Fixed mentions unlinking when typing text before them (alltheseas)
- Fixed cursor jumping behind first letter when typing a new note (alltheseas)
- Fixed an issue that would occasionally cause the app to freeze (Daniel DAquino)
- Fix issue where your own replies were sometimes not trusted (alltheseas)
- Fix issue where search results were out of order (alltheseas)
- Fixed repost notifications not appearing in notifications tab (alltheseas)
- Fixed a crash that occurred when clicking "follow all" during onboarding. (Daniel DAquino)
### Removed
- Removed "Load new content" button (Daniel DAquino)
- Wallet view no longer hangs on loading placeholder (Daniel DAquino)
- Fixed issue where the app would occasionally launch an empty universe view (Daniel DAquino)
- Profile action sheet buttons now center properly when fewer than 5 buttons are displayed (Daniel DAquino)
- Fixed an issue where DMs may not appear for users with a large contact list (Daniel DAquino)
- Fixed an issue that could cause certain networking operations to hang indefinitely (Daniel DAquino)
- Fixed a race condition in the networking logic that could cause notes to get missed in certain rare scenarios (Daniel DAquino)
- Fixed a crash on iOS 17 that would happen on startup (Daniel DAquino)
[1.16]: https://github.com/damus-io/damus/releases/tag/v1.16
## [1.15] - 2025-07-11
**Note:** This version was only released on TestFlight, and never officially released on the App Store.
### Added
- Added new onboarding suggestions based on user-selected interests (Daniel DAquino)
- Added adjustable max budget setting for Coinos one-click wallets (Daniel DAquino)
- Added send feature to the wallet view (Daniel DAquino)
- Added popover tips to DMs and Notifications toolbars on Trusted Network button (Terry Yiu)
- Added tip in threads to inform users what trusted network means (Terry Yiu)
- Added web of trust reply sorting in threads to mitigate spam (Terry Yiu)
- Added follow list kind 39089 (ericholguin)
- Added follow pack preview (ericholguin)
- Added follow pack timeline to Universe View (ericholguin)
- Added NIP-05 favicon to profile names and NIP-05 web of trust feed (Terry Yiu)
- Display uploading indicator in post view (Swift Coder)
### Changed
- Improved the image sizing behavior on the image carousel for a smoother experience (Daniel DAquino)
- Handle npub correctly in draft notes (Askia Linder)
- Move users-section to be last in muted view (Askia Linder)
- Removed media from regular link previews if media is already being shown (Terry Yiu)
- Renamed Friends of Friends to Trusted Network (Terry Yiu)
- Added privacy-based redaction to nsec in key settings view (Terry Yiu)
- Added privacy-based redaction to wallet view (Terry Yiu)
- Renamed Bitcoin Beach wallet to Blink (Terry Yiu)
### Fixed
- Fixed #nsfw tag filtering to be case insensitive (Terry Yiu)
- Fixed stretchy banner header in Edit profile (Swift)
- Fixed note rendering to include regular link previews with media removed when media previews are disabled (Terry Yiu)
- Improve error handling on wallet send feature (Daniel DAquino)
- Fixed issue where the text "??" would appear on the balance while loading (Daniel DAquino)
- Hide end previewables when hashtags are present (Terry Yiu)
- Fixed wallet transactions to always show profile display name unless there is no pubkey (Terry Yiu)
- Fixed quotes view header alignment (Terry Yiu)
### Removed
- Removed hashtags in Universe View (ericholguin)
[1.15]: https://github.com/damus-io/damus/releases/tag/v1.15
## [1.14] - 2025-05-25
### Added
@@ -7,6 +7,7 @@
import Foundation
@MainActor
struct NotificationExtensionState: HeadlessDamusState {
let ndb: Ndb
let settings: UserSettingsStore
@@ -125,8 +125,7 @@ struct NotificationFormatter {
let src = zap.request.ev
let pk = zap.is_anon ? ANON_PUBKEY : src.pubkey
let profile_txn = profiles.lookup(id: pk)
let profile = profile_txn?.unsafeUnownedValue
let profile = try? profiles.lookup(id: pk)
let name = Profile.displayName(profile: profile, pubkey: pk).displayName.truncate(maxLength: 50)
let sats = NSNumber(value: (Double(zap.invoice.amount) / 1000.0))
@@ -44,63 +44,61 @@ class NotificationService: UNNotificationServiceExtension {
// Log that we got a push notification
Log.debug("Got nostr event push notification from pubkey %s", for: .push_notifications, nostr_event.pubkey.hex())
guard let state = NotificationExtensionState() else {
Log.debug("Failed to open nostrdb", for: .push_notifications)
Task {
guard let state = await NotificationExtensionState() else {
Log.debug("Failed to open nostrdb", for: .push_notifications)
// Something failed to initialize so let's go for the next best thing
guard let improved_content = NotificationFormatter.shared.format_message(event: nostr_event) else {
// We cannot format this nostr event. Suppress notification.
contentHandler(UNNotificationContent())
// Something failed to initialize so let's go for the next best thing
guard let improved_content = NotificationFormatter.shared.format_message(event: nostr_event) else {
// We cannot format this nostr event. Suppress notification.
contentHandler(UNNotificationContent())
return
}
contentHandler(improved_content)
return
}
contentHandler(improved_content)
return
}
let sender_profile = {
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
let profile = txn?.unsafeUnownedValue?.profile
let picture = ((profile?.picture.map { URL(string: $0) }) ?? URL(string: robohash(nostr_event.pubkey)))!
return ProfileBuf(picture: picture,
name: profile?.name,
display_name: profile?.display_name,
nip05: profile?.nip05)
}()
let sender_pubkey = nostr_event.pubkey
let sender_profile = {
let profile = try? state.profiles.lookup(id: nostr_event.pubkey)
let picture = ((profile?.picture.map { URL(string: $0) }) ?? URL(string: robohash(nostr_event.pubkey)))!
return ProfileBuf(picture: picture,
name: profile?.name,
display_name: profile?.display_name,
nip05: profile?.nip05)
}()
let sender_pubkey = nostr_event.pubkey
// Don't show notification details that match mute list.
// TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
if state.mutelist_manager.is_event_muted(nostr_event) {
// We cannot really suppress muted notifications until we have the notification supression entitlement.
// The best we can do if we ever get those muted notifications (which we generally won't due to server-side processing) is to obscure the details
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("Muted event", comment: "Title for a push notification which has been muted")
content.body = NSLocalizedString("This is an event that has been muted according to your mute list rules. We cannot suppress this notification, but we obscured the details to respect your preferences", comment: "Description for a push notification which has been muted, and explanation that we cannot suppress it")
content.sound = UNNotificationSound.default
contentHandler(content)
return
}
// Don't show notification details that match mute list.
// TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
if await state.mutelist_manager.is_event_muted(nostr_event) {
// We cannot really suppress muted notifications until we have the notification supression entitlement.
// The best we can do if we ever get those muted notifications (which we generally won't due to server-side processing) is to obscure the details
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("Muted event", comment: "Title for a push notification which has been muted")
content.body = NSLocalizedString("This is an event that has been muted according to your mute list rules. We cannot suppress this notification, but we obscured the details to respect your preferences", comment: "Description for a push notification which has been muted, and explanation that we cannot suppress it")
content.sound = UNNotificationSound.default
contentHandler(content)
return
}
guard should_display_notification(state: state, event: nostr_event, mode: .push) else {
Log.debug("should_display_notification failed", for: .push_notifications)
// We should not display notification for this event. Suppress notification.
// contentHandler(UNNotificationContent())
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
contentHandler(request.content)
return
}
guard await should_display_notification(state: state, event: nostr_event, mode: .push) else {
Log.debug("should_display_notification failed", for: .push_notifications)
// We should not display notification for this event. Suppress notification.
// contentHandler(UNNotificationContent())
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
contentHandler(request.content)
return
}
guard let notification_object = generate_local_notification_object(ndb: state.ndb, from: nostr_event, state: state) else {
Log.debug("generate_local_notification_object failed", for: .push_notifications)
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
// contentHandler(UNNotificationContent())
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
contentHandler(request.content)
return
}
Task {
guard let notification_object = generate_local_notification_object(ndb: state.ndb, from: nostr_event, state: state) else {
Log.debug("generate_local_notification_object failed", for: .push_notifications)
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
// contentHandler(UNNotificationContent())
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
contentHandler(request.content)
return
}
let sender_dn = DisplayName(name: sender_profile.name, display_name: sender_profile.display_name, pubkey: sender_pubkey)
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: sender_dn.displayName, notify: notification_object, state: state) else {
@@ -186,8 +184,13 @@ func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: Str
// gather recipients
if let recipient_note_id = note.direct_replies() {
let replying_to = ndb.lookup_note(recipient_note_id)
if let replying_to_pk = replying_to?.unsafeUnownedValue?.pubkey {
let replying_to_pk = try? ndb.lookup_note(recipient_note_id, borrow: { replying_to_note -> Pubkey? in
switch replying_to_note {
case .none: return nil
case .some(let note): return note.pubkey
}
})
if let replying_to_pk {
meta.isReplyToCurrentUser = replying_to_pk == our_pubkey
if replying_to_pk != sender_pk {
@@ -247,8 +250,12 @@ func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: Str
}
func pubkey_to_inperson(ndb: Ndb, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson {
let profile_txn = ndb.lookup_profile(pubkey)
let profile = profile_txn?.unsafeUnownedValue?.profile
let profile = try? ndb.lookup_profile(pubkey, borrow: { profileRecord in
switch profileRecord {
case .some(let pr): return pr.profile
case .none: return nil
}
})
let name = profile?.name
let display_name = profile?.display_name
let nip05 = profile?.nip05
+1 -1
View File
@@ -154,7 +154,7 @@ We have a few mailing lists that anyone can join to get involved in damus develo
### Contributing
See [docs/CONTRIBUTING.md](./docs/CONTRIBUTING.md)
Before starting to work on any contributions, please read [docs/CONTRIBUTING.md](./docs/CONTRIBUTING.md).
### Privacy
Your internet protocol (IP) address is exposed to the relays you connect to, and third party media hosters (e.g. nostr.build, imgur.com, giphy.com, youtube.com etc.) that render on Damus. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
{
"originHash" : "8d71e78d1d7bdc5a85a38932a14f84af755a9e34aeab19f9d540bd11a7b32fbc",
"originHash" : "c718c1e7dcc1a07671694b2d7d7311e11804fbbaf22f4b81e49523a3df816ad6",
"pins" : [
{
"identity" : "codescanner",
@@ -17,15 +17,6 @@
"revision" : "e74bbbfbef939224b242ae7c342a90e60b88b5ce"
}
},
{
"identity" : "dswaveformimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/dmrschmidt/DSWaveformImage",
"state" : {
"revision" : "4c56578ee10128ee2b2c04c9c5aa73812de722db",
"version" : "14.2.2"
}
},
{
"identity" : "emojikit",
"kind" : "remoteSourceControl",
@@ -71,6 +62,24 @@
"version" : "8.3.1"
}
},
{
"identity" : "negentropy-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/damus-io/negentropy-swift",
"state" : {
"revision" : "181789fb0842f5666020db87ffea0d120cc5aa5d",
"version" : "0.1.0"
}
},
{
"identity" : "nostr-sdk-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/rust-nostr/nostr-sdk-swift",
"state" : {
"revision" : "42fe7d379b326583ae8282a5fd7232745f195906",
"version" : "0.44.0"
}
},
{
"identity" : "secp256k1.swift",
"kind" : "remoteSourceControl",
@@ -55,7 +55,6 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
enableAddressSanitizer = "YES"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
+37 -1
View File
@@ -25,6 +25,23 @@ enum AppAccessibilityIdentifiers: String {
case sign_in_confirm_button
// MARK: Sign Up / Create Account
// Prefix: `sign_up`
/// Button to navigate to create account view
case sign_up_option_button
/// Text field for entering name during account creation
case sign_up_name_field
/// Text field for entering bio during account creation
case sign_up_bio_field
/// Button to proceed to the next step after entering profile info
case sign_up_next_button
/// Button to save keys after account creation
case sign_up_save_keys_button
/// Button to skip saving keys
case sign_up_skip_save_keys_button
// MARK: Onboarding
// Prefix: `onboarding`
@@ -43,9 +60,22 @@ enum AppAccessibilityIdentifiers: String {
// MARK: Post composer
// Prefix: `post_composer`
/// The cancel post button
case post_composer_cancel_button
/// The text view where the user types their note
case post_composer_text_view
/// A user result in the mention autocomplete list
case post_composer_mention_user_result
// MARK: Post button (FAB)
// Prefix: `post_button`
/// The floating action button to create a new post
case post_button
// MARK: Main interface layout
// Prefix: `main`
@@ -60,6 +90,12 @@ enum AppAccessibilityIdentifiers: String {
/// The profile option in the side menu
case side_menu_profile_button
/// The logout button in the side menu
case side_menu_logout_button
/// The logout confirmation button in the alert dialog
case side_menu_logout_confirm_button
// MARK: Items specific to the user's own profile
// Prefix: `own_profile`
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "damooseLabs.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

+206 -298
View File
@@ -135,6 +135,7 @@ struct ContentView: View {
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
let sub_id = UUID().description
@State var damusClosingTask: Task<Void, Never>? = nil
// connect retry timer
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@@ -173,13 +174,13 @@ struct ContentView: View {
}
case .home:
PostingTimelineView(damus_state: damus_state!, home: home, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset)
PostingTimelineView(damus_state: damus_state!, home: home, homeEvents: home.events, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset)
case .notifications:
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
case .dms:
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle)
DirectMessagesView(damus_state: damus_state!, home: home, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle)
}
}
.background(DamusColors.adaptableWhite)
@@ -195,6 +196,9 @@ struct ContentView: View {
}
}
}
.onAppear {
notify(.display_tabbar(true))
}
}
func MaybeReportView(target: ReportTarget) -> some View {
@@ -299,16 +303,20 @@ struct ContentView: View {
.ignoresSafeArea(.keyboard)
.edgesIgnoringSafeArea(hide_bar ? [.bottom] : [])
.onAppear() {
self.connect()
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)
setup_notifications()
if !hasSeenOnboardingSuggestions || damus_state!.settings.always_show_onboarding_suggestions {
active_sheet = .onboardingSuggestions
hasSeenOnboardingSuggestions = true
}
self.appDelegate?.state = damus_state
Task { // We probably don't need this to be a detached task. According to https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Defining-and-Calling-Asynchronous-Functions, awaits are only suspension points that do not block the thread.
await self.listenAndHandleLocalNotifications()
Task {
await self.connect()
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)
setup_notifications()
if !hasSeenOnboardingSuggestions || damus_state!.settings.always_show_onboarding_suggestions {
if damus_state.is_privkey_user {
active_sheet = .onboardingSuggestions
hasSeenOnboardingSuggestions = true
}
}
self.appDelegate?.state = damus_state
Task { // We probably don't need this to be a detached task. According to https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Defining-and-Calling-Asynchronous-Functions, awaits are only suspension points that do not block the thread.
await self.listenAndHandleLocalNotifications()
}
}
}
.sheet(item: $active_sheet) { item in
@@ -370,7 +378,7 @@ struct ContentView: View {
self.hide_bar = !show
}
.onReceive(timer) { n in
self.damus_state?.nostrNetwork.postbox.try_flushing_events()
Task{ await self.damus_state?.nostrNetwork.postbox.try_flushing_events() }
self.damus_state!.profiles.profile_data(self.damus_state!.pubkey).status.try_expire()
}
.onReceive(handle_notify(.report)) { target in
@@ -381,43 +389,46 @@ struct ContentView: View {
self.confirm_mute = true
}
.onReceive(handle_notify(.attached_wallet)) { nwc in
// update the lightning address on our profile when we attach a
// wallet with an associated
guard let ds = self.damus_state,
let lud16 = nwc.lud16,
let keypair = ds.keypair.to_full(),
let profile_txn = ds.profiles.lookup(id: ds.pubkey),
let profile = profile_txn.unsafeUnownedValue,
lud16 != profile.lud16 else {
return
Task {
try? await damus_state.nostrNetwork.userRelayList.load() // Reload relay list to apply changes
// update the lightning address on our profile when we attach a
// wallet with an associated
guard let ds = self.damus_state,
let lud16 = nwc.lud16,
let keypair = ds.keypair.to_full(),
let profile = try? ds.profiles.lookup(id: ds.pubkey),
lud16 != profile.lud16 else {
return
}
// clear zapper cache for old lud16
if profile.lud16 != nil {
// TODO: should this be somewhere else, where we process profile events!?
invalidate_zapper_cache(pubkey: keypair.pubkey, profiles: ds.profiles, lnurl: ds.lnurls)
}
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: profile.reactions)
guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
await ds.nostrNetwork.postbox.send(ev)
}
// clear zapper cache for old lud16
if profile.lud16 != nil {
// TODO: should this be somewhere else, where we process profile events!?
invalidate_zapper_cache(pubkey: keypair.pubkey, profiles: ds.profiles, lnurl: ds.lnurls)
}
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: profile.reactions)
guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
ds.nostrNetwork.postbox.send(ev)
}
.onReceive(handle_notify(.broadcast)) { ev in
guard let ds = self.damus_state else { return }
ds.nostrNetwork.postbox.send(ev)
Task { await ds.nostrNetwork.postbox.send(ev) }
}
.onReceive(handle_notify(.unfollow)) { target in
guard let state = self.damus_state else { return }
_ = handle_unfollow(state: state, unfollow: target.follow_ref)
Task { _ = await handle_unfollow(state: state, unfollow: target.follow_ref) }
}
.onReceive(handle_notify(.unfollowed)) { unfollow in
home.resubscribe(.unfollowing(unfollow))
}
.onReceive(handle_notify(.follow)) { target in
guard let state = self.damus_state else { return }
handle_follow_notif(state: state, target: target)
Task { await handle_follow_notif(state: state, target: target) }
}
.onReceive(handle_notify(.followed)) { _ in
home.resubscribe(.following)
@@ -428,8 +439,10 @@ struct ContentView: View {
return
}
if !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post) {
self.active_sheet = nil
Task {
if await !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post, clientTag: state.clientTagComponents) {
self.active_sheet = nil
}
}
}
.onReceive(handle_notify(.new_mutes)) { _ in
@@ -447,6 +460,9 @@ struct ContentView: View {
.onReceive(handle_notify(.present_full_screen_item)) { item in
self.active_full_screen_item = item
}
.onReceive(handle_notify(.favoriteUpdated)) { _ in
home.subscribe_to_favorites()
}
.onReceive(handle_notify(.zapping)) { zap_ev in
guard !zap_ev.is_custom else {
return
@@ -472,35 +488,35 @@ struct ContentView: View {
}
}
.onReceive(handle_notify(.disconnect_relays)) { () in
damus_state.nostrNetwork.pool.disconnect()
Task { await damus_state.nostrNetwork.disconnectRelays() }
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
print("txn: 📙 DAMUS ACTIVE NOTIFY")
if damus_state.ndb.reopen() {
print("txn: NOSTRDB REOPENED")
} else {
print("txn: NOSTRDB FAILED TO REOPEN closed:\(damus_state.ndb.is_closed)")
}
if damus_state.purple.checkout_ids_in_progress.count > 0 {
// For extra assurance, run this after one second, to avoid race conditions if the app is also handling a damus purple welcome url.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
Task {
let freshly_completed_checkout_ids = try? await damus_state.purple.check_status_of_checkouts_in_progress()
let there_is_a_completed_checkout: Bool = (freshly_completed_checkout_ids?.count ?? 0) > 0
let account_info = try await damus_state.purple.fetch_account(pubkey: self.keypair.pubkey)
if there_is_a_completed_checkout == true && account_info?.active == true {
if damus_state.purple.onboarding_status.user_has_never_seen_the_onboarding_before() {
// Show welcome sheet
self.active_sheet = .purple_onboarding
}
else {
self.active_sheet = .purple(DamusPurpleURL.init(is_staging: damus_state.purple.environment == .staging, variant: .landing))
Task {
if damus_state.ndb.reopen() {
print("txn: NOSTRDB REOPENED")
} else {
print("txn: NOSTRDB FAILED TO REOPEN closed:\(damus_state.ndb.is_closed)")
}
if damus_state.purple.checkout_ids_in_progress.count > 0 {
// For extra assurance, run this after one second, to avoid race conditions if the app is also handling a damus purple welcome url.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
Task {
let freshly_completed_checkout_ids = try? await damus_state.purple.check_status_of_checkouts_in_progress()
let there_is_a_completed_checkout: Bool = (freshly_completed_checkout_ids?.count ?? 0) > 0
let account_info = try await damus_state.purple.fetch_account(pubkey: self.keypair.pubkey)
if there_is_a_completed_checkout == true && account_info?.active == true {
if damus_state.purple.onboarding_status.user_has_never_seen_the_onboarding_before() {
// Show welcome sheet
self.active_sheet = .purple_onboarding
}
else {
self.active_sheet = .purple(DamusPurpleURL.init(is_staging: damus_state.purple.environment == .staging, variant: .landing))
}
}
}
}
}
}
Task {
await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification)
}
}
@@ -509,8 +525,21 @@ struct ContentView: View {
switch phase {
case .background:
print("txn: 📙 DAMUS BACKGROUNDED")
Task { @MainActor in
damus_state.ndb.close()
let bgTask = this_app.beginBackgroundTask(withName: "Closing things down gracefully", expirationHandler: { [weak damus_state] in
})
damusClosingTask = Task { @MainActor in
Log.debug("App background signal handling: App being backgrounded", for: .app_lifecycle)
let startTime = CFAbsoluteTimeGetCurrent()
// Stop periodic snapshots
await damus_state.snapshotManager.stopPeriodicSnapshots()
await damus_state.nostrNetwork.handleAppBackgroundRequest() // Close ndb streaming tasks before closing ndb to avoid memory errors
Log.debug("App background signal handling: Nostr network manager closed after %.2f seconds", for: .app_lifecycle, CFAbsoluteTimeGetCurrent() - startTime)
this_app.endBackgroundTask(bgTask)
}
break
case .inactive:
@@ -518,26 +547,34 @@ struct ContentView: View {
break
case .active:
print("txn: 📙 DAMUS ACTIVE")
damus_state.nostrNetwork.pool.ping()
Task {
await damusClosingTask?.value // Wait for the closing task to finish before reopening things, to avoid race conditions
damusClosingTask = nil
await damus_state.nostrNetwork.handleAppForegroundRequest()
// Restart periodic snapshots when returning to foreground
await damus_state.snapshotManager.startPeriodicSnapshots()
}
@unknown default:
break
}
}
.onReceive(handle_notify(.onlyzaps_mode)) { hide in
home.filter_events()
guard let ds = damus_state,
let profile_txn = ds.profiles.lookup(id: ds.pubkey),
let profile = profile_txn.unsafeUnownedValue,
let keypair = ds.keypair.to_full()
else {
return
Task {
home.filter_events()
guard let ds = damus_state,
let profile = try? ds.profiles.lookup(id: ds.pubkey),
let keypair = ds.keypair.to_full()
else {
return
}
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: !hide)
guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
await ds.nostrNetwork.postbox.send(profile_ev)
}
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: !hide)
guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
ds.nostrNetwork.postbox.send(profile_ev)
}
.alert(NSLocalizedString("User muted", comment: "Alert message to indicate the user has been muted"), isPresented: $user_muted_confirm, actions: {
Button(NSLocalizedString("Thanks!", comment: "Button to close out of alert that informs that the action to muted a user was successful.")) {
@@ -545,8 +582,7 @@ struct ContentView: View {
}
}, message: {
if case let .user(pubkey, _) = self.muting {
let profile_txn = damus_state!.profiles.lookup(id: pubkey)
let profile = profile_txn?.unsafeUnownedValue
let profile = try? damus_state!.profiles.lookup(id: pubkey)
let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
Text("\(name) has been muted", comment: "Alert message that informs a user was muted.")
} else {
@@ -560,20 +596,22 @@ struct ContentView: View {
}
Button(NSLocalizedString("Yes, Overwrite", comment: "Text of button that confirms to overwrite the existing mutelist.")) {
guard let ds = damus_state,
let keypair = ds.keypair.to_full(),
let muting,
let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: muting)
else {
return
Task {
guard let ds = damus_state,
let keypair = ds.keypair.to_full(),
let muting,
let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: muting)
else {
return
}
ds.mutelist_manager.set_mutelist(mutelist)
await ds.nostrNetwork.postbox.send(mutelist)
confirm_overwrite_mutelist = false
confirm_mute = false
user_muted_confirm = true
}
ds.mutelist_manager.set_mutelist(mutelist)
ds.nostrNetwork.postbox.send(mutelist)
confirm_overwrite_mutelist = false
confirm_mute = false
user_muted_confirm = true
}
}, message: {
Text("No mute list found, create a new one? This will overwrite any previous mute lists.", comment: "Alert message prompt that asks if the user wants to create a new mute list, overwriting previous mute lists.")
@@ -587,6 +625,10 @@ struct ContentView: View {
return
}
if ds.mutelist_manager.event == nil {
home.load_latest_mutelist_event_from_damus_state()
}
if ds.mutelist_manager.event == nil {
confirm_overwrite_mutelist = true
} else {
@@ -601,13 +643,12 @@ struct ContentView: View {
}
ds.mutelist_manager.set_mutelist(ev)
ds.nostrNetwork.postbox.send(ev)
Task { await ds.nostrNetwork.postbox.send(ev) }
}
}
}, message: {
if case let .user(pubkey, _) = muting {
let profile_txn = damus_state?.profiles.lookup(id: pubkey)
let profile = profile_txn?.unsafeUnownedValue
let profile = try? damus_state?.profiles.lookup(id: pubkey)
let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
Text("Mute \(name)?", comment: "Alert message prompt to ask if a user should be muted.")
} else {
@@ -653,7 +694,7 @@ struct ContentView: View {
self.execute_open_action(openAction)
}
func connect() {
func connect() async {
// nostrdb
var mndb = Ndb()
if mndb == nil {
@@ -675,12 +716,13 @@ struct ContentView: View {
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
let new_relay_filters = load_relay_filters(pubkey) == nil
let new_relay_filters = await load_relay_filters(pubkey) == nil
self.damus_state = DamusState(keypair: keypair,
likes: EventCounter(our_pubkey: pubkey),
boosts: EventCounter(our_pubkey: pubkey),
contacts: Contacts(our_pubkey: pubkey),
contactCards: ContactCardManager(),
mutelist_manager: MutelistManager(user_keypair: keypair),
profiles: Profiles(ndb: ndb),
dms: home.dms,
@@ -706,6 +748,8 @@ struct ContentView: View {
home.damus_state = self.damus_state!
await damus_state.snapshotManager.startPeriodicSnapshots()
if let damus_state, damus_state.purple.enable_purple {
// Assign delegate so that we can send receipts to the Purple API server as soon as we get updates from user's purchases
StoreObserver.standard.delegate = damus_state.purple
@@ -717,8 +761,7 @@ struct ContentView: View {
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
}
damus_state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: home.handle_event)
damus_state.nostrNetwork.connect()
if #available(iOS 17, *) {
if damus_state.settings.developer_mode && damus_state.settings.reset_tips_on_launch {
@@ -734,29 +777,36 @@ struct ContentView: View {
Log.error("Failed to configure tips: %s", for: .tips, error.localizedDescription)
}
}
await damus_state.nostrNetwork.connect()
// TODO: Move this to a better spot. Not sure what is the best signal to listen to for sending initial filters
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: {
self.home.send_initial_filters()
})
}
func music_changed(_ state: MusicState) {
guard let damus_state else { return }
switch state {
case .playback_state:
break
case .song(let song):
guard let song, let kp = damus_state.keypair.to_full() else { return }
let pdata = damus_state.profiles.profile_data(damus_state.pubkey)
let desc = "\(song.title ?? "Unknown") - \(song.artist ?? "Unknown")"
let encodedDesc = desc.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
let url = encodedDesc.flatMap { enc in
URL(string: "spotify:search:\(enc)")
Task {
guard let damus_state else { return }
switch state {
case .playback_state:
break
case .song(let song):
guard let song, let kp = damus_state.keypair.to_full() else { return }
let pdata = damus_state.profiles.profile_data(damus_state.pubkey)
let desc = "\(song.title ?? "Unknown") - \(song.artist ?? "Unknown")"
let encodedDesc = desc.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
let url = encodedDesc.flatMap { enc in
URL(string: "spotify:search:\(enc)")
}
let music = UserStatus(type: .music, expires_at: Date.now.addingTimeInterval(song.playbackDuration), content: desc, created_at: UInt32(Date.now.timeIntervalSince1970), url: url)
pdata.status.music = music
guard let ev = music.to_note(keypair: kp) else { return }
await damus_state.nostrNetwork.postbox.send(ev)
}
let music = UserStatus(type: .music, expires_at: Date.now.addingTimeInterval(song.playbackDuration), content: desc, created_at: UInt32(Date.now.timeIntervalSince1970), url: url)
pdata.status.music = music
guard let ev = music.to_note(keypair: kp) else { return }
damus_state.nostrNetwork.postbox.send(ev)
}
}
@@ -806,7 +856,7 @@ struct TopbarSideMenuButton: View {
Button {
isSideBarOpened.toggle()
} label: {
ProfilePicView(pubkey: damus_state.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
ProfilePicView(pubkey: damus_state.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation, damusState: damus_state)
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
.accessibilityHidden(true) // Knowing there is a profile picture here leads to no actionable outcome to VoiceOver users, so it is best not to show it
@@ -908,7 +958,7 @@ func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [Nos
}
}
@MainActor
func setup_notifications() {
this_app.registerForRemoteNotifications()
let center = UNUserNotificationCenter.current()
@@ -943,169 +993,11 @@ enum FindEventType {
}
enum FoundEvent {
// TODO: Why not return the profile record itself? Right now the code probably just wants to trigger ndb to ingest the profile record and be available at ndb in parallel, but it would be cleaner if the function that uses this simply does that ndb query on their behalf.
case profile(Pubkey)
case event(NostrEvent)
}
/// Finds an event from NostrDB if it exists, or from the network
///
/// This is the callback version. There is also an asyc/await version of this function.
///
/// - Parameters:
/// - state: Damus state
/// - query_: The query, including the event being looked for, and the relays to use when looking
/// - callback: The function to call with results
func find_event(state: DamusState, query query_: FindEvent, callback: @escaping (FoundEvent?) -> ()) {
return find_event_with_subid(state: state, query: query_, subid: UUID().description, callback: callback)
}
/// Finds an event from NostrDB if it exists, or from the network
///
/// This is a the async/await version of `find_event`. Use this when using callbacks is impossible or cumbersome.
///
/// - Parameters:
/// - state: Damus state
/// - query_: The query, including the event being looked for, and the relays to use when looking
/// - callback: The function to call with results
func find_event(state: DamusState, query query_: FindEvent) async -> FoundEvent? {
await withCheckedContinuation { continuation in
find_event(state: state, query: query_) { event in
var already_resumed = false
if !already_resumed { // Ensure we do not resume twice, as it causes a crash
continuation.resume(returning: event)
already_resumed = true
}
}
}
}
func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: String, callback: @escaping (FoundEvent?) -> ()) {
var filter: NostrFilter? = nil
let find_from = query_.find_from
let query = query_.type
switch query {
case .profile(let pubkey):
if let profile_txn = state.ndb.lookup_profile(pubkey),
let record = profile_txn.unsafeUnownedValue,
record.profile != nil
{
callback(.profile(pubkey))
return
}
filter = NostrFilter(kinds: [.metadata], limit: 1, authors: [pubkey])
case .event(let evid):
if let ev = state.events.lookup(evid) {
callback(.event(ev))
return
}
filter = NostrFilter(ids: [evid], limit: 1)
}
var attempts: Int = 0
var has_event = false
guard let filter else { return }
state.nostrNetwork.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in
guard case .nostr_event(let ev) = res else {
return
}
guard ev.subid == subid else {
return
}
switch ev {
case .ok:
break
case .event(_, let ev):
has_event = true
state.nostrNetwork.pool.unsubscribe(sub_id: subid)
switch query {
case .profile:
if ev.known_kind == .metadata {
callback(.profile(ev.pubkey))
}
case .event:
callback(.event(ev))
}
case .eose:
if !has_event {
attempts += 1
if attempts >= state.nostrNetwork.pool.our_descriptors.count {
callback(nil) // If we could not find any events in any of the relays we are connected to, send back nil
}
}
state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose
case .notice:
break
case .auth:
break
}
}
}
/// Finds a replaceable event based on an `naddr` address.
///
/// This is the callback version of the function. There is another function that makes use of async/await
///
/// - Parameters:
/// - damus_state: The Damus state
/// - naddr: the `naddr` address
/// - callback: A function to handle the found event
func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (NostrEvent?) -> ()) {
let nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
let subid = UUID().description
damus_state.nostrNetwork.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in
guard case .nostr_event(let ev) = res else {
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
return
}
if case .event(_, let ev) = ev {
for tag in ev.tags {
if(tag.count >= 2 && tag[0].string() == "d"){
if (tag[1].string() == naddr.identifier){
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
callback(ev)
return
}
}
}
}
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
}
}
/// Finds a replaceable event based on an `naddr` address.
///
/// This is the async/await version of the function. Another version of this function which makes use of callback functions also exists .
///
/// - Parameters:
/// - damus_state: The Damus state
/// - naddr: the `naddr` address
/// - callback: A function to handle the found event
func naddrLookup(damus_state: DamusState, naddr: NAddr) async -> NostrEvent? {
await withCheckedContinuation { continuation in
var already_resumed = false
naddrLookup(damus_state: damus_state, naddr: naddr) { event in
if !already_resumed { // Ensure we do not resume twice, as it causes a crash
continuation.resume(returning: event)
already_resumed = true
}
}
}
}
func timeline_name(_ timeline: Timeline?) -> String {
guard let timeline else {
return ""
@@ -1123,14 +1015,15 @@ func timeline_name(_ timeline: Timeline?) -> String {
}
@discardableResult
func handle_unfollow(state: DamusState, unfollow: FollowRef) -> Bool {
@MainActor
func handle_unfollow(state: DamusState, unfollow: FollowRef) async -> Bool {
guard let keypair = state.keypair.to_full() else {
return false
}
let old_contacts = state.contacts.event
guard let ev = unfollow_reference(postbox: state.nostrNetwork.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
guard let ev = await unfollow_reference(postbox: state.nostrNetwork.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
else {
return false
}
@@ -1151,12 +1044,13 @@ func handle_unfollow(state: DamusState, unfollow: FollowRef) -> Bool {
}
@discardableResult
func handle_follow(state: DamusState, follow: FollowRef) -> Bool {
@MainActor
func handle_follow(state: DamusState, follow: FollowRef) async -> Bool {
guard let keypair = state.keypair.to_full() else {
return false
}
guard let ev = follow_reference(box: state.nostrNetwork.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
guard let ev = await follow_reference(box: state.nostrNetwork.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
else {
return false
}
@@ -1176,37 +1070,51 @@ func handle_follow(state: DamusState, follow: FollowRef) -> Bool {
}
@discardableResult
func handle_follow_notif(state: DamusState, target: FollowTarget) -> Bool {
func handle_follow_notif(state: DamusState, target: FollowTarget) async -> Bool {
switch target {
case .pubkey(let pk):
state.contacts.add_friend_pubkey(pk)
await state.contacts.add_friend_pubkey(pk)
case .contact(let ev):
state.contacts.add_friend_contact(ev)
await state.contacts.add_friend_contact(ev)
}
return handle_follow(state: state, follow: target.follow_ref)
return await handle_follow(state: state, follow: target.follow_ref)
}
func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, post: NostrPostResult) -> Bool {
/// Handles a post notification by converting the post to a signed nostr event and broadcasting it.
///
/// - Parameters:
/// - keypair: The user's full keypair used to sign the event.
/// - postbox: The postbox used to broadcast the event to relays.
/// - events: The event cache used to look up referenced events for rebroadcasting.
/// - post: The post result, either a post to publish or a cancellation.
/// - clientTag: Optional client tag array (e.g., `["client", "Damus"]`) to include in the event,
/// identifying which application created the post. Pass `nil` to omit the tag.
/// - Returns: `true` if the post was successfully converted and sent, `false` if the post was
/// cancelled or if event conversion failed.
///
/// When successful, this function also rebroadcasts up to 3 referenced events and 3 quoted events
/// to help ensure they are available on relays.
func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, post: NostrPostResult, clientTag: [String]? = nil) async -> Bool {
switch post {
case .post(let post):
//let post = tup.0
//let to_relays = tup.1
print("post \(post.content)")
guard let new_ev = post.to_event(keypair: keypair) else {
guard let new_ev = post.to_event(keypair: keypair, clientTag: clientTag) else {
return false
}
postbox.send(new_ev)
await postbox.send(new_ev)
for eref in new_ev.referenced_ids.prefix(3) {
// also broadcast at most 3 referenced events
if let ev = events.lookup(eref) {
postbox.send(ev)
await postbox.send(ev)
}
}
for qref in new_ev.referenced_quote_ids.prefix(3) {
// also broadcast at most 3 referenced quoted events
if let ev = events.lookup(qref.note_id) {
postbox.send(ev)
await postbox.send(ev)
}
}
return true
@@ -1218,16 +1126,18 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
extension LossyLocalNotification {
/// Computes a view open action from a mention reference.
/// Use this when opening a user-presentable interface to a specific mention reference.
/// Converts this mention's NIP-19 reference into a UI action for the app.
///
/// Maps NPUB and NPROFILE references to profile routes, NOTE/NEVENT/NADDR references to loadable note routes, NSCRIPT to a script view, and returns an error sheet for deprecated or unsafe references (`nrelay`, `nsec`).
/// - Returns: A `ContentView.ViewOpenAction` that represents the route or sheet to present for this mention.
func toViewOpenAction() -> ContentView.ViewOpenAction {
switch self.mention.nip19 {
case .npub(let pubkey):
return .route(.ProfileByKey(pubkey: pubkey))
case .note(let noteId):
return .route(.LoadableNostrEvent(note_reference: .note_id(noteId)))
return .route(.LoadableNostrEvent(note_reference: .note_id(noteId, relays: [])))
case .nevent(let nEvent):
// TODO: Improve this by implementing a route that handles nevents with their relay hints.
return .route(.LoadableNostrEvent(note_reference: .note_id(nEvent.noteid)))
return .route(.LoadableNostrEvent(note_reference: .note_id(nEvent.noteid, relays: nEvent.relays)))
case .nprofile(let nProfile):
// TODO: Improve this by implementing a profile route that handles nprofiles with their relay hints.
return .route(.ProfileByKey(pubkey: nProfile.author))
@@ -1254,10 +1164,8 @@ extension LossyLocalNotification {
}
}
func logout(_ state: DamusState?)
{
state?.close()
notify(.logout)
}
}
+2 -2
View File
@@ -12,11 +12,11 @@ struct NIP04 {}
extension NIP04 {
/// Encrypts a message using NIP-04.
static func encrypt_message(message: String, privkey: Privkey, to_pk: Pubkey, encoding: EncEncoding = .base64) -> String? {
let iv = random_bytes(count: 16).bytes
let iv = random_bytes(count: 16).byteArray
guard let shared_sec = get_shared_secret(privkey: privkey, pubkey: to_pk) else {
return nil
}
let utf8_message = Data(message.utf8).bytes
let utf8_message = Data(message.utf8).byteArray
guard let enc_message = aes_encrypt(data: utf8_message, iv: iv, shared_sec: shared_sec) else {
return nil
}
+4
View File
@@ -42,6 +42,10 @@ extension NIP65 {
self.relays = Self.relayOrderedDictionary(from: relays)
}
init() {
self.relays = Self.relayOrderedDictionary(from: [])
}
init(relays: [RelayURL]) {
let relayItemList = relays.map({ RelayItem(url: $0, rwConfiguration: .readWrite) })
self.relays = Self.relayOrderedDictionary(from: relayItemList)
@@ -0,0 +1,196 @@
//
// EntityPreloader.swift
// damus
//
// Created by Daniel D'Aquino on 2026-01-22.
//
import Foundation
import os
import Negentropy
extension NostrNetworkManager {
/// Preloads entities referenced in notes to improve user experience.
///
/// This actor efficiently batches entity preload requests to avoid overloading the network.
/// Currently limited to preloading profile metadata, but designed to be expanded to other
/// entity types (e.g., referenced events, media) in the future.
///
/// ## Implementation notes
///
/// - Uses a queue to collect preload requests
/// - Batches requests intelligently: either when 500 pending requests accumulate, or after 1 second
/// - Uses standard Nostr subscriptions to fetch metadata
/// - Runs a long-running task to process the queue continuously
actor EntityPreloader {
private let pool: RelayPool
private let ndb: Ndb
private let queue: QueueableNotify<Set<Pubkey>>
private var processingTask: Task<Void, Never>?
private var accumulatedPubkeys = Set<Pubkey>()
private static let logger = Logger(
subsystem: Constants.MAIN_APP_BUNDLE_IDENTIFIER,
category: "entity_preloader"
)
/// Maximum number of items allowed in the queue before old items are discarded
private static let maxQueueItems = 1000
/// Batch size threshold - preload immediately when this many requests are pending
private static let batchSizeThreshold = 500
/// Time threshold - preload after this duration even if batch size not reached
private static let timeThreshold: Duration = .seconds(1)
init(pool: RelayPool, ndb: Ndb) {
self.pool = pool
self.ndb = ndb
self.queue = QueueableNotify<Set<Pubkey>>(maxQueueItems: Self.maxQueueItems)
}
/// Starts the preloader's background processing task
func start() {
guard processingTask == nil else {
Self.logger.warning("EntityPreloader already started")
return
}
Self.logger.info("Starting EntityPreloader")
processingTask = Task {
await monitorQueue()
}
}
/// Stops the preloader's background processing task
func stop() {
Self.logger.info("Stopping EntityPreloader")
processingTask?.cancel()
processingTask = nil
}
/// Preloads metadata for the author and referenced profiles in a note
///
/// - Parameter noteLender: The note to extract profiles from
nonisolated func preload(note noteLender: NdbNoteLender) {
Task {
do {
let pubkeys = try noteLender.borrow { event in
if event.known_kind == .metadata { return Set<Pubkey>() } // Don't preload pubkeys from a user profile
var pubkeys = Set<Pubkey>()
// Add the author
pubkeys.insert(event.pubkey)
// Add all referenced pubkeys from p tags
for referencedPubkey in event.referenced_pubkeys {
pubkeys.insert(referencedPubkey)
}
return pubkeys
}
guard !pubkeys.isEmpty else { return }
// Filter out pubkeys that already have profiles in ndb
let pubkeysToPreload = await pubkeys.asyncFilter { pubkey in
let hasProfile = (try? await ndb.lookup_profile(pubkey, borrow: { pr in
pr != nil
})) ?? false
return !hasProfile
}
guard !pubkeysToPreload.isEmpty else {
Self.logger.debug("All \(pubkeys.count, privacy: .public) profiles already in ndb, skipping preload")
return
}
Self.logger.debug("Queueing preload for \(pubkeysToPreload.count, privacy: .public) profiles (\(pubkeys.count - pubkeysToPreload.count, privacy: .public) already cached)")
await queue.add(item: pubkeysToPreload)
} catch {
Self.logger.error("Error extracting pubkeys from note: \(error.localizedDescription, privacy: .public)")
}
}
}
/// Processes the queue continuously, batching requests intelligently
private func monitorQueue() async {
await withThrowingTaskGroup { group in
group.addTask {
for await newPubkeys in await self.queue.stream {
try Task.checkCancellation()
await self.handle(newQueueItem: newPubkeys)
}
}
group.addTask {
while !Task.isCancelled {
try await Task.sleep(for: Self.timeThreshold)
await self.handleTimerTick()
}
}
}
}
private func handleTimerTick() async {
if accumulatedPubkeys.count > 0 {
await self.performPreload()
}
}
private func handle(newQueueItem: Set<Pubkey>) async {
accumulatedPubkeys = self.accumulatedPubkeys.union(newQueueItem)
if accumulatedPubkeys.count > Self.batchSizeThreshold {
await self.performPreload()
}
}
private func performPreload() async {
let pubkeysToPreload = accumulatedPubkeys
accumulatedPubkeys.removeAll()
Self.logger.debug("Preloading \(pubkeysToPreload.count, privacy: .public) profiles")
await self.performPreload(pubkeys: pubkeysToPreload)
}
/// Performs the actual preload operation using standard Nostr subscriptions.
///
/// - Parameter pubkeys: The set of pubkeys to preload metadata for
private func performPreload(pubkeys: Set<Pubkey>) async {
guard !pubkeys.isEmpty else { return }
print("EntityPreloader.performPreload: Starting preload for \(pubkeys.count) pubkeys")
let filter = NostrFilter(
kinds: [.metadata],
authors: Array(pubkeys)
)
for try await _ in await pool.subscribeExistingItems(
filters: [filter],
to: nil,
eoseTimeout: .seconds(10),
) {
// NO-OP: We are only subscribing to let nostrdb ingest those events, but we do not need special handling here.
guard !Task.isCancelled else { break }
}
Self.logger.debug("Completed metadata fetch for \(pubkeys.count, privacy: .public) profiles")
}
}
}
// MARK: - Private Extensions
private extension Set {
/// Asynchronously filters the set based on an async predicate
///
/// - Parameter predicate: An async closure that returns true for elements to include
/// - Returns: A new set containing only elements for which predicate returns true
func asyncFilter(_ predicate: (Element) async -> Bool) async -> Set<Element> {
var result = Set<Element>()
for element in self {
if await predicate(element) {
result.insert(element)
}
}
return result
}
}
@@ -24,7 +24,7 @@ class NostrNetworkManager {
/// ## Implementation notes
///
/// - This will be marked `private` in the future to prevent other code from accessing the relay pool directly. Code outside this layer should use a higher level interface
let pool: RelayPool // TODO: Make this private and make higher level interface for classes outside the NostrNetworkManager
private let pool: RelayPool // TODO: Make this private and make higher level interface for classes outside the NostrNetworkManager
/// A delegate that allows us to interact with the rest of app without introducing hard or circular dependencies
private var delegate: Delegate
/// Manages the user's relay list, controls RelayPool's connected relays
@@ -33,34 +33,260 @@ class NostrNetworkManager {
let postbox: PostBox
/// Handles subscriptions and functions to read or consume data from the Nostr network
let reader: SubscriptionManager
let profilesManager: ProfilesManager
init(delegate: Delegate) {
/// Tracks whether the network manager has completed its initial connection
private var isConnected = false
/// A list of continuations waiting for connection to complete
///
/// We use a unique ID for each connection request so that multiple concurrent calls to `awaitConnection()`
/// can be properly tracked and resumed. This follows the pattern established in `RelayConnection` and `WalletModel`.
private var connectionContinuations: [UUID: CheckedContinuation<Void, Never>] = [:]
/// A lock to ensure thread-safe access to the continuations dictionary and connection state
private let continuationsLock = NSLock()
init(delegate: Delegate, addNdbToRelayPool: Bool = true) {
self.delegate = delegate
let pool = RelayPool(ndb: delegate.ndb, keypair: delegate.keypair)
let pool = RelayPool(ndb: addNdbToRelayPool ? delegate.ndb : nil, keypair: delegate.keypair)
self.pool = pool
let reader = SubscriptionManager(pool: pool, ndb: delegate.ndb)
let reader = SubscriptionManager(pool: pool, ndb: delegate.ndb, experimentalLocalRelayModelSupport: self.delegate.experimentalLocalRelayModelSupport)
let userRelayList = UserRelayListManager(delegate: delegate, pool: pool, reader: reader)
self.reader = reader
self.userRelayList = userRelayList
self.postbox = PostBox(pool: pool)
self.profilesManager = ProfilesManager(subscriptionManager: reader, ndb: delegate.ndb)
}
// MARK: - Control functions
// MARK: - Control and lifecycle functions
/// Connects the app to the Nostr network
func connect() {
self.userRelayList.connect()
func connect() async {
await self.userRelayList.connect() // Will load the user's list, apply it, and get RelayPool to connect to it.
await self.profilesManager.load()
await self.reader.startPreloader()
continuationsLock.lock()
isConnected = true
continuationsLock.unlock()
resumeAllConnectionContinuations()
}
/// Waits for the app to be connected to the network by checking for the next `connect()` call to complete
///
/// This method allows code to await the app to load the relay list and connect to it.
/// It uses Swift continuations to handle completion notifications from potentially different threads.
///
/// - Parameter timeout: Optional timeout duration (defaults to 30 seconds)
///
/// ## Usage
/// ```swift
/// await nostrNetworkManager.awaitConnection()
/// // Code here runs after connection is established
/// ```
///
/// ## Implementation notes
///
/// - Thread-safe: Can be called from any thread and will handle synchronization properly
/// - Multiple callers: Supports multiple concurrent calls, each tracked by a unique ID
/// - Timeout handling: Automatically resumes after timeout even if connection fails
/// - Short-circuits immediately if already connected, preventing unnecessary waiting
func awaitConnection(timeout: Duration = .seconds(30)) async {
// Short-circuit if already connected
continuationsLock.lock()
let alreadyConnected = isConnected
continuationsLock.unlock()
guard !alreadyConnected else {
return
}
let requestId = UUID()
var timeoutTask: Task<Void, Never>?
var isResumed = false
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
// Store the continuation in a thread-safe manner
continuationsLock.lock()
connectionContinuations[requestId] = continuation
continuationsLock.unlock()
// Set up timeout
timeoutTask = Task {
try? await Task.sleep(for: timeout)
if !isResumed {
self.resumeConnectionContinuation(requestId: requestId, isResumed: &isResumed)
}
}
}
timeoutTask?.cancel()
}
/// Resumes a connection continuation in a thread-safe manner
///
/// This can be called from any thread and ensures the continuation is only resumed once
///
/// - Parameters:
/// - requestId: The unique identifier for this connection request
/// - isResumed: Flag to track if the continuation has already been resumed
private func resumeConnectionContinuation(requestId: UUID, isResumed: inout Bool) {
continuationsLock.lock()
defer { continuationsLock.unlock() }
guard !isResumed, let continuation = connectionContinuations[requestId] else {
return
}
isResumed = true
connectionContinuations.removeValue(forKey: requestId)
continuation.resume()
}
/// Resumes all pending connection continuations in a thread-safe manner
///
/// This is useful for notifying all waiting callers when the connection is established
/// or when you need to unblock all pending connection requests.
///
/// This can be called from any thread and ensures all continuations are resumed safely.
private func resumeAllConnectionContinuations() {
continuationsLock.lock()
defer { continuationsLock.unlock() }
// Resume all pending continuations
for (_, continuation) in connectionContinuations {
continuation.resume()
}
// Clear the dictionary
connectionContinuations.removeAll()
}
func disconnectRelays() async {
await self.pool.disconnect()
continuationsLock.lock()
isConnected = false
continuationsLock.unlock()
}
func handleAppBackgroundRequest() async {
await self.reader.cancelAllTasks()
await self.reader.stopPreloader()
await self.pool.cleanQueuedRequestForSessionEnd()
}
func handleAppForegroundRequest() async {
// Pinging the network will automatically reconnect any dead websocket connections
await self.ping()
await self.reader.startPreloader()
}
func close() async {
await withTaskGroup { group in
// Spawn each cancellation task in parallel for faster execution speed
group.addTask {
await self.reader.cancelAllTasks()
}
group.addTask {
await self.profilesManager.stop()
}
group.addTask {
await self.reader.stopPreloader()
}
// But await on each one to prevent race conditions
for await value in group { continue }
await pool.close()
}
continuationsLock.lock()
isConnected = false
continuationsLock.unlock()
}
func ping() async {
await self.pool.ping()
}
func relaysForEvent(event: NostrEvent) -> [RelayURL] {
@MainActor
func relaysForEvent(event: NostrEvent) async -> [RelayURL] {
// TODO(tyiu) Ideally this list would be sorted by the event author's outbox relay preferences
// and reliability of relays to maximize chances of others finding this event.
if let relays = pool.seen[event.id] {
if let relays = await pool.seen[event.id] {
return Array(relays)
}
return []
}
// TODO: ORGANIZE THESE
// MARK: - Communication with the Nostr Network
/// ## Implementation notes
///
/// - This class hides the relay pool on purpose to avoid other code from dealing with complex relay + nostrDB logic.
/// - Instead, we provide an easy to use interface so that normal code can just get the info they want.
/// - This is also to help us migrate to the relay model.
// TODO: Define a better interface. This is a temporary scaffold to replace direct relay pool access. After that is done, we can refactor this interface to be cleaner and reduce non-sense.
func sendToNostrDB(event: NostrEvent) async {
await self.pool.send_raw_to_local_ndb(.typical(.event(event)))
}
func send(event: NostrEvent, to targetRelays: [RelayURL]? = nil, skipEphemeralRelays: Bool = true) async {
await self.pool.send(.event(event), to: targetRelays, skip_ephemeral: skipEphemeralRelays)
}
@MainActor
func getRelay(_ id: RelayURL) -> RelayPool.Relay? {
pool.get_relay(id)
}
@MainActor
var connectedRelays: [RelayPool.Relay] {
self.pool.relays
}
@MainActor
var ourRelayDescriptors: [RelayPool.RelayDescriptor] {
self.pool.our_descriptors
}
@MainActor
func relayURLsThatSawNote(id: NoteId) async -> Set<RelayURL>? {
return await self.pool.seen[id]
}
@MainActor
func determineToRelays(filters: RelayFilters) -> [RelayURL] {
return self.pool.our_descriptors
.map { $0.url }
.filter { !filters.is_filtered(timeline: .search, relay_id: $0) }
}
// MARK: NWC
// TODO: Move this to NWCManager
@discardableResult
func nwcPay(url: WalletConnectURL, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil, zap_request: NostrEvent? = nil) async -> NostrEvent? {
await WalletConnect.pay(url: url, pool: self.pool, post: post, invoice: invoice, zap_request: nil)
}
/// Send a donation zap to the Damus team
func send_donation_zap(nwc: WalletConnectURL, percent: Int, base_msats: Int64) async {
let percent_f = Double(percent) / 100.0
let donations_msats = Int64(percent_f * Double(base_msats))
let payreq = LNUrlPayRequest(allowsNostr: true, commentAllowed: nil, nostrPubkey: "", callback: "https://sendsats.lol/@damus")
guard let invoice = await fetch_zap_invoice(payreq, zapreq: nil, msats: donations_msats, zap_type: .non_zap, comment: nil) else {
// we failed... oh well. no donation for us.
print("damus-donation failed to fetch invoice")
return
}
print("damus-donation donating...")
await WalletConnect.pay(url: nwc, pool: self.pool, post: self.postbox, invoice: invoice, zap_request: nil, delay: nil)
}
}
@@ -85,6 +311,7 @@ extension NostrNetworkManager {
/// The latest contact list `NostrEvent`
///
/// Note: Read-only access, because `NostrNetworkManager` does not manage contact lists.
@MainActor
var latestContactListEvent: NostrEvent? { get }
/// Default bootstrap relays to start with when a user relay list is not present
@@ -93,6 +320,9 @@ extension NostrNetworkManager {
/// Whether the app is in developer mode
var developerMode: Bool { get }
/// Whether the app has the experimental local relay model flag that streams data only from the local relay (ndb)
var experimentalLocalRelayModelSupport: Bool { get }
/// The cache of relay model information
var relayModelCache: RelayModelCache { get }
@@ -0,0 +1,225 @@
//
// ProfilesManager.swift
// damus
//
// Created by Daniel DAquino on 2025-09-19.
//
import Foundation
extension NostrNetworkManager {
/// Efficiently manages getting profile metadata from the network and NostrDB without too many relay subscriptions
///
/// This is necessary because relays have a limit on how many subscriptions can be sent to relays at one given time.
actor ProfilesManager {
private var profileListenerTask: Task<Void, any Error>? = nil
private var subscriptionSwitcherTask: Task<Void, any Error>? = nil
private var subscriptionNeedsUpdate: Bool = false
private let subscriptionManager: SubscriptionManager
private let ndb: Ndb
private var streams: [Pubkey: [UUID: ProfileStreamInfo]]
// MARK: - Initialization and deinitialization
init(subscriptionManager: SubscriptionManager, ndb: Ndb) {
self.subscriptionManager = subscriptionManager
self.ndb = ndb
self.streams = [:]
}
deinit {
self.subscriptionSwitcherTask?.cancel()
self.profileListenerTask?.cancel()
}
// MARK: - Task management
func load() {
self.restartProfileListenerTask()
self.subscriptionSwitcherTask?.cancel()
self.subscriptionSwitcherTask = Task {
while true {
try await Task.sleep(for: .seconds(1))
try Task.checkCancellation()
if subscriptionNeedsUpdate {
try Task.checkCancellation()
self.restartProfileListenerTask()
subscriptionNeedsUpdate = false
}
}
}
}
func stop() async {
await withTaskGroup { group in
// Spawn each cancellation in parallel for better execution speed
group.addTask {
await self.subscriptionSwitcherTask?.cancel()
try? await self.subscriptionSwitcherTask?.value
}
group.addTask {
await self.profileListenerTask?.cancel()
try? await self.profileListenerTask?.value
}
// But await for all of them to be done before returning to avoid race conditions
for await value in group { continue }
}
}
private func restartProfileListenerTask() {
self.profileListenerTask?.cancel()
self.profileListenerTask = Task {
try await self.listenToProfileChanges()
}
}
// MARK: - Listening and publishing of profile changes
private func listenToProfileChanges() async throws {
let pubkeys = Array(streams.keys)
guard pubkeys.count > 0 else { return }
let profileFilter = NostrFilter(kinds: [.metadata], authors: pubkeys)
try Task.checkCancellation()
for await ndbLender in self.subscriptionManager.streamIndefinitely(filters: [profileFilter], streamMode: .ndbFirst(networkOptimization: nil)) {
try Task.checkCancellation()
try? ndbLender.borrow { ev in
publishProfileUpdates(metadataEvent: ev)
}
try Task.checkCancellation()
}
}
private func publishProfileUpdates(metadataEvent: borrowing UnownedNdbNote) {
let now = UInt64(Date.now.timeIntervalSince1970)
try? ndb.write_profile_last_fetched(pubkey: metadataEvent.pubkey, fetched_at: now)
if let relevantStreams = streams[metadataEvent.pubkey] {
// If we have the user metadata event in ndb, then we should have the profile record as well.
guard let profile = try? ndb.lookup_profile_and_copy(metadataEvent.pubkey) else { return }
for relevantStream in relevantStreams.values {
relevantStream.continuation.yield(profile)
}
}
}
/// Manually trigger profile updates for a given pubkey
/// This is useful for local profile changes (e.g., nip05 validation, donation percentage updates)
func notifyProfileUpdate(pubkey: Pubkey) {
if let relevantStreams = streams[pubkey] {
guard let profile = try? ndb.lookup_profile_and_copy(pubkey) else { return }
for relevantStream in relevantStreams.values {
relevantStream.continuation.yield(profile)
}
}
}
// MARK: - Streaming interface
/// Streams profile updates for a single pubkey.
///
/// By default, the stream immediately yields the existing profile from NostrDB
/// (if available), then continues yielding updates as they arrive from the network.
///
/// This immediate yield is essential for views that display profile data (names,
/// pictures) because the subscription restart has a ~1 second delay. Without it,
/// views would flash abbreviated pubkeys or robohash placeholders.
///
/// Set `yieldCached: false` for subscribers that only need network updates (e.g.,
/// re-rendering content when profiles change) and already handle initial state
/// through other means.
///
/// - Parameters:
/// - pubkey: The pubkey to stream profile updates for
/// - yieldCached: Whether to immediately yield the cached profile. Defaults to `true`.
/// - Returns: An AsyncStream that yields Profile objects
func streamProfile(pubkey: Pubkey, yieldCached: Bool = true) -> AsyncStream<ProfileStreamItem> {
return AsyncStream<ProfileStreamItem> { continuation in
let stream = ProfileStreamInfo(continuation: continuation)
self.add(pubkey: pubkey, stream: stream)
// Yield cached profile immediately so views don't flash placeholder content.
// Callers that only need updates (not initial state) can opt out via yieldCached: false.
if yieldCached, let existingProfile = try? ndb.lookup_profile_and_copy(pubkey) {
continuation.yield(existingProfile)
}
continuation.onTermination = { @Sendable _ in
Task { await self.removeStream(pubkey: pubkey, id: stream.id) }
}
}
}
/// Streams profile updates for multiple pubkeys.
///
/// Same behavior as `streamProfile(_:yieldCached:)` but for a set of pubkeys.
///
/// - Parameters:
/// - pubkeys: The set of pubkeys to stream profile updates for
/// - yieldCached: Whether to immediately yield cached profiles. Defaults to `true`.
/// - Returns: An AsyncStream that yields Profile objects
func streamProfiles(pubkeys: Set<Pubkey>, yieldCached: Bool = true) -> AsyncStream<ProfileStreamItem> {
guard !pubkeys.isEmpty else {
return AsyncStream<ProfileStreamItem> { continuation in
continuation.finish()
}
}
return AsyncStream<ProfileStreamItem> { continuation in
let stream = ProfileStreamInfo(continuation: continuation)
for pubkey in pubkeys {
self.add(pubkey: pubkey, stream: stream)
}
// Yield cached profiles immediately so views render correctly from the start.
// Callers that only need updates (not initial state) can opt out via yieldCached: false.
if yieldCached {
for pubkey in pubkeys {
if let existingProfile = try? ndb.lookup_profile_and_copy(pubkey) {
continuation.yield(existingProfile)
}
}
}
continuation.onTermination = { @Sendable _ in
Task {
for pubkey in pubkeys {
await self.removeStream(pubkey: pubkey, id: stream.id)
}
}
}
}
}
// MARK: - Stream management
private func add(pubkey: Pubkey, stream: ProfileStreamInfo) {
if self.streams[pubkey] == nil {
self.streams[pubkey] = [:]
self.subscriptionNeedsUpdate = true
}
self.streams[pubkey]?[stream.id] = stream
}
func removeStream(pubkey: Pubkey, id: UUID) {
self.streams[pubkey]?[id] = nil
if self.streams[pubkey]?.keys.count == 0 {
// We don't need to subscribe to this profile anymore
self.streams[pubkey] = nil
self.subscriptionNeedsUpdate = true
}
}
// MARK: - Helper types
typealias ProfileStreamItem = Profile
struct ProfileStreamInfo {
let id: UUID = UUID()
let continuation: AsyncStream<ProfileStreamItem>.Continuation
}
}
}
@@ -4,6 +4,10 @@
//
// Created by Daniel DAquino on 2025-03-25.
//
import Foundation
import os
import Negentropy
extension NostrNetworkManager {
/// Reads or fetches information from RelayPool and NostrDB, and provides an easier and unified higher-level interface.
@@ -14,48 +18,647 @@ extension NostrNetworkManager {
class SubscriptionManager {
private let pool: RelayPool
private var ndb: Ndb
private var taskManager: TaskManager
private let experimentalLocalRelayModelSupport: Bool
private let entityPreloader: EntityPreloader
init(pool: RelayPool, ndb: Ndb) {
private static let logger = Logger(
subsystem: Constants.MAIN_APP_BUNDLE_IDENTIFIER,
category: "subscription_manager"
)
init(pool: RelayPool, ndb: Ndb, experimentalLocalRelayModelSupport: Bool) {
self.pool = pool
self.ndb = ndb
self.taskManager = TaskManager()
self.experimentalLocalRelayModelSupport = experimentalLocalRelayModelSupport
self.entityPreloader = EntityPreloader(pool: pool, ndb: ndb)
}
// MARK: - Reading data from Nostr
// MARK: - Subscribing and Streaming data from Nostr
/// Subscribes to data from the user's relays
///
/// ## Implementation notes
///
/// - When we migrate to the local relay model, we should modify this function to stream directly from NostrDB
///
/// - Parameter filters: The nostr filters to specify what kind of data to subscribe to
/// - Returns: An async stream of nostr data
func subscribe(filters: [NostrFilter]) -> AsyncStream<StreamItem> {
return AsyncStream<StreamItem> { continuation in
let streamTask = Task {
for await item in self.pool.subscribe(filters: filters) {
/// Streams notes until the EOSE signal
func streamExistingEvents(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, timeout: Duration? = nil, streamMode: StreamMode? = nil, preloadStrategy: PreloadStrategy? = nil, id: UUID? = nil) -> AsyncStream<NdbNoteLender> {
let timeout = timeout ?? .seconds(10)
return AsyncStream<NdbNoteLender> { continuation in
let streamingTask = Task {
outerLoop: for await item in self.advancedStream(filters: filters, to: desiredRelays, timeout: timeout, streamMode: streamMode, preloadStrategy: preloadStrategy, id: id) {
try Task.checkCancellation()
switch item {
case .eose: continuation.yield(.eose)
case .event(let nostrEvent):
// At this point of the pipeline, if the note is valid it should have been processed and verified by NostrDB,
// in which case we should pull the note from NostrDB to ensure validity.
// However, NdbNotes are unowned, so we return a function where our callers can temporarily borrow the NostrDB note
let noteId = nostrEvent.id
let lender: NdbNoteLender = { lend in
guard let ndbNoteTxn = self.ndb.lookup_note(noteId) else {
throw NdbNoteLenderError.errorLoadingNote
}
guard let unownedNote = UnownedNdbNote(ndbNoteTxn) else {
throw NdbNoteLenderError.errorLoadingNote
}
lend(unownedNote)
}
continuation.yield(.event(borrow: lender))
case .event(let lender):
continuation.yield(lender)
case .eose:
break outerLoop
case .ndbEose:
continue
case .networkEose:
continue
}
}
continuation.finish()
}
continuation.onTermination = { @Sendable _ in
streamingTask.cancel()
}
}
}
/// Subscribes to data from user's relays, for a maximum period of time after which the stream will end.
///
/// This is useful when waiting for some specific data from Nostr, but not indefinitely.
func timedStream(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, timeout: Duration, streamMode: StreamMode? = nil, preloadStrategy: PreloadStrategy? = nil, id: UUID? = nil) -> AsyncStream<NdbNoteLender> {
return AsyncStream<NdbNoteLender> { continuation in
let streamingTask = Task {
for await item in self.advancedStream(filters: filters, to: desiredRelays, timeout: timeout, streamMode: streamMode, preloadStrategy: preloadStrategy, id: id) {
try Task.checkCancellation()
switch item {
case .event(lender: let lender):
continuation.yield(lender)
case .eose: break
case .ndbEose: break
case .networkEose: break
}
}
continuation.finish()
}
continuation.onTermination = { @Sendable _ in
streamingTask.cancel()
}
}
}
/// Subscribes to notes indefinitely
///
/// This is useful when simply streaming all events indefinitely
func streamIndefinitely(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, streamMode: StreamMode? = nil, preloadStrategy: PreloadStrategy? = nil, id: UUID? = nil) -> AsyncStream<NdbNoteLender> {
return AsyncStream<NdbNoteLender> { continuation in
let streamingTask = Task {
for await item in self.advancedStream(filters: filters, to: desiredRelays, streamMode: streamMode, preloadStrategy: preloadStrategy, id: id) {
try Task.checkCancellation()
switch item {
case .event(lender: let lender):
continuation.yield(lender)
case .eose:
break
case .ndbEose:
break
case .networkEose:
break
}
}
}
continuation.onTermination = { @Sendable _ in
streamTask.cancel() // Close the RelayPool stream when caller stops streaming
streamingTask.cancel()
}
}
}
func advancedStream(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, timeout: Duration? = nil, streamMode: StreamMode? = nil, preloadStrategy: PreloadStrategy? = nil, id: UUID? = nil) -> AsyncStream<StreamItem> {
let id = id ?? UUID()
let streamMode = streamMode ?? defaultStreamMode()
let preloadStrategy = preloadStrategy ?? self.defaultPreloadingMode()
return AsyncStream<StreamItem> { continuation in
let timeoutTask = Task {
guard let timeout else { return }
try? await Task.sleep(for: timeout)
Self.logger.debug("Subscription \(id.uuidString, privacy: .public): Timed out!")
continuation.finish()
}
let startTime = CFAbsoluteTimeGetCurrent()
Self.logger.debug("Session subscription \(id.uuidString, privacy: .public): Started")
var ndbEOSEIssued = false
var networkEOSEIssued = false
// This closure function issues (yields) an EOSE signal to the stream if all relevant conditions are met
let yieldEOSEIfReady = {
let connectedToNetwork = self.pool.network_monitor.currentPath.status == .satisfied
// In normal mode: Issuing EOSE requires EOSE from both NDB and the network, since they are all considered separate relays
// In experimental local relay model mode: Issuing EOSE requires only EOSE from NDB, since that is the only relay that "matters"
let canIssueEOSE = switch streamMode {
case .ndbFirst, .ndbOnly: (ndbEOSEIssued)
case .ndbAndNetworkParallel: (ndbEOSEIssued && (networkEOSEIssued || !connectedToNetwork))
}
if canIssueEOSE {
Self.logger.debug("Session subscription \(id.uuidString, privacy: .public): Issued EOSE for session. Elapsed: \(CFAbsoluteTimeGetCurrent() - startTime, format: .fixed(precision: 2), privacy: .public) seconds")
logStreamPipelineStats("SubscriptionManager_Advanced_Stream_\(id)", "Consumer_\(id)")
continuation.yield(.eose)
}
}
var networkStreamTask: Task<Void, any Error>? = nil
var latestNoteTimestampSeen: UInt32? = nil
var negentropyStorageVector = NegentropyStorageVector()
let startNetworkStreamTask = {
guard streamMode.shouldStreamFromNetwork else { return }
networkStreamTask = Task {
while !Task.isCancelled {
let networkOptimizationData = StreamMode.NetworkOptimizationData.from(
strategy: streamMode.networkOptimizationStrategy,
latestNoteTimestampSeen: latestNoteTimestampSeen,
negentropyStorageVector: negentropyStorageVector
)
for await item in self.multiSessionNetworkStream(filters: filters, to: desiredRelays, streamMode: streamMode, id: id, networkOptimizationData: networkOptimizationData) {
try Task.checkCancellation()
logStreamPipelineStats("SubscriptionManager_Network_Stream_\(id)", "SubscriptionManager_Advanced_Stream_\(id)")
switch item {
case .event(let lender):
logStreamPipelineStats("SubscriptionManager_Advanced_Stream_\(id)", "Consumer_\(id)")
// Preload entities if requested
if case .preload = preloadStrategy {
self.entityPreloader.preload(note: lender)
}
continuation.yield(item)
case .eose:
break // Should not happen
case .ndbEose:
break // Should not happen
case .networkEose:
logStreamPipelineStats("SubscriptionManager_Advanced_Stream_\(id)", "Consumer_\(id)")
continuation.yield(item)
networkEOSEIssued = true
yieldEOSEIfReady()
}
}
}
}
}
if streamMode.optimizeNetworkFilter == false && streamMode.shouldStreamFromNetwork {
// Start streaming from the network straight away
startNetworkStreamTask()
}
let ndbStreamTask = Task {
while !Task.isCancelled {
for await item in self.multiSessionNdbStream(filters: filters, to: desiredRelays, streamMode: streamMode, id: id) {
try Task.checkCancellation()
logStreamPipelineStats("SubscriptionManager_Ndb_MultiSession_Stream_\(id)", "SubscriptionManager_Advanced_Stream_\(id)")
switch item {
case .event(let lender):
logStreamPipelineStats("SubscriptionManager_Advanced_Stream_\(id)", "Consumer_\(id)")
try? lender.borrow({ event in
if let latestTimestamp = latestNoteTimestampSeen {
latestNoteTimestampSeen = max(latestTimestamp, event.createdAt)
}
else {
latestNoteTimestampSeen = event.createdAt
}
negentropyStorageVector.unsealAndInsert(nostrEvent: event)
})
// Preload entities if requested
if case .preload = preloadStrategy {
self.entityPreloader.preload(note: lender)
}
continuation.yield(item)
case .eose:
break // Should not happen
case .ndbEose:
logStreamPipelineStats("SubscriptionManager_Advanced_Stream_\(id)", "Consumer_\(id)")
continuation.yield(item)
ndbEOSEIssued = true
if streamMode.optimizeNetworkFilter && streamMode.shouldStreamFromNetwork {
startNetworkStreamTask()
}
yieldEOSEIfReady()
case .networkEose:
break // Should not happen
}
}
}
}
continuation.onTermination = { @Sendable _ in
timeoutTask.cancel()
networkStreamTask?.cancel()
ndbStreamTask.cancel()
}
}
}
private func multiSessionNetworkStream(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, streamMode: StreamMode? = nil, id: UUID? = nil, networkOptimizationData: StreamMode.NetworkOptimizationData?) -> AsyncStream<StreamItem> {
let id = id ?? UUID()
let streamMode = streamMode ?? defaultStreamMode()
return AsyncStream<StreamItem> { continuation in
let startTime = CFAbsoluteTimeGetCurrent()
Self.logger.debug("Network subscription \(id.uuidString, privacy: .public): Started")
let streamTask = Task {
while await !self.pool.open {
Self.logger.info("\(id.uuidString, privacy: .public): RelayPool closed. Sleeping for 1 second before resuming.")
try await Task.sleep(nanoseconds: 1_000_000_000)
continue
}
do {
for await item in await self.sessionNetworkStreamWithOptimization(filters: filters, to: desiredRelays, id: id, networkOptimizationData: networkOptimizationData) {
try Task.checkCancellation()
logStreamPipelineStats("RelayPool_Handler_\(id)", "SubscriptionManager_Network_Stream_\(id)")
switch item {
case .event(let event):
switch streamMode {
case .ndbFirst, .ndbOnly:
break // NO-OP
case .ndbAndNetworkParallel:
continuation.yield(.event(lender: NdbNoteLender(ownedNdbNote: event)))
}
case .eose:
Self.logger.debug("Session subscription \(id.uuidString, privacy: .public): Received EOSE from the network. Elapsed: \(CFAbsoluteTimeGetCurrent() - startTime, format: .fixed(precision: 2), privacy: .public) seconds")
continuation.yield(.networkEose)
}
}
}
catch {
Self.logger.error("Network subscription \(id.uuidString, privacy: .public): Streaming error: \(error.localizedDescription, privacy: .public)")
}
Self.logger.debug("Network subscription \(id.uuidString, privacy: .public): Network streaming ended")
continuation.finish()
}
continuation.onTermination = { @Sendable _ in
streamTask.cancel()
}
}
}
/// Stream from the network with some optional optimization
private func sessionNetworkStreamWithOptimization(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, id: UUID? = nil, networkOptimizationData: StreamMode.NetworkOptimizationData?) async -> AsyncStream<RelayPool.StreamItem> {
guard let networkOptimizationData else {
// No optimization, just return a regular RelayPool subscription
return await self.pool.subscribe(filters: filters, to: desiredRelays, id: id)
}
switch networkOptimizationData {
case .sinceOptimization(let latestNoteTimestampSeen):
let optimizedFilters = filters.map {
var optimizedFilter = $0
// Shift the since filter 2 minutes (120 seconds) before the last note timestamp
optimizedFilter.since = latestNoteTimestampSeen > 120 ? latestNoteTimestampSeen - 120 : 0
return optimizedFilter
}
return await self.pool.subscribe(filters: optimizedFilters, to: desiredRelays, id: id)
case .negentropy(let negentropyStorageVector):
return AsyncStream<RelayPool.StreamItem>.with(task: { continuation in
let id = id ?? UUID()
do {
for try await item in try await self.pool.negentropySubscribe(filters: filters, to: desiredRelays, negentropyVector: negentropyStorageVector, id: id, ignoreUnsupportedRelays: true) {
continuation.yield(item)
}
}
catch {
Self.logger.error("Network subscription \(id.uuidString, privacy: .public): Streaming error: \(error.localizedDescription, privacy: .public)")
}
})
}
}
private func multiSessionNdbStream(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, streamMode: StreamMode? = nil, id: UUID? = nil) -> AsyncStream<StreamItem> {
return AsyncStream<StreamItem> { continuation in
let subscriptionId = id ?? UUID()
let startTime = CFAbsoluteTimeGetCurrent()
Self.logger.info("Starting multi-session NDB subscription \(subscriptionId.uuidString, privacy: .public): \(filters.debugDescription, privacy: .private)")
let multiSessionStreamingTask = Task {
while !Task.isCancelled {
do {
guard !self.ndb.is_closed else {
Self.logger.info("\(subscriptionId.uuidString, privacy: .public): Ndb closed. Sleeping for 1 second before resuming.")
try await Task.sleep(nanoseconds: 1_000_000_000)
continue
}
Self.logger.info("\(subscriptionId.uuidString, privacy: .public): Streaming from NDB.")
for await item in self.sessionNdbStream(filters: filters, to: desiredRelays, streamMode: streamMode, id: id) {
try Task.checkCancellation()
logStreamPipelineStats("SubscriptionManager_Ndb_Session_Stream_\(id?.uuidString ?? "NoID")", "SubscriptionManager_Ndb_MultiSession_Stream_\(id?.uuidString ?? "NoID")")
continuation.yield(item)
}
Self.logger.info("\(subscriptionId.uuidString, privacy: .public): Session subscription ended. Sleeping for 1 second before resuming.")
try await Task.sleep(nanoseconds: 1_000_000_000)
}
catch {
Self.logger.error("Session subscription \(subscriptionId.uuidString, privacy: .public): Error: \(error.localizedDescription, privacy: .public)")
}
}
Self.logger.info("\(subscriptionId.uuidString, privacy: .public): Terminated.")
}
continuation.onTermination = { @Sendable _ in
Self.logger.info("\(subscriptionId.uuidString, privacy: .public): Cancelled multi-session NDB stream.")
multiSessionStreamingTask.cancel()
}
}
}
private func sessionNdbStream(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, streamMode: StreamMode? = nil, id: UUID? = nil) -> AsyncStream<StreamItem> {
let id = id ?? UUID()
//let streamMode = streamMode ?? defaultStreamMode()
return AsyncStream<StreamItem> { continuation in
let startTime = CFAbsoluteTimeGetCurrent()
Self.logger.debug("Session subscription \(id.uuidString, privacy: .public): Started")
let ndbStreamTask = Task {
do {
for await item in try self.ndb.subscribe(filters: try filters.map({ try NdbFilter(from: $0) })) {
try Task.checkCancellation()
switch item {
case .eose:
Self.logger.debug("Session subscription \(id.uuidString, privacy: .public): Received EOSE from nostrdb. Elapsed: \(CFAbsoluteTimeGetCurrent() - startTime, format: .fixed(precision: 2), privacy: .public) seconds")
continuation.yield(.ndbEose)
case .event(let noteKey):
let lender = NdbNoteLender(ndb: self.ndb, noteKey: noteKey)
try Task.checkCancellation()
guard let desiredRelays else {
continuation.yield(.event(lender: lender)) // If no desired relays are specified, return all notes we see.
break
}
if try ndb.was(noteKey: noteKey, seenOnAnyOf: desiredRelays) {
continuation.yield(.event(lender: lender)) // If desired relays were specified and this note was seen there, return it.
}
}
}
}
catch {
Self.logger.error("Session subscription \(id.uuidString, privacy: .public): NDB streaming error: \(error.localizedDescription, privacy: .public)")
}
Self.logger.debug("Session subscription \(id.uuidString, privacy: .public): NDB streaming ended")
continuation.finish()
}
Task {
// Add the ndb streaming task to the task manager so that it can be cancelled when the app is backgrounded
let ndbStreamTaskId = await self.taskManager.add(task: ndbStreamTask)
continuation.onTermination = { @Sendable _ in
Task {
await self.taskManager.cancelAndCleanUp(taskId: ndbStreamTaskId)
}
}
}
}
}
// MARK: - Utility functions
private func defaultStreamMode() -> StreamMode {
// Note: Network optimizations disabled by default for now because we need more testing to understand the effects of turning them on by default.
self.experimentalLocalRelayModelSupport ? .ndbFirst(networkOptimization: nil) : .ndbAndNetworkParallel(networkOptimization: nil)
}
private func defaultPreloadingMode() -> PreloadStrategy {
return .preload
}
// MARK: - Finding specific data from Nostr
/// Finds a non-replaceable event based on a note ID.
///
/// When relay hints are provided, they get a short exclusive window to respond.
/// If no event is found within that window, the remaining time is used to broadcast
/// to all connected relays. The `timeout` parameter is a total deadline for both phases.
func lookup(noteId: NoteId, to targetRelays: [RelayURL]? = nil, timeout: Duration? = nil) async throws -> NdbNoteLender? {
// Since note ids point to immutable objects, we can do a simple ndb lookup first
if let note = try? self.ndb.lookup_note_and_copy(noteId) {
return NdbNoteLender(ownedNdbNote: note)
}
// Not available in local ndb, stream from network
let filter = NostrFilter(ids: [noteId], limit: 1)
let totalTimeout = timeout ?? .seconds(10)
let startTime = ContinuousClock.now
// If relay hints provided, try them first with a short timeout
if let targetRelays, !targetRelays.isEmpty {
// Acquire ephemeral relays and connect to them
await self.pool.acquireEphemeralRelays(targetRelays)
defer {
Task { await self.pool.releaseEphemeralRelays(targetRelays) }
}
let connectedRelays = await self.pool.ensureConnected(to: targetRelays)
guard !connectedRelays.isEmpty else {
#if DEBUG
Self.logger.info("lookup(noteId): No hint relays connected, skipping to broadcast")
#endif
return await fetchFromRelays(filter: filter, relays: nil, timeout: totalTimeout)
}
// Use min of 3 seconds or half of total timeout for hint phase
let hintTimeout = min(.seconds(3), totalTimeout / 2)
#if DEBUG
Self.logger.info("lookup(noteId): Trying \(connectedRelays.count)/\(targetRelays.count) hint relay(s) with \(hintTimeout) timeout")
#endif
let result = await fetchFromRelays(filter: filter, relays: connectedRelays, timeout: hintTimeout)
if let result {
return result
}
// Calculate remaining time for broadcast phase
let elapsed = ContinuousClock.now - startTime
let remaining = totalTimeout - elapsed
guard remaining > .zero else {
#if DEBUG
Self.logger.info("lookup(noteId): Total timeout exceeded, skipping broadcast")
#endif
return nil
}
// Hint relays didn't respond, fallback to broadcast with remaining time
#if DEBUG
Self.logger.info("lookup(noteId): Hint relays didn't respond, falling back to broadcast (\(remaining) remaining)")
#endif
return await fetchFromRelays(filter: filter, relays: nil, timeout: remaining)
}
// No hints, broadcast to all relays
return await fetchFromRelays(filter: filter, relays: nil, timeout: totalTimeout)
}
/// Fetches the first event matching the filter from the specified relays.
///
/// - Parameters:
/// - filter: The NostrFilter to match events against.
/// - relays: Optional relay URLs to query. If nil, broadcasts to all connected relays.
/// - timeout: Maximum duration to wait for a response.
/// - Returns: An `NdbNoteLender` for the first matching event, or `nil` if EOSE is received
/// or the timeout expires without finding a match.
private func fetchFromRelays(filter: NostrFilter, relays: [RelayURL]?, timeout: Duration) async -> NdbNoteLender? {
for await item in await self.pool.subscribe(filters: [filter], to: relays, eoseTimeout: timeout) {
switch item {
case .event(let event):
return NdbNoteLender(ownedNdbNote: event)
case .eose:
return nil
}
}
return nil
}
func query(filters: [NostrFilter], to: [RelayURL]? = nil, timeout: Duration? = nil) async -> [NostrEvent] {
var events: [NostrEvent] = []
for await noteLender in self.streamExistingEvents(filters: filters, to: to, timeout: timeout) {
noteLender.justUseACopy({ events.append($0) })
}
return events
}
/// Finds a Nostr event that corresponds to the provided naddr identifier.
/// - Parameters:
/// - naddr: The NAddr (network address) that identifies the target replaceable event (contains kind, author, and identifier).
/// - targetRelays: Optional relay URLs to hint where to search; the method may acquire ephemeral relays and will use only the subset of those that become connected.
/// - timeout: Optional duration to bound the search.
/// - Returns: The matching `NostrEvent` whose first referenced parameter equals `naddr.identifier`, or `nil` if no matching event is found.
func lookup(naddr: NAddr, to targetRelays: [RelayURL]? = nil, timeout: Duration? = nil) async -> NostrEvent? {
var connectedTargetRelays = targetRelays
var ephemeralRelays: [RelayURL] = []
if let relays = targetRelays, !relays.isEmpty {
await self.pool.acquireEphemeralRelays(relays)
ephemeralRelays = relays
let connectedRelays = await self.pool.ensureConnected(to: relays)
connectedTargetRelays = connectedRelays.isEmpty ? nil : connectedRelays
#if DEBUG
Self.logger.info("lookup(naddr): Using \(connectedRelays.count)/\(relays.count) relay hints: \(connectedRelays.map { $0.absoluteString }.joined(separator: ", "), privacy: .public)")
#endif
}
defer {
if !ephemeralRelays.isEmpty {
Task { await self.pool.releaseEphemeralRelays(ephemeralRelays) }
}
}
let nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
for await noteLender in self.streamExistingEvents(filters: [filter], to: connectedTargetRelays, timeout: timeout) {
guard let event = noteLender.justGetACopy() else { continue }
if event.referenced_params.first?.param.string() == naddr.identifier {
return event
}
}
return nil
}
/// Searches for a profile or event specified by `query` and returns the first matching result.
/// The function first checks the local NDB cache and, if not found, queries relays (honoring any relay hints in the query).
/// - Parameter query: Specifies what to find (profile by pubkey or event by id) and optional relay hints to use for network lookup.
/// - Returns: A `FoundEvent` containing the matched profile or event, or `nil` if no match is found.
func findEvent(query: FindEvent) async -> FoundEvent? {
var filter: NostrFilter? = nil
let find_from = query.find_from
let query = query.type
switch query {
case .profile(let pubkey):
let profileNotNil = try? self.ndb.lookup_profile(pubkey, borrow: { pr in
switch pr {
case .some(let pr): return pr.profile != nil
case .none: return true
}
})
if profileNotNil ?? false {
return .profile(pubkey)
}
filter = NostrFilter(kinds: [.metadata], limit: 1, authors: [pubkey])
case .event(let evid):
if let event = try? self.ndb.lookup_note_and_copy(evid) {
return .event(event)
}
filter = NostrFilter(ids: [evid], limit: 1)
}
guard let filter else { return nil }
var targetRelays = find_from
var ephemeralRelays: [RelayURL] = []
if let relays = find_from, !relays.isEmpty {
await self.pool.acquireEphemeralRelays(relays)
ephemeralRelays = relays
let connectedRelays = await self.pool.ensureConnected(to: relays)
targetRelays = connectedRelays.isEmpty ? nil : connectedRelays
#if DEBUG
Self.logger.info("findEvent: Using \(connectedRelays.count)/\(relays.count) relay hints: \(connectedRelays.map { $0.absoluteString }.joined(separator: ", "), privacy: .public)")
#endif
}
defer {
if !ephemeralRelays.isEmpty {
Task { await self.pool.releaseEphemeralRelays(ephemeralRelays) }
}
}
for await noteLender in self.streamExistingEvents(filters: [filter], to: targetRelays) {
let foundEvent: FoundEvent? = try? noteLender.borrow({ event in
switch query {
case .profile:
if event.known_kind == .metadata {
return .profile(event.pubkey)
}
case .event:
return .event(event.toOwned())
}
return nil
})
if let foundEvent {
return foundEvent
}
}
return nil
}
// MARK: - Task management
func startPreloader() async {
await self.entityPreloader.start()
}
func stopPreloader() async {
await self.entityPreloader.stop()
}
func cancelAllTasks() async {
await self.taskManager.cancelAllTasks()
}
actor TaskManager {
private var tasks: [UUID: Task<Void, Never>] = [:]
private static let logger = Logger(
subsystem: "com.jb55.damus",
category: "subscription_manager.task_manager"
)
func add(task: Task<Void, Never>) -> UUID {
let taskId = UUID()
self.tasks[taskId] = task
return taskId
}
func cancelAndCleanUp(taskId: UUID) async {
self.tasks[taskId]?.cancel()
await self.tasks[taskId]?.value
self.tasks[taskId] = nil
return
}
func cancelAllTasks() async {
await withTaskGroup { group in
Self.logger.info("Cancelling all SubscriptionManager tasks")
// Start each task cancellation in parallel for faster execution
for (taskId, _) in self.tasks {
Self.logger.info("Cancelling SubscriptionManager task \(taskId.uuidString, privacy: .public)")
group.addTask {
await self.cancelAndCleanUp(taskId: taskId)
}
}
// However, wait until all cancellations are complete to avoid race conditions.
for await value in group {
continue
}
Self.logger.info("Cancelled all SubscriptionManager tasks")
}
}
}
@@ -63,8 +666,98 @@ extension NostrNetworkManager {
enum StreamItem {
/// An event which can be borrowed from NostrDB
case event(borrow: NdbNoteLender)
/// The end of stored events
case event(lender: NdbNoteLender)
/// The canonical generic "end of stored events", which depends on the stream mode. See `StreamMode` to see when this event is fired in relation to other EOSEs
case eose
/// "End of stored events" from NostrDB.
case ndbEose
/// "End of stored events" from all relays in `RelayPool`.
case networkEose
var debugDescription: String {
switch self {
case .event(lender: let lender):
let detailedDescription = try? lender.borrow({ event in
"Note with ID: \(event.id.hex())"
})
return detailedDescription ?? "Some note"
case .eose:
return "EOSE"
case .ndbEose:
return "NDB EOSE"
case .networkEose:
return "NETWORK EOSE"
}
}
}
/// The mode of streaming
enum StreamMode {
/// Returns notes exclusively through NostrDB, treating it as the only channel for information in the pipeline. Generic EOSE is fired when EOSE is received from NostrDB
case ndbFirst(networkOptimization: NetworkOptimizationStrategy?)
/// Returns notes from both NostrDB and the network, in parallel, treating it with similar importance against the network relays. Generic EOSE is fired when EOSE is received from both the network and NostrDB
case ndbAndNetworkParallel(networkOptimization: NetworkOptimizationStrategy?)
/// Ignores the network.
case ndbOnly
var optimizeNetworkFilter: Bool {
return networkOptimizationStrategy != nil
}
var networkOptimizationStrategy: NetworkOptimizationStrategy? {
switch self {
case .ndbFirst(networkOptimization: let networkOptimization):
return networkOptimization
case .ndbAndNetworkParallel(networkOptimization: let networkOptimization):
return networkOptimization
case .ndbOnly:
return nil
}
}
var shouldStreamFromNetwork: Bool {
switch self {
case .ndbFirst:
return true
case .ndbAndNetworkParallel:
return true
case .ndbOnly:
return false
}
}
enum NetworkOptimizationStrategy {
/// Returns notes from ndb, then streams from the network with an added "since" filter set to the latest note stored on ndb.
case sinceOptimization
/// Returns notes from ndb, negentropy syncs missing notes with relays, then streams normally
case negentropy
}
enum NetworkOptimizationData {
/// Returns notes from ndb, then streams from the network with an added "since" filter set to the latest note stored on ndb.
case sinceOptimization(latestNoteTimestampSeen: UInt32)
/// Returns notes from ndb, negentropy syncs missing notes with relays, then streams normally
case negentropy(negentropyStorageVector: NegentropyStorageVector)
static func from(strategy: NetworkOptimizationStrategy?, latestNoteTimestampSeen: UInt32?, negentropyStorageVector: NegentropyStorageVector?) -> Self? {
guard let strategy else { return nil }
switch strategy {
case .sinceOptimization:
guard let latestNoteTimestampSeen else { return nil }
return .sinceOptimization(latestNoteTimestampSeen: latestNoteTimestampSeen)
case .negentropy:
guard let negentropyStorageVector else { return nil }
return .negentropy(negentropyStorageVector: negentropyStorageVector)
}
}
}
}
/// Defines the preloading strategy for a stream
enum PreloadStrategy {
/// No preloading - notes are not sent to EntityPreloader
case noPreloading
/// Preload metadata for authors and referenced profiles
case preload
}
}
@@ -30,6 +30,7 @@ extension NostrNetworkManager {
// MARK: - Computing the relays to connect to
@MainActor
private func relaysToConnectTo() -> [RelayPool.RelayDescriptor] {
return self.computeRelaysToConnectTo(with: self.getBestEffortRelayList())
}
@@ -49,6 +50,7 @@ extension NostrNetworkManager {
/// It attempts to get a relay list from the user. If one is not available, it uses the default bootstrap list.
///
/// This is always guaranteed to return a relay list.
@MainActor
func getBestEffortRelayList() -> NIP65.RelayList {
guard let userCurrentRelayList = self.getUserCurrentRelayList() else {
return NIP65.RelayList(relays: delegate.bootstrapRelays)
@@ -59,6 +61,7 @@ extension NostrNetworkManager {
/// Gets the user's current relay list.
///
/// It attempts to get a NIP-65 relay list from the local database, or falls back to a legacy list.
@MainActor
func getUserCurrentRelayList() -> NIP65.RelayList? {
if let latestRelayListEvent = try? self.getLatestNIP65RelayList() { return latestRelayListEvent }
if let latestRelayListEvent = try? self.getLatestKind3RelayList() { return latestRelayListEvent }
@@ -87,12 +90,13 @@ extension NostrNetworkManager {
private func getLatestNIP65RelayListEvent() -> NdbNote? {
guard let latestRelayListEventId = delegate.latestRelayListEventIdHex else { return nil }
guard let latestRelayListEventId = NoteId(hex: latestRelayListEventId) else { return nil }
return delegate.ndb.lookup_note(latestRelayListEventId)?.unsafeUnownedValue?.to_owned()
return try? delegate.ndb.lookup_note_and_copy(latestRelayListEventId)
}
/// Gets the latest `kind:3` relay list from NostrDB.
///
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
@MainActor
private func getLatestKind3RelayList() throws(LoadingError) -> NIP65.RelayList? {
guard let latestContactListEvent = delegate.latestContactListEvent else { return nil }
guard let legacyContactList = try? NIP65.RelayList.fromLegacyContactList(latestContactListEvent) else { throw .relayListParseError }
@@ -114,6 +118,7 @@ extension NostrNetworkManager {
/// Gets the creation date of the user's current relay list, with preference to NIP-65 relay lists
/// - Returns: The current relay list's creation date
@MainActor
private func getUserCurrentRelayListCreationDate() -> UInt32? {
if let latestNIP65RelayListEvent = self.getLatestNIP65RelayListEvent() { return latestNIP65RelayListEvent.created_at }
if let latestKind3RelayListEvent = delegate.latestContactListEvent { return latestKind3RelayListEvent.created_at }
@@ -122,72 +127,67 @@ extension NostrNetworkManager {
// MARK: - Listening to and handling relay updates from the network
func connect() {
self.load()
func connect() async {
await self.load()
self.relayListObserverTask?.cancel()
self.relayListObserverTask = Task { await self.listenAndHandleRelayUpdates() }
self.walletUpdatesObserverTask?.cancel()
self.walletUpdatesObserverTask = handle_notify(.attached_wallet).sink { _ in self.load() }
self.walletUpdatesObserverTask = handle_notify(.attached_wallet).sink { _ in Task { await self.load() } }
}
func listenAndHandleRelayUpdates() async {
let filter = NostrFilter(kinds: [.relay_list], authors: [delegate.keypair.pubkey])
for await item in self.reader.subscribe(filters: [filter]) {
switch item {
case .event(borrow: let borrow): // Signature validity already ensured at this point
let currentRelayListCreationDate = self.getUserCurrentRelayListCreationDate()
try? borrow { note in
guard note.pubkey == self.delegate.keypair.pubkey else { return } // Ensure this new list was ours
guard note.createdAt > (currentRelayListCreationDate ?? 0) else { return } // Ensure this is a newer list
guard let relayList = try? NIP65.RelayList(event: note) else { return } // Ensure it is a valid NIP-65 list
try? self.set(userRelayList: relayList) // Set the validated list
}
case .eose: continue
}
for await noteLender in self.reader.streamIndefinitely(filters: [filter]) {
let currentRelayListCreationDate = await self.getUserCurrentRelayListCreationDate()
guard let note = noteLender.justGetACopy() else { continue }
guard note.pubkey == self.delegate.keypair.pubkey else { continue } // Ensure this new list was ours
guard note.created_at > (currentRelayListCreationDate ?? 0) else { continue } // Ensure this is a newer list
guard let relayList = try? NIP65.RelayList(event: note) else { continue } // Ensure it is a valid NIP-65 list
try? await self.set(userRelayList: relayList) // Set the validated list
}
}
// MARK: - Editing the user's relay list
func upsert(relay: NIP65.RelayList.RelayItem, force: Bool = false, overwriteExisting: Bool = false) throws(UpdateError) {
guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
func upsert(relay: NIP65.RelayList.RelayItem, force: Bool = false, overwriteExisting: Bool = false) async throws(UpdateError) {
guard let currentUserRelayList = await force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
guard !currentUserRelayList.relays.keys.contains(relay.url) || overwriteExisting else { throw .relayAlreadyExists }
var newList = currentUserRelayList.relays
newList[relay.url] = relay
try self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values)))
try await self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values)))
}
func insert(relay: NIP65.RelayList.RelayItem, force: Bool = false) throws(UpdateError) {
guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
func insert(relay: NIP65.RelayList.RelayItem, force: Bool = false) async throws(UpdateError) {
guard let currentUserRelayList = await force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
guard currentUserRelayList.relays[relay.url] == nil else { throw .relayAlreadyExists }
try self.upsert(relay: relay, force: force)
try await self.upsert(relay: relay, force: force)
}
func remove(relayURL: RelayURL, force: Bool = false) throws(UpdateError) {
guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
func remove(relayURL: RelayURL, force: Bool = false) async throws(UpdateError) {
guard let currentUserRelayList = await force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
guard currentUserRelayList.relays.keys.contains(relayURL) || force else { throw .noSuchRelay }
var newList = currentUserRelayList.relays
newList[relayURL] = nil
try self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values)))
try await self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values)))
}
func set(userRelayList: NIP65.RelayList) throws(UpdateError) {
func set(userRelayList: NIP65.RelayList) async throws(UpdateError) {
guard let fullKeypair = delegate.keypair.to_full() else { throw .notAuthorizedToChangeRelayList }
guard let relayListEvent = userRelayList.toNostrEvent(keypair: fullKeypair) else { throw .cannotFormRelayListEvent }
self.apply(newRelayList: self.computeRelaysToConnectTo(with: userRelayList))
await self.apply(newRelayList: self.computeRelaysToConnectTo(with: userRelayList))
self.pool.send(.event(relayListEvent)) // This will send to NostrDB as well, which will locally save that NIP-65 event
await self.pool.send(.event(relayListEvent)) // This will send to NostrDB as well, which will locally save that NIP-65 event
self.delegate.latestRelayListEventIdHex = relayListEvent.id.hex() // Make sure we are able to recall this event from NostrDB
}
// MARK: - Syncing our saved user relay list with the active `RelayPool`
/// Loads the current user relay list
func load() {
self.apply(newRelayList: self.relaysToConnectTo())
func load() async {
await self.apply(newRelayList: self.relaysToConnectTo())
}
/// Loads a new relay list into the active relay pool, making sure it matches the specified relay list.
@@ -201,7 +201,8 @@ extension NostrNetworkManager {
///
/// - This is `private` because syncing the user's saved relay list with the relay pool is `NostrNetworkManager`'s responsibility,
/// so we do not want other classes to forcibly load this.
private func apply(newRelayList: [RelayPool.RelayDescriptor]) {
@MainActor
private func apply(newRelayList: [RelayPool.RelayDescriptor]) async {
let currentRelayList = self.pool.relays.map({ $0.descriptor })
var changed = false
@@ -221,28 +222,39 @@ extension NostrNetworkManager {
let relaysToRemove = currentRelayURLs.subtracting(newRelayURLs)
let relaysToAdd = newRelayURLs.subtracting(currentRelayURLs)
// Remove relays not in the new list
relaysToRemove.forEach { url in
pool.remove_relay(url)
changed = true
}
await withTaskGroup { taskGroup in
// Remove relays not in the new list
relaysToRemove.forEach { url in
taskGroup.addTask(operation: { await self.pool.remove_relay(url) })
changed = true
}
// Add new relays from the new list
relaysToAdd.forEach { url in
guard let descriptor = newRelayList.first(where: { $0.url == url }) else { return }
add_new_relay(
model_cache: delegate.relayModelCache,
relay_filters: delegate.relayFilters,
pool: pool,
descriptor: descriptor,
new_relay_filters: new_relay_filters,
logging_enabled: delegate.developerMode
)
changed = true
// Add new relays from the new list
relaysToAdd.forEach { url in
guard let descriptor = newRelayList.first(where: { $0.url == url }) else { return }
taskGroup.addTask(operation: {
await add_new_relay(
model_cache: self.delegate.relayModelCache,
relay_filters: self.delegate.relayFilters,
pool: self.pool,
descriptor: descriptor,
new_relay_filters: new_relay_filters,
logging_enabled: self.delegate.developerMode
)
})
changed = true
}
for await value in taskGroup { continue }
}
// Always tell RelayPool to connect whether or not we are already connected.
// This is because:
// 1. Internally it won't redo the connection because of internal checks
// 2. Even if the relay list has not changed, relays may have been disconnected from app lifecycle or other events
await pool.connect()
if changed {
pool.connect()
notify(.relays_changed)
}
}
@@ -280,8 +292,8 @@ fileprivate extension NIP65.RelayList {
/// - descriptor: The description of the relay being added
/// - new_relay_filters: Whether to insert new relay filters
/// - logging_enabled: Whether logging is enabled
fileprivate func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayPool.RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) {
try? pool.add_relay(descriptor)
fileprivate func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayPool.RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) async {
try? await pool.add_relay(descriptor)
let url = descriptor.url
let relay_id = url
@@ -299,7 +311,7 @@ fileprivate func add_new_relay(model_cache: RelayModelCache, relay_filters: Rela
model_cache.insert(model: model)
if logging_enabled {
pool.setLog(model.log, for: relay_id)
Task { await pool.setLog(model.log, for: relay_id) }
}
// if this is the first time adding filters, we should filter non-paid relays
+50 -4
View File
@@ -49,22 +49,22 @@ protocol TagItemConvertible {
struct QuoteId: IdType, TagKey, TagConvertible {
let id: Data
init(_ data: Data) {
self.id = data
}
/// The note id being quoted
var note_id: NoteId {
NoteId(self.id)
}
var keychar: AsciiCharacter { "q" }
var tag: [String] {
["q", self.hex()]
}
static func from_tag(tag: TagSequence) -> QuoteId? {
var i = tag.makeIterator()
@@ -80,6 +80,52 @@ struct QuoteId: IdType, TagKey, TagConvertible {
}
}
/// A quote reference with optional relay hints for fetching.
///
/// Per NIP-10/NIP-18, `q` tags include a relay URL at position 2 where the quoted
/// event can be found.
///
/// Note: The NIPs allow `q` tags to contain either event IDs (hex) or event addresses
/// (`<kind>:<pubkey>:<d>` for replaceable events). This implementation currently only
/// supports hex event IDs; quotes of addressable events are not yet handled.
struct QuoteRef: TagConvertible {
let quote_id: QuoteId
let relayHints: [RelayURL]
/// The note ID being quoted
var note_id: NoteId {
quote_id.note_id
}
var tag: [String] {
var tagBuilder = ["q", quote_id.hex()]
if let relay = relayHints.first {
tagBuilder.append(relay.absoluteString)
}
return tagBuilder
}
/// Parses a `q` tag into a QuoteRef, preserving relay hints from position 2.
///
/// Only parses `q` tags containing hex event IDs. Tags with event addresses
/// (`<kind>:<pubkey>:<d>`) are not currently supported and will return nil.
static func from_tag(tag: TagSequence) -> QuoteRef? {
var i = tag.makeIterator()
guard tag.count >= 2,
let t0 = i.next(),
let key = t0.single_char,
key == "q",
let t1 = i.next(),
let data = t1.id()
else { return nil }
let quoteId = QuoteId(data)
let relayHints = tag.relayHints
return QuoteRef(quote_id: quoteId, relayHints: relayHints)
}
}
struct Privkey: IdType {
let id: Data
+31 -11
View File
@@ -122,6 +122,11 @@ struct MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
}
}
/// Parses a tag sequence into a MentionRef, preserving relay hints.
///
/// Per NIP-01/NIP-10, position 2 in `e`, `p`, and `a` tags contains an optional relay URL.
/// When present, this method creates `nevent`/`nprofile`/`naddr` variants that preserve
/// the relay hint for later use in event fetching.
static func from_tag(tag: TagSequence) -> MentionRef? {
guard tag.count >= 2 else { return nil }
@@ -135,23 +140,35 @@ struct MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
return nil
}
let relayHints = tag.relayHints
switch mention_type {
case .p:
guard let data = element.id() else { return nil }
return .init(nip19: .npub(Pubkey(data)))
let pubkey = Pubkey(data)
if relayHints.isEmpty {
return .init(nip19: .npub(pubkey))
}
return .init(nip19: .nprofile(NProfile(author: pubkey, relays: relayHints)))
case .e:
guard let data = element.id() else { return nil }
return .init(nip19: .note(NoteId(data)))
let noteId = NoteId(data)
if relayHints.isEmpty {
return .init(nip19: .note(noteId))
}
#if DEBUG
print("[relay-hints] e tag: Found \(relayHints.count) hint(s) for \(noteId.hex().prefix(8))...: \(relayHints.map { $0.absoluteString })")
#endif
return .init(nip19: .nevent(NEvent(noteid: noteId, relays: relayHints)))
case .a:
let str = element.string()
let data = str.split(separator: ":")
if(data.count != 3) { return nil }
guard data.count == 3 else { return nil }
guard let pubkey = Pubkey(hex: String(data[1])) else { return nil }
guard let kind = UInt32(data[0]) else { return nil }
return .init(nip19: .naddr(NAddr(identifier: String(data[2]), author: pubkey, relays: [], kind: kind)))
case .r: return .init(nip19: .nrelay(element.string()))
return .init(nip19: .naddr(NAddr(identifier: String(data[2]), author: pubkey, relays: relayHints, kind: kind)))
case .r:
return .init(nip19: .nrelay(element.string()))
}
}
@@ -294,16 +311,19 @@ func format_msats(_ msat: Int64, locale: Locale = Locale.current) -> String {
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats)
}
func convert_invoice_description(b11: ndb_invoice) -> InvoiceDescription? {
/// Extracts the description from a BOLT11 invoice.
/// Returns empty description if invoice has neither description nor description_hash,
/// as both fields are optional per BOLT11 spec.
func convert_invoice_description(b11: ndb_invoice) -> InvoiceDescription {
if let desc = b11.description {
return .description(String(cString: desc))
}
if var deschash = maybe_pointee(b11.description_hash) {
return .description_hash(Data(bytes: &deschash, count: 32))
}
return nil
return .description("")
}
func find_tag_ref(type: String, id: String, tags: [[String]]) -> Int? {
+59 -8
View File
@@ -11,8 +11,8 @@ typealias Profile = NdbProfile
typealias ProfileKey = UInt64
//typealias ProfileRecord = NdbProfileRecord
class ProfileRecord {
let data: NdbProfileRecord
struct ProfileRecord: ~Copyable {
private let data: NdbProfileRecord // Marked as private to make users access the safer `profile` property
init(data: NdbProfileRecord, key: ProfileKey) {
self.data = data
@@ -20,7 +20,11 @@ class ProfileRecord {
}
let profileKey: ProfileKey
var profile: Profile? { return data.profile }
var profile: Profile? {
// Clone the data since `NdbProfile` can be unowned, but does not `~Copyable` semantics.
// This helps ensure the memory safety of this property
return data.profile?.clone()
}
var receivedAt: UInt64 { data.receivedAt }
var noteKey: UInt64 { data.noteKey }
@@ -37,10 +41,7 @@ class ProfileRecord {
}
if addr.contains("@") {
// this is a heavy op and is used a lot in views, cache it!
let addr = lnaddress_to_lnurl(addr);
self._lnurl = addr
return addr
return lnaddress_to_lnurl(addr)
}
if !addr.lowercased().hasPrefix("lnurl") {
@@ -81,6 +82,24 @@ extension NdbProfile {
return URL(string: trim)
}
}
/// Clones this object. Useful for creating an owned copy from an unowned profile
func clone() -> Self {
return NdbProfile(
name: self.name,
display_name: self.display_name,
about: self.about,
picture: self.picture,
banner: self.banner,
website: self.website,
lud06: self.lud06,
lud16: self.lud16,
nip05: self.nip05,
damus_donation: self.damus_donation,
reactions: self.reactions
)
}
init(name: String? = nil, display_name: String? = nil, about: String? = nil, picture: String? = nil, banner: String? = nil, website: String? = nil, lud06: String? = nil, lud16: String? = nil, nip05: String? = nil, damus_donation: Int? = nil, reactions: Bool = true) {
@@ -309,7 +328,40 @@ func make_ln_url(_ str: String?) -> URL? {
return str.flatMap { URL(string: "lightning:" + $0) }
}
import Synchronization
@available(iOS 18.0, *)
class CachedLNAddressConverter {
static let shared: CachedLNAddressConverter = .init()
private let cache: Mutex<[String: String?]> = .init([:]) // Using a mutex here to avoid race conditions without imposing actor isolation requirements.
func lnaddress_to_lnurl(_ lnaddr: String) -> String? {
if let cachedValue = cache.withLock({ $0[lnaddr] }) {
return cachedValue
}
let lnurl: String? = compute_lnaddress_to_lnurl(lnaddr)
cache.withLock({ cache in
cache[lnaddr] = .some(lnurl)
})
return lnurl
}
}
func lnaddress_to_lnurl(_ lnaddr: String) -> String? {
if #available(iOS 18.0, *) {
// This is a heavy op, use a cache if available!
return CachedLNAddressConverter.shared.lnaddress_to_lnurl(lnaddr)
} else {
// Fallback on earlier versions
return compute_lnaddress_to_lnurl(lnaddr)
}
}
func compute_lnaddress_to_lnurl(_ lnaddr: String) -> String? {
let parts = lnaddr.split(separator: "@")
guard parts.count == 2 else {
return nil
@@ -322,4 +374,3 @@ func lnaddress_to_lnurl(_ lnaddr: String) -> String? {
return bech32_encode(hrp: "lnurl", Array(dat))
}
+176 -33
View File
@@ -32,6 +32,50 @@ enum ValidationResult: Decodable {
case bad_sig
}
/// Represents metadata from a NIP-89 client tag (`["client", name, address?, relay?]`).
/// Used to identify which application published a nostr event.
struct ClientTagMetadata: Equatable {
/// The client application name (e.g., "Damus").
let name: String
/// Optional NIP-89 handler address for the client.
let handlerAddress: String?
/// Optional relay hint where the handler can be found.
let relayHint: String?
init(name: String, handlerAddress: String? = nil, relayHint: String? = nil) {
self.name = name
self.handlerAddress = handlerAddress
self.relayHint = relayHint
}
/// Parses client tag metadata from tag components array.
/// - Parameter tagComponents: Array where index 0 is "client", index 1 is name, etc.
/// - Returns: nil if the tag is not a valid client tag.
init?(tagComponents: [String]) {
guard tagComponents.first == "client", let clientName = tagComponents[safe: 1], !clientName.isEmpty else {
return nil
}
self.name = clientName
self.handlerAddress = tagComponents[safe: 2]
self.relayHint = tagComponents[safe: 3]
}
/// Converts this metadata back into a tag array suitable for inclusion in an event.
var tagValues: [String] {
var components = ["client", name]
if let handlerAddress, !handlerAddress.isEmpty {
components.append(handlerAddress)
if let relayHint, !relayHint.isEmpty {
components.append(relayHint)
}
}
return components
}
/// The default Damus client tag.
static let damus = ClientTagMetadata(name: "Damus")
}
/*
class NostrEventOld: Codable, Identifiable, CustomStringConvertible, Equatable, Hashable, Comparable {
// TODO: memory mapped db events
@@ -321,7 +365,7 @@ func sign_id(privkey: String, id: String) -> String {
// Extra params for custom signing
var aux_rand = random_bytes(count: 64).bytes
var aux_rand = random_bytes(count: 64).byteArray
var digest = try! id.bytes
// API allows for signing variable length messages
@@ -331,11 +375,11 @@ func sign_id(privkey: String, id: String) -> String {
}
func decode_nostr_event(txt: String) -> NostrResponse? {
return NostrResponse.owned_from_json(json: txt)
return NostrResponse.decode(from: txt)
}
func decode_and_verify_nostr_response(txt: String) -> NostrResponse? {
guard let response = NostrResponse.owned_from_json(json: txt) else { return nil }
guard let response = NostrResponse.decode(from: txt) else { return nil }
guard verify_nostr_response(response: response) == true else { return nil }
return response
}
@@ -352,6 +396,10 @@ func verify_nostr_response(response: borrowing NostrResponse) -> Bool {
return true
case .auth(_):
return true
case .negentropyError(subscriptionId: let subscriptionId, reasonCodeString: let reasonCodeString):
return true
case .negentropyMessage(subscriptionId: let subscriptionId, hexEncodedData: let hexEncodedData):
return true
}
}
@@ -512,6 +560,15 @@ func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String =
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 7, tags: tags)
}
func make_live_chat_event(keypair: FullKeypair, content: String, root: String, dtag: String, relayURL: RelayURL?) -> NostrEvent? {
//var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag })
var aTagBuilder = ["a", "30311:\(root):\(dtag)"]
var tags: [[String]] = [aTagBuilder]
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 1311, tags: tags)
}
func generate_private_keypair(our_privkey: Privkey, id: NoteId, created_at: UInt32) -> FullKeypair? {
let to_hash = our_privkey.hex() + id.hex() + String(created_at)
guard let dat = to_hash.data(using: .utf8) else {
@@ -786,57 +843,116 @@ func validate_event(ev: NostrEvent) -> ValidationResult {
let ctx = secp256k1.Context.raw
var xonly_pubkey = secp256k1_xonly_pubkey.init()
var ev_pubkey = ev.pubkey.id.bytes
var ev_pubkey = ev.pubkey.id.byteArray
var ok = secp256k1_xonly_pubkey_parse(ctx, &xonly_pubkey, &ev_pubkey) != 0
if !ok {
return .bad_sig
}
var sig = ev.sig.data.bytes
var idbytes = id.id.bytes
var sig = ev.sig.data.byteArray
var idbytes = id.id.byteArray
ok = secp256k1_schnorrsig_verify(ctx, &sig, &idbytes, 32, &xonly_pubkey) > 0
return ok ? .ok : .bad_sig
}
func first_eref_mention(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> Mention<NoteId>? {
guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else { return nil }
return blockGroup.forEachBlock({ index, block in
switch block {
case .mention(let mention):
guard let mention = MentionRef(block: mention) else { return .loopContinue }
switch mention.nip19 {
case .note(let noteId):
return .loopReturn(Mention<NoteId>.note(noteId, index: index))
case .nevent(let nEvent):
return .loopReturn(Mention<NoteId>.note(nEvent.noteid, index: index))
return try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
return blockGroup.forEachBlock({ index, block in
switch block {
case .mention(let mention):
guard let mention = MentionRef(block: mention) else { return .loopContinue }
switch mention.nip19 {
case .note(let noteId):
return .loopReturn(Mention<NoteId>.note(noteId, index: index))
case .nevent(let nEvent):
return .loopReturn(Mention<NoteId>.note(nEvent.noteid, index: index))
default:
return .loopContinue
}
default:
return .loopContinue
}
default:
return .loopContinue
}
})
})
}
func separate_invoices(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [Invoice]? {
guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else {
/// Represents a note mention with optional relay hints for fetching.
struct NoteMentionWithHints {
let noteId: NoteId
let relayHints: [RelayURL]
let index: Int?
}
/// Finds the first event reference mention in a note's content, preserving relay hints.
///
/// Per NIP-19, `nevent` bech32 entities may include relay hints. This function extracts
/// those hints so they can be used when fetching the referenced event.
///
/// If no inline mention is found in the content, falls back to checking `q` tags (NIP-10/NIP-18)
/// to support quote reposts that don't embed the quoted note inline.
///
/// - Parameters:
/// - ndb: The nostrdb instance.
/// - ev: The event to search.
/// - keypair: The keypair for decryption if needed.
/// - Returns: A `NoteMentionWithHints` containing the note ID and relay hints, or nil if not found.
func first_eref_mention_with_hints(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> NoteMentionWithHints? {
// First check content blocks for inline mentions
let inlineMention: NoteMentionWithHints? = try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
return blockGroup.forEachBlock({ index, block in
switch block {
case .mention(let mention):
guard let mentionRef = MentionRef(block: mention) else { return .loopContinue }
switch mentionRef.nip19 {
case .note(let noteId):
return .loopReturn(NoteMentionWithHints(noteId: noteId, relayHints: [], index: index))
case .nevent(let nEvent):
#if DEBUG
if !nEvent.relays.isEmpty {
print("[relay-hints] Inline nevent: Found \(nEvent.relays.count) hint(s) for \(nEvent.noteid.hex().prefix(8))...: \(nEvent.relays.map { $0.absoluteString })")
}
#endif
return .loopReturn(NoteMentionWithHints(noteId: nEvent.noteid, relayHints: nEvent.relays, index: index))
default:
return .loopContinue
}
default:
return .loopContinue
}
})
})
if let inlineMention {
return inlineMention
}
// Fall back to q tags (NIP-10/NIP-18 quote reposts)
guard let quoteRef = ev.referenced_quote_refs.first else {
return nil
}
let invoiceBlocks: [Invoice] = (try? blockGroup.reduce(initialResult: [Invoice](), { index, invoices, block in
switch block {
case .invoice(let invoice):
if let invoice = invoice.as_invoice() {
return .loopReturn(invoices + [invoice])
#if DEBUG
if !quoteRef.relayHints.isEmpty {
print("[relay-hints] Quote: Found q tag with \(quoteRef.relayHints.count) hint(s) for \(quoteRef.note_id.hex().prefix(8))...: \(quoteRef.relayHints.map { $0.absoluteString })")
}
#endif
return NoteMentionWithHints(noteId: quoteRef.note_id, relayHints: quoteRef.relayHints, index: nil)
}
func separate_invoices(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [Invoice]? {
return try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
let invoiceBlocks: [Invoice] = (try? blockGroup.reduce(initialResult: [Invoice](), { index, invoices, block in
switch block {
case .invoice(let invoice):
return .loopReturn(invoices + [invoice.as_invoice()])
default:
break
}
default:
break
}
return .loopContinue
})) ?? []
return invoiceBlocks.isEmpty ? nil : invoiceBlocks
return .loopContinue
})) ?? []
return invoiceBlocks.isEmpty ? nil : invoiceBlocks
})
}
/**
@@ -872,4 +988,31 @@ extension NostrEvent {
return nil
}
}
#if DEBUG
var debugDescription: String {
var output = "🔍 NostrEvent Debug Info\n"
output += "═══════════════════════════\n"
output += "📝 ID: \(id)\n"
output += "👤 Pubkey: \(pubkey)\n"
output += "📅 Created: \(Date(timeIntervalSince1970: TimeInterval(created_at))) (\(created_at))\n"
output += "🏷️ Kind: \(kind) (\(String(describing: known_kind))\n"
output += "✍️ Signature: \(sig)\n"
output += "📄 Content (\(content.count) chars):\n"
output += " \"\(content.prefix(100))\(content.count > 100 ? "..." : "")\"\n"
output += "\n🏷️ Tags (\(tags.count) total):\n"
for (index, tag) in tags.enumerated() {
output += " [\(index)]: ["
for (tagIndex, tagElem) in tag.enumerated() {
if tagIndex > 0 { output += ", " }
output += "\"\(tagElem.string())\""
}
output += "]\n"
}
output += "═══════════════════════════\n"
return output
}
#endif
}
+3 -1
View File
@@ -18,7 +18,7 @@ enum NostrKind: UInt32, Codable {
case boost = 6
case like = 7
case chat = 42
case voice_message = 1222
case live_chat = 1311
case mute_list = 10000
case relay_list = 10002
case interest_list = 10015
@@ -31,6 +31,8 @@ enum NostrKind: UInt32, Codable {
case nwc_request = 23194
case nwc_response = 23195
case http_auth = 27235
case live = 30311
case status = 30315
case contact_card = 30_382
case follow_list = 39089
}
+94
View File
@@ -48,6 +48,12 @@ enum NostrRequest {
case event(NostrEvent)
/// Authenticate with the relay
case auth(NostrEvent)
/// Negentropy open
case negentropyOpen(subscriptionId: String, filter: NostrFilter, initialMessage: [UInt8])
/// Negentropy message
case negentropyMessage(subscriptionId: String, message: [UInt8])
/// Close negentropy communication
case negentropyClose(subscriptionId: String)
/// Whether this request is meant to write data to a relay
var is_write: Bool {
@@ -60,6 +66,12 @@ enum NostrRequest {
return true
case .auth:
return false
case .negentropyOpen:
return false
case .negentropyMessage:
return false
case .negentropyClose:
return false
}
}
@@ -69,3 +81,85 @@ enum NostrRequest {
}
}
func make_nostr_req(_ req: NostrRequest) -> String? {
switch req {
case .subscribe(let sub):
return make_nostr_subscription_req(sub.filters, sub_id: sub.sub_id)
case .unsubscribe(let sub_id):
return make_nostr_unsubscribe_req(sub_id)
case .event(let ev):
return make_nostr_push_event(ev: ev)
case .auth(let ev):
return make_nostr_auth_event(ev: ev)
case .negentropyOpen(subscriptionId: let subscriptionId, filter: let filter, initialMessage: let initialMessage):
return make_nostr_negentropy_open_req(subscriptionId: subscriptionId, filter: filter, initialMessage: initialMessage)
case .negentropyMessage(subscriptionId: let subscriptionId, message: let message):
return make_nostr_negentropy_message_req(subscriptionId: subscriptionId, message: message)
case .negentropyClose(subscriptionId: let subscriptionId):
return make_nostr_negentropy_close_req(subscriptionId: subscriptionId)
}
}
func make_nostr_auth_event(ev: NostrEvent) -> String? {
guard let event = encode_json(ev) else {
return nil
}
let encoded = "[\"AUTH\",\(event)]"
print(encoded)
return encoded
}
func make_nostr_push_event(ev: NostrEvent) -> String? {
guard let event = encode_json(ev) else {
return nil
}
let encoded = "[\"EVENT\",\(event)]"
print(encoded)
return encoded
}
func make_nostr_unsubscribe_req(_ sub_id: String) -> String? {
"[\"CLOSE\",\"\(sub_id)\"]"
}
func make_nostr_subscription_req(_ filters: [NostrFilter], sub_id: String) -> String? {
let encoder = JSONEncoder()
var req = "[\"REQ\",\"\(sub_id)\""
for filter in filters {
req += ","
guard let filter_json = try? encoder.encode(filter) else {
return nil
}
let filter_json_str = String(decoding: filter_json, as: UTF8.self)
req += filter_json_str
}
req += "]"
return req
}
func make_nostr_negentropy_open_req(subscriptionId: String, filter: NostrFilter, initialMessage: [UInt8]) -> String? {
let encoder = JSONEncoder()
let messageData = Data(initialMessage)
let messageHex = hex_encode(messageData)
var req = "[\"NEG-OPEN\",\"\(subscriptionId)\","
guard let filter_json = try? encoder.encode(filter) else {
return nil
}
let filter_json_str = String(decoding: filter_json, as: UTF8.self)
req += filter_json_str
req += ",\"\(messageHex)\""
req += "]"
return req
}
func make_nostr_negentropy_message_req(subscriptionId: String, message: [UInt8]) -> String? {
let messageData = Data(message)
let messageHex = hex_encode(messageData)
return "[\"NEG-MSG\",\"\(subscriptionId)\",\"\(messageHex)\"]"
}
func make_nostr_negentropy_close_req(subscriptionId: String) -> String? {
return "[\"NEG-CLOSE\",\"\(subscriptionId)\"]"
}
+93 -2
View File
@@ -18,6 +18,23 @@ enum MaybeResponse {
case ok(NostrResponse)
}
enum NegentropyResponse {
/// Negentropy error
case error(subscriptionId: String, reasonCodeString: String)
/// Negentropy message
case message(subscriptionId: String, data: [UInt8])
/// Invalid negentropy message
case invalidResponse(subscriptionId: String)
var subscriptionId: String {
switch self {
case .error(subscriptionId: let subscriptionId, reasonCodeString: let reasonCodeString): subscriptionId
case .message(subscriptionId: let subscriptionId, data: let data): subscriptionId
case .invalidResponse(subscriptionId: let subscriptionId): subscriptionId
}
}
}
enum NostrResponse {
case event(String, NostrEvent)
case notice(String)
@@ -27,6 +44,10 @@ enum NostrResponse {
///
/// The associated type of this case is the challenge string sent by the server.
case auth(String)
/// Negentropy error
case negentropyError(subscriptionId: String, reasonCodeString: String)
/// Negentropy message
case negentropyMessage(subscriptionId: String, hexEncodedData: String)
var subid: String? {
switch self {
@@ -36,14 +57,84 @@ enum NostrResponse {
return sub_id
case .eose(let sub_id):
return sub_id
case .notice:
case .notice(_):
return nil
case .auth(let challenge_string):
return challenge_string
case .negentropyError(subscriptionId: let subscriptionId, reasonCodeString: _):
return subscriptionId
case .negentropyMessage(subscriptionId: let subscriptionId, hexEncodedData: _):
return subscriptionId
}
}
var negentropyResponse: NegentropyResponse? {
switch self {
case .event(_, _): return nil
case .notice(_): return nil
case .eose(_): return nil
case .ok(_): return nil
case .auth(_): return nil
case .negentropyError(subscriptionId: let subscriptionId, reasonCodeString: let reasonCodeString):
return .error(subscriptionId: subscriptionId, reasonCodeString: reasonCodeString)
case .negentropyMessage(subscriptionId: let subscriptionId, hexEncodedData: let hexData):
if let bytes = hex_decode(hexData) {
return .message(subscriptionId: subscriptionId, data: bytes)
}
return .invalidResponse(subscriptionId: subscriptionId)
}
}
/// Decode a Nostr response from JSON using idiomatic Swift parsing
/// Supports NEG-MSG and NEG-ERR formats, falling back to C parsing for other message types
static func decode(from json: String) -> NostrResponse? {
// Try Swift-based parsing first for negentropy messages
if let response = try? decodeNegentropyMessage(from: json) {
return response
}
// Fall back to C-based parsing for standard Nostr messages
return owned_from_json(json: json)
}
/// Decode negentropy messages using idiomatic Swift
private static func decodeNegentropyMessage(from json: String) throws -> NostrResponse? {
guard let jsonData = json.data(using: .utf8) else {
return nil
}
guard let jsonArray = try JSONSerialization.jsonObject(with: jsonData) as? [Any],
jsonArray.count >= 2,
let messageType = jsonArray[0] as? String else {
return nil
}
switch messageType {
case "NEG-MSG":
// Format: ["NEG-MSG", "subscription-id", "hex-encoded-data"]
guard jsonArray.count == 3,
let subscriptionId = jsonArray[1] as? String,
let hexData = jsonArray[2] as? String else {
return nil
}
return .negentropyMessage(subscriptionId: subscriptionId, hexEncodedData: hexData)
case "NEG-ERR":
// Format: ["NEG-ERR", "subscription-id", "reason-code"]
guard jsonArray.count == 3,
let subscriptionId = jsonArray[1] as? String,
let reasonCode = jsonArray[2] as? String else {
return nil
}
return .negentropyError(subscriptionId: subscriptionId, reasonCodeString: reasonCode)
default:
// Not a negentropy message
return nil
}
}
static func owned_from_json(json: String) -> NostrResponse? {
private static func owned_from_json(json: String) -> NostrResponse? {
return json.withCString{ cstr in
let bufsize: Int = max(Int(Double(json.utf8.count) * 8.0), Int(getpagesize()))
let data = malloc(bufsize)
+35
View File
@@ -0,0 +1,35 @@
//
// ProfileObserver.swift
// damus
//
// Created by Daniel DAquino on 2025-09-19.
//
import Combine
import Foundation
@MainActor
class ProfileObserver: ObservableObject {
private let pubkey: Pubkey
private var observerTask: Task<Void, any Error>? = nil
private let damusState: DamusState
init(pubkey: Pubkey, damusState: DamusState) {
self.pubkey = pubkey
self.damusState = damusState
self.watchProfileChanges()
}
private func watchProfileChanges() {
observerTask?.cancel()
observerTask = Task {
for await _ in await damusState.nostrNetwork.profilesManager.streamProfile(pubkey: self.pubkey) {
try Task.checkCancellation()
DispatchQueue.main.async { self.objectWillChange.send() }
}
}
}
deinit {
observerTask?.cancel()
}
}
+29 -15
View File
@@ -74,31 +74,45 @@ class Profiles {
profile_data(pubkey).zapper
}
func lookup_with_timestamp(_ pubkey: Pubkey) -> NdbTxn<ProfileRecord?>? {
ndb.lookup_profile(pubkey)
func lookup_with_timestamp<T>(_ pubkey: Pubkey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T {
return try ndb.lookup_profile(pubkey, borrow: lendingFunction)
}
func lookup_lnurl(_ pubkey: Pubkey) throws -> String? {
return try lookup_with_timestamp(pubkey, borrow: { pr in
switch pr {
case .some(let pr): return pr.lnurl
case .none: return nil
}
})
}
func lookup_by_key(key: ProfileKey) -> NdbTxn<ProfileRecord?>? {
ndb.lookup_profile_by_key(key: key)
func lookup_by_key<T>(key: ProfileKey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T {
return try ndb.lookup_profile_by_key(key: key, borrow: lendingFunction)
}
func search<Y>(_ query: String, limit: Int, txn: NdbTxn<Y>) -> [Pubkey] {
ndb.search_profile(query, limit: limit, txn: txn)
func search(_ query: String, limit: Int) throws -> [Pubkey] {
try ndb.search_profile(query, limit: limit)
}
func lookup(id: Pubkey, txn_name: String? = nil) -> NdbTxn<Profile?>? {
guard let txn = ndb.lookup_profile(id, txn_name: txn_name) else {
return nil
}
return txn.map({ pr in pr?.profile })
func lookup(id: Pubkey) throws -> Profile? {
return try ndb.lookup_profile(id, borrow: { pr in
switch pr {
case .none:
return nil
case .some(let profileRecord):
// This will clone the value to make it owned and safe to return.
return profileRecord.profile
}
})
}
func lookup_key_by_pubkey(_ pubkey: Pubkey) -> ProfileKey? {
ndb.lookup_profile_key(pubkey)
func lookup_key_by_pubkey(_ pubkey: Pubkey) throws -> ProfileKey? {
try ndb.lookup_profile_key(pubkey)
}
func has_fresh_profile<Y>(id: Pubkey, txn: NdbTxn<Y>) -> Bool {
guard let fetched_at = ndb.read_profile_last_fetched(txn: txn, pubkey: id)
func has_fresh_profile(id: Pubkey) throws -> Bool {
guard let fetched_at = try ndb.read_profile_last_fetched(pubkey: id)
else {
return false
}
+5
View File
@@ -139,6 +139,11 @@ struct RelayMetadata: Codable {
var is_paid: Bool {
return limitation?.payment_required ?? false
}
var supports_negentropy: Bool? {
// Supports negentropy if NIP-77 is in the list of supported NIPs
supported_nips?.contains(where: { $0 == 77 })
}
}
extension RelayPool {
+148 -64
View File
@@ -7,6 +7,7 @@
import Combine
import Foundation
import Negentropy
enum NostrConnectionEvent {
/// Other non-message websocket events
@@ -35,6 +36,15 @@ enum NostrConnectionEvent {
}
}
}
var subId: String? {
switch self {
case .ws_connection_event(_):
return nil
case .nostr_event(let event):
return event.subid
}
}
}
final class RelayConnection: ObservableObject {
@@ -48,18 +58,55 @@ final class RelayConnection: ObservableObject {
private lazy var socket = WebSocket(relay_url.url)
private var subscriptionToken: AnyCancellable?
private var handleEvent: (NostrConnectionEvent) -> ()
private var handleEvent: (NostrConnectionEvent) async -> ()
private var processEvent: (WebSocketEvent) -> ()
private let relay_url: RelayURL
var log: RelayLog?
/// The queue of WebSocket events to be processed
/// We need this queue to ensure events are processed and sent to RelayPool in the exact order in which they arrive.
/// See `processEventsTask()` for more information
var wsEventQueue: QueueableNotify<WebSocketEvent>
/// The task which will process WebSocket events in the order in which we receive them from the wire
var wsEventProcessTask: Task<Void, any Error>?
@RelayPoolActor // Isolate this to a specific actor to avoid thread-satefy issues.
var negentropyStreams: [String: AsyncStream<NegentropyResponse>.Continuation] = [:]
init(url: RelayURL,
handleEvent: @escaping (NostrConnectionEvent) -> (),
handleEvent: @escaping (NostrConnectionEvent) async -> (),
processUnverifiedWSEvent: @escaping (WebSocketEvent) -> ())
{
self.relay_url = url
self.handleEvent = handleEvent
self.processEvent = processUnverifiedWSEvent
self.wsEventQueue = .init(maxQueueItems: 1000)
self.wsEventProcessTask = nil
self.wsEventProcessTask = Task {
try await self.processEventsTask()
}
}
deinit {
self.wsEventProcessTask?.cancel()
}
/// The task that will stream the queue of WebSocket events to be processed
/// We need this in order to ensure events are processed and sent to RelayPool in the exact order in which they arrive.
///
/// We need this (or some equivalent syncing mechanism) because without it, two WebSocket events can be processed concurrently,
/// and sometimes sent in the wrong order due to difference in processing timing.
///
/// For example, streaming a filter that yields 1 event can cause the EOSE signal to arrive in RelayPool before the event, simply because the event
/// takes longer to process compared to the EOSE signal.
///
/// To prevent this, we send raw WebSocket events to this queue BEFORE any processing (to ensure equal timing),
/// and then process the queue in the order in which they appear
func processEventsTask() async throws {
for await item in await self.wsEventQueue.stream {
try Task.checkCancellation()
await self.receive(event: item)
}
}
func ping() {
@@ -95,12 +142,12 @@ final class RelayConnection: ObservableObject {
.sink { [weak self] completion in
switch completion {
case .failure(let error):
self?.receive(event: .error(error))
Task { await self?.wsEventQueue.add(item: .error(error)) }
case .finished:
self?.receive(event: .disconnected(.normalClosure, nil))
Task { await self?.wsEventQueue.add(item: .disconnected(.normalClosure, nil)) }
}
} receiveValue: { [weak self] event in
self?.receive(event: event)
Task { await self?.wsEventQueue.add(item: event) }
}
socket.connect()
@@ -138,7 +185,7 @@ final class RelayConnection: ObservableObject {
}
}
private func receive(event: WebSocketEvent) {
private func receive(event: WebSocketEvent) async {
assert(!Thread.isMainThread, "This code must not be executed on the main thread")
processEvent(event)
switch event {
@@ -149,7 +196,7 @@ final class RelayConnection: ObservableObject {
self.isConnecting = false
}
case .message(let message):
self.receive(message: message)
await self.receive(message: message)
case .disconnected(let closeCode, let reason):
if closeCode != .normalClosure {
Log.error("⚠️ Warning: RelayConnection (%d) closed with code: %s", for: .networking, String(describing: closeCode), String(describing: reason))
@@ -176,10 +223,8 @@ final class RelayConnection: ObservableObject {
self.reconnect_with_backoff()
}
}
DispatchQueue.main.async {
guard let ws_connection_event = NostrConnectionEvent.WSConnectionEvent.from(full_ws_event: event) else { return }
self.handleEvent(.ws_connection_event(ws_connection_event))
}
guard let ws_connection_event = NostrConnectionEvent.WSConnectionEvent.from(full_ws_event: event) else { return }
await self.handleEvent(.ws_connection_event(ws_connection_event))
if let description = event.description {
log?.add(description)
@@ -213,74 +258,113 @@ final class RelayConnection: ObservableObject {
}
}
private func receive(message: URLSessionWebSocketTask.Message) {
private func receive(message: URLSessionWebSocketTask.Message) async {
switch message {
case .string(let messageString):
// NOTE: Once we switch to the local relay model,
// we will not need to verify nostr events at this point.
if let ev = decode_and_verify_nostr_response(txt: messageString) {
DispatchQueue.main.async {
self.handleEvent(.nostr_event(ev))
await self.handleEvent(.nostr_event(ev))
if let negentropyResponse = ev.negentropyResponse {
await self.negentropyStreams[negentropyResponse.subscriptionId]?.yield(negentropyResponse)
}
return
}
print("failed to decode event \(messageString)")
print("\(self.relay_url): failed to decode event \(messageString)")
case .data(let messageData):
if let messageString = String(data: messageData, encoding: .utf8) {
receive(message: .string(messageString))
await receive(message: .string(messageString))
}
@unknown default:
print("An unexpected URLSessionWebSocketTask.Message was received.")
}
}
}
func make_nostr_req(_ req: NostrRequest) -> String? {
switch req {
case .subscribe(let sub):
return make_nostr_subscription_req(sub.filters, sub_id: sub.sub_id)
case .unsubscribe(let sub_id):
return make_nostr_unsubscribe_req(sub_id)
case .event(let ev):
return make_nostr_push_event(ev: ev)
case .auth(let ev):
return make_nostr_auth_event(ev: ev)
}
}
func make_nostr_auth_event(ev: NostrEvent) -> String? {
guard let event = encode_json(ev) else {
return nil
}
let encoded = "[\"AUTH\",\(event)]"
print(encoded)
return encoded
}
func make_nostr_push_event(ev: NostrEvent) -> String? {
guard let event = encode_json(ev) else {
return nil
}
let encoded = "[\"EVENT\",\(event)]"
print(encoded)
return encoded
}
func make_nostr_unsubscribe_req(_ sub_id: String) -> String? {
"[\"CLOSE\",\"\(sub_id)\"]"
}
func make_nostr_subscription_req(_ filters: [NostrFilter], sub_id: String) -> String? {
let encoder = JSONEncoder()
var req = "[\"REQ\",\"\(sub_id)\""
for filter in filters {
req += ","
guard let filter_json = try? encoder.encode(filter) else {
return nil
// MARK: - Negentropy logic
/// Retrieves the IDs of events missing locally compared to the relay using negentropy protocol.
///
/// - Parameters:
/// - filter: The Nostr filter to scope the sync
/// - negentropyVector: The local storage vector for comparison
/// - timeout: Optional timeout for the operation
/// - Returns: Array of IDs that the relay has but we don't
/// - Throws: NegentropySyncError on failure
@RelayPoolActor
func getMissingIds(filter: NostrFilter, negentropyVector: NegentropyStorageVector, timeout: Duration?) async throws -> [Id] {
if let relayMetadata = try? await fetch_relay_metadata(relay_id: self.relay_url),
let supportsNegentropy = relayMetadata.supports_negentropy {
if !supportsNegentropy {
// Throw an error if the relay specifically advertises that there is no support for negentropy
throw NegentropySyncError.notSupported
}
}
let filter_json_str = String(decoding: filter_json, as: UTF8.self)
req += filter_json_str
let timeout = timeout ?? .seconds(3)
let frameSizeLimit = 60_000 // Copied from rust-nostr project: Default frame limit is 128k. Halve that (hex encoding) and subtract a bit (JSON msg overhead)
try? negentropyVector.seal() // Error handling note: We do not care if it throws an `alreadySealed` error. As long as it is sealed in the end it is fine
let negentropyClient = try Negentropy(storage: negentropyVector, frameSizeLimit: frameSizeLimit)
let initialMessage = try negentropyClient.initiate()
let subscriptionId = UUID().uuidString
var allNeedIds: [Id] = []
for await response in negentropyStream(subscriptionId: subscriptionId, filter: filter, initialMessage: initialMessage, timeoutDuration: timeout) {
switch response {
case .error(subscriptionId: _, reasonCodeString: let reasonCodeString):
throw NegentropySyncError.genericError(reasonCodeString)
case .message(subscriptionId: _, data: let data):
var haveIds: [Id] = []
var needIds: [Id] = []
let nextMessage = try negentropyClient.reconcile(data, haveIds: &haveIds, needIds: &needIds)
allNeedIds.append(contentsOf: needIds)
if let nextMessage {
self.send(.typical(.negentropyMessage(subscriptionId: subscriptionId, message: nextMessage)))
}
else {
// Reconciliation is complete
return allNeedIds
}
case .invalidResponse(subscriptionId: _):
throw NegentropySyncError.relayError
}
}
// If the stream completes without a response, throw a timeout/relay error
throw NegentropySyncError.relayError
}
enum NegentropySyncError: Error {
/// Fallback generic error
case genericError(String)
/// Negentropy is not supported by the relay
case notSupported
/// Something went wrong with the relay communication during negentropy sync
case relayError
}
@RelayPoolActor
private func negentropyStream(subscriptionId: String, filter: NostrFilter, initialMessage: [UInt8], timeoutDuration: Duration? = nil) -> AsyncStream<NegentropyResponse> {
return AsyncStream<NegentropyResponse> { continuation in
self.negentropyStreams[subscriptionId] = continuation
let nostrRequest: NostrRequest = .negentropyOpen(subscriptionId: subscriptionId, filter: filter, initialMessage: initialMessage)
self.send(.typical(nostrRequest))
let timeoutTask = Task {
if let timeoutDuration {
try Task.checkCancellation()
try await Task.sleep(for: timeoutDuration)
try Task.checkCancellation()
continuation.finish()
}
}
continuation.onTermination = { @Sendable _ in
Task {
await self.removeNegentropyStream(id: subscriptionId)
self.send(.typical(.negentropyClose(subscriptionId: subscriptionId)))
}
timeoutTask.cancel()
}
}
}
@RelayPoolActor
private func removeNegentropyStream(id: String) {
self.negentropyStreams[id] = nil
}
req += "]"
return req
}
+615 -109
View File
@@ -7,10 +7,14 @@
import Foundation
import Network
import Negentropy
struct RelayHandler {
let sub_id: String
let callback: (RelayURL, NostrConnectionEvent) -> ()
/// The filters that this handler will handle. Set this to `nil` if you want your handler to receive all events coming from the relays.
let filters: [NostrFilter]?
let to: [RelayURL]?
var handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation
}
struct QueuedRequest {
@@ -19,135 +23,367 @@ struct QueuedRequest {
let skip_ephemeral: Bool
}
struct SeenEvent: Hashable {
let relay_id: RelayURL
let evid: NoteId
}
@globalActor
actor RelayPoolActor {
static let shared = RelayPoolActor()
private init() {}
}
/// Establishes and manages connections and subscriptions to a list of relays.
@RelayPoolActor
class RelayPool {
@MainActor
private(set) var relays: [Relay] = []
var open: Bool = false
var handlers: [RelayHandler] = []
var request_queue: [QueuedRequest] = []
var seen: [NoteId: Set<RelayURL>] = [:]
var counts: [RelayURL: UInt64] = [:]
var ndb: Ndb
var ndb: Ndb?
/// The keypair used to authenticate with relays
var keypair: Keypair?
var message_received_function: (((String, RelayDescriptor)) -> Void)?
var message_sent_function: (((String, Relay)) -> Void)?
var delegate: Delegate?
private(set) var signal: SignalModel = SignalModel()
private let network_monitor = NWPathMonitor()
/// Tracks active leases on ephemeral relays to prevent premature cleanup.
/// Each lookup that uses an ephemeral relay acquires a lease; cleanup only
/// happens when the last lease is released.
private var ephemeralLeases: [RelayURL: Int] = [:]
let network_monitor = NWPathMonitor()
private let network_monitor_queue = DispatchQueue(label: "io.damus.network_monitor")
private var last_network_status: NWPath.Status = .unsatisfied
/// The limit of maximum concurrent subscriptions. Any subscriptions beyond this limit will be paused until subscriptions clear
/// This is to avoid error states and undefined behaviour related to hitting subscription limits on the relays, by letting those wait instead with the principle that although slower is not ideal, it is better than completely broken.
static let MAX_CONCURRENT_SUBSCRIPTION_LIMIT = 14 // This number is only an educated guess based on some local experiments.
func close() {
disconnect()
relays = []
func close() async {
await disconnect()
await clearRelays()
open = false
handlers = []
request_queue = []
seen.removeAll()
await clearSeen()
counts = [:]
keypair = nil
}
@MainActor
private func clearRelays() {
relays = []
}
private func clearSeen() {
seen.removeAll()
}
init(ndb: Ndb, keypair: Keypair? = nil) {
nonisolated init(ndb: Ndb?, keypair: Keypair? = nil) {
self.ndb = ndb
self.keypair = keypair
network_monitor.pathUpdateHandler = { [weak self] path in
if (path.status == .satisfied || path.status == .requiresConnection) && self?.last_network_status != path.status {
DispatchQueue.main.async {
self?.connect_to_disconnected()
}
}
if let self, path.status != self.last_network_status {
for relay in self.relays {
relay.connection.log?.add("Network state: \(path.status)")
}
}
self?.last_network_status = path.status
Task { await self?.pathUpdateHandler(path: path) }
}
network_monitor.start(queue: network_monitor_queue)
}
private func pathUpdateHandler(path: NWPath) async {
if (path.status == .satisfied || path.status == .requiresConnection) && self.last_network_status != path.status {
await self.connect_to_disconnected()
}
if path.status != self.last_network_status {
for relay in await self.relays {
relay.connection.log?.add("Network state: \(path.status)")
}
}
self.last_network_status = path.status
}
@MainActor
var our_descriptors: [RelayDescriptor] {
return all_descriptors.filter { d in !d.ephemeral }
}
@MainActor
var all_descriptors: [RelayDescriptor] {
relays.map { r in r.descriptor }
}
@MainActor
var num_connected: Int {
return relays.reduce(0) { n, r in n + (r.connection.isConnected ? 1 : 0) }
}
func remove_handler(sub_id: String) {
self.handlers = handlers.filter { $0.sub_id != sub_id }
print("removing \(sub_id) handler, current: \(handlers.count)")
self.handlers = handlers.filter {
if $0.sub_id != sub_id {
return true
}
else {
$0.handler.finish()
return false
}
}
Log.debug("Removing %s handler, current: %d", for: .networking, sub_id, handlers.count)
}
func ping() {
Log.info("Pinging %d relays", for: .networking, relays.count)
for relay in relays {
func ping() async {
Log.info("Pinging %d relays", for: .networking, await relays.count)
for relay in await relays {
relay.connection.ping()
}
}
func register_handler(sub_id: String, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) {
for handler in handlers {
// don't add duplicate handlers
if handler.sub_id == sub_id {
return
}
func register_handler(sub_id: String, filters: [NostrFilter]?, to relays: [RelayURL]? = nil, handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation) async {
while handlers.count > Self.MAX_CONCURRENT_SUBSCRIPTION_LIMIT {
Log.debug("%s: Too many subscriptions, waiting for subscription pool to clear", for: .networking, sub_id)
try? await Task.sleep(for: .seconds(1))
}
self.handlers.append(RelayHandler(sub_id: sub_id, callback: handler))
print("registering \(sub_id) handler, current: \(self.handlers.count)")
Log.debug("%s: Subscription pool cleared", for: .networking, sub_id)
handlers = handlers.filter({ handler in
if handler.sub_id == sub_id {
Log.error("Duplicate handler detected for the same subscription ID. Overriding.", for: .networking)
handler.handler.finish()
return false
}
else {
return true
}
})
self.handlers.append(RelayHandler(sub_id: sub_id, filters: filters, to: relays, handler: handler))
Log.debug("Registering %s handler, current: %d", for: .networking, sub_id, self.handlers.count)
}
func remove_relay(_ relay_id: RelayURL) {
/// Removes the relay with the given URL from the pool, permanently disables its connection, and ensures it is disconnected.
/// - Parameters:
/// - relay_id: The RelayURL identifying the relay to disable and remove.
@MainActor
func remove_relay(_ relay_id: RelayURL) async {
var i: Int = 0
self.disconnect(to: [relay_id])
await self.disconnect(to: [relay_id])
for relay in relays {
if relay.id == relay_id {
relay.connection.disablePermanently()
relays.remove(at: i)
break
}
i += 1
}
}
func add_relay(_ desc: RelayDescriptor) throws(RelayError) {
/// Acquires a lease on ephemeral relays to prevent them from being cleaned up
/// Increment lease counts for the given ephemeral relay URLs to prevent their removal while leased.
/// - Parameters:
/// - relayURLs: The relay URLs whose ephemeral lease counts will be incremented; each URL's lease count is increased by one.
func acquireEphemeralRelays(_ relayURLs: [RelayURL]) {
for url in relayURLs {
ephemeralLeases[url, default: 0] += 1
#if DEBUG
print("[RelayPool] Acquired lease on ephemeral relay \(url.absoluteString), count: \(ephemeralLeases[url] ?? 0)")
#endif
}
}
/// Releases leases on ephemeral relays. When the last lease is released,
/// Releases one lease for each specified relay and removes any ephemeral relay when its last lease is released.
/// - Parameters:
/// - relayURLs: Relay URLs whose leases should be decremented. If a relay's lease count reaches zero and the relay is marked ephemeral, the relay will be removed. Relays not present in the lease table are ignored.
func releaseEphemeralRelays(_ relayURLs: [RelayURL]) async {
for url in relayURLs {
guard let count = ephemeralLeases[url], count > 0 else { continue }
// Decrement immediately (atomic with respect to this actor, before any suspension)
let newCount = count - 1
ephemeralLeases[url] = newCount == 0 ? nil : newCount
#if DEBUG
print("[RelayPool] Released lease on ephemeral relay \(url.absoluteString), count: \(newCount)")
#endif
if newCount == 0 {
// Check if relay exists and is ephemeral
if let relay = await get_relay(url), relay.descriptor.ephemeral {
// Re-check: only remove if lease is still nil (not re-acquired during await)
guard ephemeralLeases[url] == nil else {
#if DEBUG
print("[RelayPool] Lease re-acquired during check, skipping removal: \(url.absoluteString)")
#endif
continue
}
#if DEBUG
print("[RelayPool] Removing ephemeral relay: \(url.absoluteString)")
#endif
await remove_relay(url)
}
}
}
}
/// Adds and registers a new relay in the pool using the provided descriptor.
/// - Parameter desc: Descriptor for the relay to add (includes its URL, metadata, and whether it is ephemeral).
/// - Throws: `RelayError.RelayAlreadyExists` if a relay with the same URL is already present in the pool.
func add_relay(_ desc: RelayDescriptor) async throws(RelayError) {
let relay_id = desc.url
if get_relay(relay_id) != nil {
if await get_relay(relay_id) != nil {
throw RelayError.RelayAlreadyExists
}
let conn = RelayConnection(url: desc.url, handleEvent: { event in
self.handle_event(relay_id: relay_id, event: event)
await self.handle_event(relay_id: relay_id, event: event)
}, processUnverifiedWSEvent: { wsev in
guard case .message(let msg) = wsev,
case .string(let str) = msg
else { return }
let _ = self.ndb.process_event(str)
#if DEBUG
if desc.ephemeral {
if str.hasPrefix("[\"EVENT\"") {
print("[RelayPool] Received EVENT from ephemeral relay \(relay_id.absoluteString): \(str.prefix(200))...")
} else if str.hasPrefix("[\"EOSE\"") {
print("[RelayPool] Received EOSE from ephemeral relay \(relay_id.absoluteString)")
}
}
#endif
let _ = self.ndb?.processEvent(str, originRelayURL: relay_id)
self.message_received_function?((str, desc))
})
let relay = Relay(descriptor: desc, connection: conn)
await self.appendRelayToList(relay: relay)
}
/// Appends the given Relay to the pool's internal list of relays.
@MainActor
private func appendRelayToList(relay: Relay) {
self.relays.append(relay)
}
func setLog(_ log: RelayLog, for relay_id: RelayURL) {
/// Ensures the given relay URLs are connected, adding them as ephemeral relays if not already in the pool.
/// Returns the list of relay URLs that are actually connected (ready for subscriptions).
///
/// Callers should use `acquireEphemeralRelays` before the lookup and `releaseEphemeralRelays` after.
///
/// - Parameters:
/// - relayURLs: The relay URLs to ensure are connected
/// - timeout: Maximum time to wait for pending connections (default 2s). Returns early when first relay connects.
/// Ensure the given relays are present in the pool and return those that are connected.
///
/// This will add missing URLs as ephemeral relays, initiate connections for relays that are not connected, and wait up to `timeout` for connections to establish. Once any relay connects, the method allows a short grace period for additional relays to connect before returning.
/// - Parameters:
/// - relayURLs: The relay URLs to ensure connectivity for. Missing URLs will be added as ephemeral relays.
/// - timeout: Maximum time to wait for connections (default: 2 seconds). A short grace period (300 ms) is applied after the first relay connects.
/// - Returns: The subset of `relayURLs` that are currently connected (includes relays that were already connected and those that became connected during the wait).
func ensureConnected(to relayURLs: [RelayURL], timeout: Duration = .seconds(2)) async -> [RelayURL] {
var toConnect: [RelayURL] = []
var alreadyConnected: [RelayURL] = []
for url in relayURLs {
if let existing = await get_relay(url) {
if existing.connection.isConnected {
alreadyConnected.append(url)
#if DEBUG
print("[RelayPool] Relay \(url.absoluteString) already connected")
#endif
} else {
toConnect.append(url)
}
continue
}
let descriptor = RelayDescriptor(url: url, info: .readWrite, variant: .ephemeral)
do {
try await add_relay(descriptor)
toConnect.append(url)
#if DEBUG
print("[RelayPool] Added ephemeral relay: \(url.absoluteString)")
#endif
} catch {
#if DEBUG
print("[RelayPool] Failed to add relay \(url.absoluteString): \(error)")
#endif
}
}
guard !toConnect.isEmpty else { return alreadyConnected }
await connect(to: toConnect)
let checkInterval: Duration = .milliseconds(50)
let overallDeadline = ContinuousClock.now + timeout
var graceDeadline: ContinuousClock.Instant? = alreadyConnected.isEmpty ? nil : ContinuousClock.now + .milliseconds(300)
// Wait for relays to connect. Once the first connects, start a grace period for others.
waitLoop: while ContinuousClock.now < overallDeadline {
do {
try await Task.sleep(for: checkInterval)
} catch {
break
}
// Check if any relay has connected
var anyConnected = false
for url in toConnect {
if let relay = await get_relay(url), relay.connection.isConnected {
anyConnected = true
break
}
}
if anyConnected && graceDeadline == nil {
// Start grace period on first connection
graceDeadline = ContinuousClock.now + .milliseconds(300)
}
// Exit once grace period expires (check every iteration if deadline is set)
if let deadline = graceDeadline, ContinuousClock.now >= deadline {
break waitLoop
}
}
// Collect all connected relays
var connected = alreadyConnected
for url in toConnect {
if let relay = await get_relay(url), relay.connection.isConnected {
connected.append(url)
#if DEBUG
print("[RelayPool] Relay \(url.absoluteString) connected: true")
#endif
} else {
#if DEBUG
print("[RelayPool] Relay \(url.absoluteString) connected: false (excluded)")
#endif
}
}
return connected
}
/// Attaches a `RelayLog` to the connection for the specified relay and records the current network status in the log.
/// - Parameters:
/// - log: The `RelayLog` instance to attach to the relay's connection.
/// - relay_id: The `RelayURL` identifying the relay whose connection will receive the log.
func setLog(_ log: RelayLog, for relay_id: RelayURL) async {
// add the current network state to the log
log.add("Network state: \(network_monitor.currentPath.status)")
get_relay(relay_id)?.connection.log = log
await get_relay(relay_id)?.connection.log = log
}
/// This is used to retry dead connections
func connect_to_disconnected() {
for relay in relays {
func connect_to_disconnected() async {
for relay in await relays {
let c = relay.connection
let is_connecting = c.isConnecting
@@ -164,38 +400,96 @@ class RelayPool {
}
}
func reconnect(to: [RelayURL]? = nil) {
let relays = to.map{ get_relays($0) } ?? self.relays
func reconnect(to targetRelays: [RelayURL]? = nil) async {
let relays = await getRelays(targetRelays: targetRelays)
for relay in relays {
// don't try to reconnect to broken relays
relay.connection.reconnect()
}
}
func connect(to: [RelayURL]? = nil) {
let relays = to.map{ get_relays($0) } ?? self.relays
func connect(to targetRelays: [RelayURL]? = nil) async {
let relays = await getRelays(targetRelays: targetRelays)
for relay in relays {
relay.connection.connect()
}
// Mark as open last, to prevent other classes from pulling data before the relays are actually connected
// Only mark as open when connecting ALL relays (not specific ones)
if targetRelays == nil {
open = true
}
}
func disconnect(to: [RelayURL]? = nil) {
let relays = to.map{ get_relays($0) } ?? self.relays
func disconnect(to targetRelays: [RelayURL]? = nil) async {
// Mark as closed first, to prevent other classes from pulling data while the relays are being disconnected
// Only mark as closed when disconnecting ALL relays (not specific ones)
if targetRelays == nil {
open = false
}
let relays = await getRelays(targetRelays: targetRelays)
for relay in relays {
relay.connection.disconnect()
}
}
/// Gets relays matching the provided relay URLs, or all relays when no targets are specified.
/// - Parameter targetRelays: Optional list of relay URLs to filter by. If `nil`, the pool's full relay list is returned.
/// - Returns: An array of `Relay` instances corresponding to the requested URLs; any requested URL not present in the pool is omitted from the result.
@MainActor
func getRelays(targetRelays: [RelayURL]? = nil) -> [Relay] {
let result = targetRelays.map{ get_relays($0) } ?? self.relays
#if DEBUG
if let targets = targetRelays {
let found = result.map { $0.descriptor.url.absoluteString }
let requested = targets.map { $0.absoluteString }
if found.count != targets.count {
print("[RelayPool] getRelays: MISMATCH! requested=\(requested) but found=\(found)")
}
}
#endif
return result
}
/// Deletes queued up requests that should not persist between app sessions (i.e. when the app goes to background then back to foreground)
func cleanQueuedRequestForSessionEnd() {
request_queue = request_queue.filter { request in
guard case .typical(let typicalRequest) = request.req else { return true }
switch typicalRequest {
case .subscribe(_):
return true
case .unsubscribe(_):
return false // Do not persist unsubscribe requests to prevent them to race against subscribe requests when we come back to the foreground.
case .event(_):
return true
case .auth(_):
return true
case .negentropyOpen(subscriptionId: _, filter: _, initialMessage: _):
return false // Do not persist negentropy requests across sessions
case .negentropyMessage(subscriptionId: _, message: _):
return false // Do not persist negentropy requests across sessions
case .negentropyClose(subscriptionId: _):
return false // Do not persist negentropy requests across sessions
}
}
}
func unsubscribe(sub_id: String, to: [RelayURL]? = nil) {
func unsubscribe(sub_id: String, to: [RelayURL]? = nil) async {
if to == nil {
self.remove_handler(sub_id: sub_id)
}
self.send(.unsubscribe(sub_id), to: to)
await self.send(.unsubscribe(sub_id), to: to)
}
func subscribe(sub_id: String, filters: [NostrFilter], handler: @escaping (RelayURL, NostrConnectionEvent) -> (), to: [RelayURL]? = nil) {
register_handler(sub_id: sub_id, handler: handler)
send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
func subscribe(sub_id: String, filters: [NostrFilter], handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation, to: [RelayURL]? = nil) {
Task {
await register_handler(sub_id: sub_id, filters: filters, to: to, handler: handler)
// When the caller specifies no relays, it is implied that the user wants to use the ones in the user relay list. Skip ephemeral relays in that case.
// When the caller specifies specific relays, do not skip ephemeral relays to respect the exact list given by the caller.
let shouldSkipEphemeralRelays = to == nil ? true : false
await send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to, skip_ephemeral: shouldSkipEphemeralRelays)
}
}
/// Subscribes to data from the `RelayPool` based on a filter and a list of desired relays.
@@ -203,52 +497,104 @@ class RelayPool {
/// - Parameters:
/// - filters: The filters specifying the desired content.
/// - desiredRelays: The desired relays which to subsctibe to. If `nil`, it defaults to the `RelayPool`'s default list
/// - eoseTimeout: The maximum timeout which to give up waiting for the eoseSignal, in seconds
/// - Returns: Returns an async stream that callers can easily consume via a for-loop
func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: TimeInterval = 10) -> AsyncStream<StreamItem> {
let desiredRelays = desiredRelays ?? self.relays.map({ $0.descriptor.url })
/// - eoseTimeout: The maximum timeout which to give up waiting for the eoseSignal
/// Open a subscription for the given filters and provide a stream of matching items and EOSE notifications.
/// - Parameters:
/// - filters: The list of NostrFilter objects that define which events to receive.
/// - desiredRelays: Optional list of RelayURL to subscribe to; when `nil` the pool's relays are used.
/// - eoseTimeout: Optional timeout to wait before emitting an EOSE if not all relays have reported EOSE; defaults to 5 seconds.
/// - id: Optional UUID to use as the subscription identifier; a new UUID is generated when `nil`.
/// - Returns: An AsyncStream that yields StreamItem values representing matched events and end-of-stream (EOSE) notifications for this subscription. The stream deduplicates events by their NoteId. When the stream terminates it will unsubscribe from the chosen relays and remove the internal handler.
func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: Duration? = nil, id: UUID? = nil) async -> AsyncStream<StreamItem> {
let eoseTimeout = eoseTimeout ?? .seconds(5)
let desiredRelays = await getRelays(targetRelays: desiredRelays)
#if DEBUG
print("[RelayPool] subscribe: requested=\(desiredRelays.map { $0.descriptor.url.absoluteString }), pool has \(await relays.count) relays")
if let ids = filters.first?.ids {
print("[RelayPool] subscribe: filter ids=\(ids.map { $0.hex() })")
}
#endif
let startTime = CFAbsoluteTimeGetCurrent()
return AsyncStream<StreamItem> { continuation in
let sub_id = UUID().uuidString
let id = id ?? UUID()
let sub_id = id.uuidString
var seenEvents: Set<NoteId> = []
var relaysWhoFinishedInitialResults: Set<RelayURL> = []
var eoseSent = false
self.subscribe(sub_id: sub_id, filters: filters, handler: { (relayUrl, connectionEvent) in
switch connectionEvent {
case .ws_connection_event(let ev):
// Websocket events such as connect/disconnect/error are already handled in `RelayConnection`. Do not perform any handling here.
// For the future, perhaps we should abstract away `.ws_connection_event` in `RelayPool`? Seems like something to be handled on the `RelayConnection` layer.
break
case .nostr_event(let nostrResponse):
guard nostrResponse.subid == sub_id else { return } // Do not stream items that do not belong in this subscription
switch nostrResponse {
case .event(_, let nostrEvent):
if seenEvents.contains(nostrEvent.id) { break } // Don't send two of the same events.
continuation.yield(with: .success(.event(nostrEvent)))
seenEvents.insert(nostrEvent.id)
case .notice(let note):
break // We do not support handling these yet
case .eose(_):
relaysWhoFinishedInitialResults.insert(relayUrl)
if relaysWhoFinishedInitialResults == Set(desiredRelays) {
continuation.yield(with: .success(.eose))
eoseSent = true
let upstreamStream = AsyncStream<(RelayURL, NostrConnectionEvent)> { upstreamContinuation in
self.subscribe(sub_id: sub_id, filters: filters, handler: upstreamContinuation, to: desiredRelays.map({ $0.descriptor.url }))
}
let upstreamStreamingTask = Task {
for await (relayUrl, connectionEvent) in upstreamStream {
try Task.checkCancellation()
switch connectionEvent {
case .ws_connection_event(let ev):
// Websocket events such as connect/disconnect/error are already handled in `RelayConnection`. Do not perform any handling here.
// For the future, perhaps we should abstract away `.ws_connection_event` in `RelayPool`? Seems like something to be handled on the `RelayConnection` layer.
break
case .nostr_event(let nostrResponse):
guard nostrResponse.subid == sub_id else { return } // Do not stream items that do not belong in this subscription
switch nostrResponse {
case .event(_, let nostrEvent):
if seenEvents.contains(nostrEvent.id) { break } // Don't send two of the same events.
continuation.yield(with: .success(.event(nostrEvent)))
seenEvents.insert(nostrEvent.id)
case .notice(let note):
break // We do not support handling these yet
case .eose(_):
relaysWhoFinishedInitialResults.insert(relayUrl)
let desiredAndConnectedRelays = desiredRelays.filter({ $0.connection.isConnected }).map({ $0.descriptor.url })
Log.debug("RelayPool subscription %s: EOSE from %s. EOSE count: %d/%d. Elapsed: %.2f seconds.", for: .networking, id.uuidString, relayUrl.absoluteString, relaysWhoFinishedInitialResults.count, Set(desiredAndConnectedRelays).count, CFAbsoluteTimeGetCurrent() - startTime)
if relaysWhoFinishedInitialResults == Set(desiredAndConnectedRelays) {
continuation.yield(with: .success(.eose))
eoseSent = true
}
case .ok(_): break // No need to handle this, we are not sending an event to the relay
case .auth(_): break // Handled in a separate function in RelayPool
case .negentropyError(subscriptionId: _, reasonCodeString: _): break // Not handled in regular subscriptions
case .negentropyMessage(subscriptionId: _, hexEncodedData: _): break // Not handled in regular subscriptions
}
case .ok(_): break // No need to handle this, we are not sending an event to the relay
case .auth(_): break // Handled in a separate function in RelayPool
}
}
}, to: desiredRelays)
Task {
try? await Task.sleep(nanoseconds: 1_000_000_000 * UInt64(eoseTimeout))
}
let timeoutTask = Task {
try? await Task.sleep(for: eoseTimeout)
if !eoseSent { continuation.yield(with: .success(.eose)) }
}
continuation.onTermination = { @Sendable _ in
self.unsubscribe(sub_id: sub_id, to: desiredRelays)
self.remove_handler(sub_id: sub_id)
continuation.onTermination = { @Sendable termination in
switch termination {
case .finished:
Log.debug("RelayPool subscription %s finished. Closing.", for: .networking, sub_id)
case .cancelled:
Log.debug("RelayPool subscription %s cancelled. Closing.", for: .networking, sub_id)
@unknown default:
break
}
Task {
await self.unsubscribe(sub_id: sub_id, to: desiredRelays.map({ $0.descriptor.url }))
await self.remove_handler(sub_id: sub_id)
}
timeoutTask.cancel()
upstreamStreamingTask.cancel()
}
}
}
/// This streams events that are pre-existing on the relay, and stops streaming as soon as it receives the EOSE signal.
func subscribeExistingItems(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: Duration? = nil, id: UUID? = nil) -> AsyncStream<NostrEvent> {
return AsyncStream<NostrEvent>.with(task: { continuation in
outerLoop: for await item in await self.subscribe(filters: filters, to: desiredRelays, eoseTimeout: eoseTimeout, id: id) {
if Task.isCancelled { return }
switch item {
case .event(let event):
continuation.yield(event)
case .eose:
break outerLoop
}
}
})
}
enum StreamItem {
/// A Nostr event
case event(NostrEvent)
@@ -256,9 +602,12 @@ class RelayPool {
case eose
}
func subscribe_to(sub_id: String, filters: [NostrFilter], to: [RelayURL]?, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) {
register_handler(sub_id: sub_id, handler: handler)
send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
func subscribe_to(sub_id: String, filters: [NostrFilter], to: [RelayURL]?, handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation) {
Task {
await register_handler(sub_id: sub_id, filters: filters, to: to, handler: handler)
await send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
}
}
func count_queued(relay: RelayURL) -> Int {
@@ -271,7 +620,7 @@ class RelayPool {
return c
}
func queue_req(r: NostrRequestType, relay: RelayURL, skip_ephemeral: Bool) {
let count = count_queued(relay: relay)
guard count <= 10 else {
@@ -288,15 +637,22 @@ class RelayPool {
switch req {
case .typical(let r):
if case .event = r, let rstr = make_nostr_req(r) {
let _ = ndb.process_client_event(rstr)
let _ = ndb?.process_client_event(rstr)
}
case .custom(let string):
let _ = ndb.process_client_event(string)
let _ = ndb?.process_client_event(string)
}
}
func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) {
let relays = to.map{ get_relays($0) } ?? self.relays
/// Dispatches a Nostr request to the pool's matching relays, writing a local copy to the NostrDB and queuing the request for any relay that is not currently connected.
///
/// Filters target relays by their read/write capabilities and, optionally, by ephemeral status; connected relays receive the request immediately and disconnected relays have the request queued for later delivery. Sent messages are reported via `message_sent_function` when available.
/// - Parameters:
/// - req: The Nostr request to send.
/// - to: Optional list of relay URLs to restrict delivery to; `nil` targets the pool's default set of relays.
/// - skip_ephemeral: If `true`, skip ephemeral relays when sending the request.
func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) async {
let relays = await getRelays(targetRelays: to)
self.send_raw_to_local_ndb(req) // Always send Nostr events and data to NostrDB for a local copy
@@ -314,25 +670,32 @@ class RelayPool {
}
guard relay.connection.isConnected else {
queue_req(r: req, relay: relay.id, skip_ephemeral: skip_ephemeral)
Task { await queue_req(r: req, relay: relay.id, skip_ephemeral: skip_ephemeral) }
continue
}
relay.connection.send(req, callback: { str in
#if DEBUG
if relay.descriptor.ephemeral && str.hasPrefix("[\"REQ\"") {
print("[RelayPool] Sending REQ to ephemeral relay \(relay.id.absoluteString): \(str)")
}
#endif
self.message_sent_function?((str, relay))
})
}
}
func send(_ req: NostrRequest, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) {
send_raw(.typical(req), to: to, skip_ephemeral: skip_ephemeral)
func send(_ req: NostrRequest, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) async {
await send_raw(.typical(req), to: to, skip_ephemeral: skip_ephemeral)
}
@MainActor
func get_relays(_ ids: [RelayURL]) -> [Relay] {
// don't include ephemeral relays in the default list to query
relays.filter { ids.contains($0.id) }
}
@MainActor
func get_relay(_ id: RelayURL) -> Relay? {
relays.first(where: { $0.id == id })
}
@@ -345,7 +708,7 @@ class RelayPool {
}
print("running queueing request: \(req.req) for \(relay_id)")
self.send_raw(req.req, to: [relay_id], skip_ephemeral: false)
Task { await self.send_raw(req.req, to: [relay_id], skip_ephemeral: false) }
}
}
@@ -361,27 +724,48 @@ class RelayPool {
}
}
}
func resubscribeAll(relayId: RelayURL) async {
for handler in self.handlers {
guard let filters = handler.filters else { continue }
// When the caller specifies no relays, it is implied that the user wants to use the ones in the user relay list. Skip ephemeral relays in that case.
// When the caller specifies specific relays, do not skip ephemeral relays to respect the exact list given by the caller.
let shouldSkipEphemeralRelays = handler.to == nil ? true : false
if let handlerTargetRelays = handler.to,
!handlerTargetRelays.contains(where: { $0 == relayId }) {
// Not part of the target relays, skip
continue
}
Log.debug("%s: Sending resubscribe request to %s", for: .networking, handler.sub_id, relayId.absoluteString)
await send(.subscribe(.init(filters: filters, sub_id: handler.sub_id)), to: [relayId], skip_ephemeral: shouldSkipEphemeralRelays)
}
}
func handle_event(relay_id: RelayURL, event: NostrConnectionEvent) {
func handle_event(relay_id: RelayURL, event: NostrConnectionEvent) async {
record_seen(relay_id: relay_id, event: event)
// run req queue when we reconnect
// When we reconnect, do two things
// - Send messages that were stored in the queue
// - Re-subscribe to filters we had subscribed before
if case .ws_connection_event(let ws) = event {
if case .connected = ws {
run_queue(relay_id)
await self.resubscribeAll(relayId: relay_id)
}
}
// Handle auth
if case let .nostr_event(nostrResponse) = event,
case let .auth(challenge_string) = nostrResponse {
if let relay = get_relay(relay_id) {
if let relay = await get_relay(relay_id) {
print("received auth request from \(relay.descriptor.url.id)")
relay.authentication_state = .pending
if let keypair {
if let fullKeypair = keypair.to_full() {
if let authRequest = make_auth_request(keypair: fullKeypair, challenge_string: challenge_string, relay: relay) {
send(.auth(authRequest), to: [relay_id], skip_ephemeral: false)
await send(.auth(authRequest), to: [relay_id], skip_ephemeral: false)
relay.authentication_state = .verified
} else {
print("failed to make auth request")
@@ -400,13 +784,135 @@ class RelayPool {
}
for handler in handlers {
handler.callback(relay_id, event)
// We send data to the handlers if:
// - the subscription ID matches, or
// - the handler filters is `nil`, which is used in some cases as a blanket "give me all notes" (e.g. during signup)
guard handler.sub_id == event.subId || handler.filters == nil else { continue }
logStreamPipelineStats("RelayPool_\(relay_id.absoluteString)", "RelayPool_Handler_\(handler.sub_id)")
handler.handler.yield((relay_id, event))
}
}
// MARK: - Negentropy
/// This streams items in the following fashion:
/// 1. Performs a negentropy sync, sending missing notes to the stream
/// 2. Send EOSE to signal end of syncing
/// 3. Stream new notes
func negentropySubscribe(
filters: [NostrFilter],
to desiredRelayURLs: [RelayURL]? = nil,
negentropyVector: NegentropyStorageVector,
eoseTimeout: Duration? = nil,
id: UUID? = nil,
ignoreUnsupportedRelays: Bool
) async throws -> AsyncThrowingStream<StreamItem, any Error> {
return AsyncThrowingStream<StreamItem, any Error>.with(task: { continuation in
// 1. Mark the time when we begin negentropy syncing
let negentropyStartTimestamp = UInt32(Date().timeIntervalSince1970)
// 2. Negentropy sync missing notes and send the missing notes over
for try await event in try await self.negentropySync(filters: filters, to: desiredRelayURLs, negentropyVector: negentropyVector, ignoreUnsupportedRelays: ignoreUnsupportedRelays) {
continuation.yield(.event(event))
}
// 3. When syncing is done, send the EOSE signal
continuation.yield(.eose)
// 3. Stream new notes that match the filter
let updatedFilters = filters.map({ filter in
var newFilter = filter
newFilter.since = negentropyStartTimestamp
return newFilter
})
for await item in await self.subscribe(filters: updatedFilters, to: desiredRelayURLs, eoseTimeout: eoseTimeout, id: id) {
try Task.checkCancellation()
switch item {
case .event(let nostrEvent):
continuation.yield(.event(nostrEvent))
case .eose:
continue // We already sent the EOSE signal after negentropy sync, ignore this redundant EOSE
}
}
})
}
/// This performs a negentropy syncing with various relays and various filters and sends missing notes over an async stream
func negentropySync(
filters: [NostrFilter],
to desiredRelayURLs: [RelayURL]? = nil,
negentropyVector: NegentropyStorageVector,
eoseTimeout: Duration? = nil,
ignoreUnsupportedRelays: Bool
) async throws -> AsyncThrowingStream<NostrEvent, any Error> {
return AsyncThrowingStream<NostrEvent, any Error>.with(task: { continuation in
for filter in filters {
try Task.checkCancellation()
for try await event in try await self.negentropySync(filter: filter, to: desiredRelayURLs, negentropyVector: negentropyVector, eoseTimeout: eoseTimeout, ignoreUnsupportedRelays: ignoreUnsupportedRelays) {
try Task.checkCancellation()
continuation.yield(event)
// Note: Negentropy vector already updated by the underlying stream, since it is a reference type
try Task.checkCancellation()
}
}
})
}
/// This performs a negentropy syncing with various relays and sends missing notes over an async stream
func negentropySync(
filter: NostrFilter,
to desiredRelayURLs: [RelayURL]? = nil,
negentropyVector: NegentropyStorageVector,
eoseTimeout: Duration? = nil,
ignoreUnsupportedRelays: Bool
) async throws -> AsyncThrowingStream<NostrEvent, any Error> {
return AsyncThrowingStream<NostrEvent, any Error>.with(task: { continuation in
let desiredRelays = await self.getRelays(targetRelays: desiredRelayURLs)
for desiredRelay in desiredRelays {
try Task.checkCancellation()
do {
for try await event in try await self.negentropySync(filter: filter, to: desiredRelay, negentropyVector: negentropyVector, eoseTimeout: eoseTimeout) {
try Task.checkCancellation()
continuation.yield(event)
// Add to our negentropy vector so that we don't need to receive it from the next relay!
negentropyVector.unseal()
try negentropyVector.insert(nostrEvent: event)
try Task.checkCancellation()
}
}
catch {
if ignoreUnsupportedRelays {
// Do not throw error, ignore the relays that do not support negentropy
// Note: Some relays such as wss://nos.lol/v2 advertise negentropy but throw an error such as `["NOTICE","ERROR: bad msg: negentropy disabled"]`
// Therefore, realistically, we cannot rely on what the relay advertises and
// we have to suppress those errors if we want to ignore unsupported relays to avoid the whole multi-relay negentropy syncing operation to fail
Log.error("Error while negentropy streaming: %s", for: .networking, error.localizedDescription)
}
else {
throw error
}
}
}
})
}
/// This performs a negentropy syncing with one relay and sends missing notes over an async stream
func negentropySync(filter: NostrFilter, to desiredRelay: Relay, negentropyVector: NegentropyStorageVector, eoseTimeout: Duration? = nil) async throws -> AsyncThrowingStream<NostrEvent, any Error> {
return AsyncThrowingStream<NostrEvent, any Error>.with(task: { streamContinuation in
let missingIds = try await desiredRelay.connection.getMissingIds(filter: filter, negentropyVector: negentropyVector, timeout: eoseTimeout)
let missingIdsFilter = NostrFilter(ids: missingIds.map { NoteId($0.toData()) })
for await event in self.subscribeExistingItems(filters: [missingIdsFilter], to: [desiredRelay.descriptor.url], eoseTimeout: eoseTimeout) {
streamContinuation.yield(event)
}
})
}
}
func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) {
try? pool.add_relay(RelayPool.RelayDescriptor(url: url, info: .readWrite))
func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) async {
try? await pool.add_relay(RelayPool.RelayDescriptor(url: url, info: .readWrite))
}
extension RelayPool {
protocol Delegate {
func latestRelayListChanged(_ newEvent: NdbNote)
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
import Foundation
public struct RelayURL: Hashable, Equatable, Codable, CodingKeyRepresentable, Identifiable, Comparable, CustomStringConvertible {
public struct RelayURL: Hashable, Equatable, Codable, CodingKeyRepresentable, Identifiable, Comparable, CustomStringConvertible, Sendable {
private(set) var url: URL
public var id: URL {
+29 -8
View File
@@ -9,12 +9,13 @@ import Foundation
import LinkPresentation
import EmojiPicker
class DamusState: HeadlessDamusState {
class DamusState: HeadlessDamusState, ObservableObject {
let keypair: Keypair
let likes: EventCounter
let boosts: EventCounter
let quote_reposts: EventCounter
let contacts: Contacts
let contactCards: ContactCard
let mutelist_manager: MutelistManager
let profiles: Profiles
let dms: DirectMessagesModel
@@ -38,12 +39,14 @@ class DamusState: HeadlessDamusState {
let emoji_provider: EmojiProvider
let favicon_cache: FaviconCache
private(set) var nostrNetwork: NostrNetworkManager
var snapshotManager: DatabaseSnapshotManager
init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider, favicon_cache: FaviconCache) {
init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, contactCards: ContactCard, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider, favicon_cache: FaviconCache, addNdbToRelayPool: Bool = true) {
self.keypair = keypair
self.likes = likes
self.boosts = boosts
self.contacts = contacts
self.contactCards = contactCards
self.mutelist_manager = mutelist_manager
self.profiles = profiles
self.dms = dms
@@ -72,13 +75,16 @@ class DamusState: HeadlessDamusState {
self.favicon_cache = FaviconCache()
let networkManagerDelegate = NostrNetworkManagerDelegate(settings: settings, contacts: contacts, ndb: ndb, keypair: keypair, relayModelCache: relay_model_cache, relayFilters: relay_filters)
self.nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate)
let nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate, addNdbToRelayPool: addNdbToRelayPool)
self.nostrNetwork = nostrNetwork
self.wallet.nostrNetwork = nostrNetwork
self.snapshotManager = .init(ndb: ndb)
}
@MainActor
convenience init?(keypair: Keypair) {
convenience init?(keypair: Keypair, owns_db_file: Bool) {
// nostrdb
var mndb = Ndb()
var mndb = Ndb(owns_db_file: owns_db_file)
if mndb == nil {
// try recovery
print("DB ISSUE! RECOVERING")
@@ -109,6 +115,7 @@ class DamusState: HeadlessDamusState {
likes: EventCounter(our_pubkey: pubkey),
boosts: EventCounter(our_pubkey: pubkey),
contacts: Contacts(our_pubkey: pubkey),
contactCards: ContactCardManager(),
mutelist_manager: MutelistManager(user_keypair: keypair),
profiles: Profiles(ndb: ndb),
dms: home.dms,
@@ -122,7 +129,7 @@ class DamusState: HeadlessDamusState {
events: EventCache(ndb: ndb),
bookmarks: BookmarksManager(pubkey: pubkey),
replies: ReplyCounter(our_pubkey: pubkey),
wallet: WalletModel(settings: settings),
wallet: WalletModel(settings: settings), // nostrNetwork is connected after initialization
nav: navigationCoordinator,
music: MusicController(onChange: { _ in }),
video: DamusVideoCoordinator(),
@@ -158,16 +165,27 @@ class DamusState: HeadlessDamusState {
keypair.privkey != nil
}
/// Returns the Damus client tag array if the user has enabled client tag publishing, nil otherwise.
var clientTagComponents: [String]? {
guard settings.publish_client_tag else {
return nil
}
return ClientTagMetadata.damus.tagValues
}
func close() {
print("txn: damus close")
Task {
try await self.push_notification_client.revoke_token()
}
wallet.disconnect()
nostrNetwork.pool.close()
ndb.close()
Task {
await nostrNetwork.close() // Close ndb streaming tasks before closing ndb to avoid memory errors
ndb.close()
}
}
@MainActor
static var empty: DamusState {
let empty_pub: Pubkey = .empty
let empty_sec: Privkey = .empty
@@ -178,6 +196,7 @@ class DamusState: HeadlessDamusState {
likes: EventCounter(our_pubkey: empty_pub),
boosts: EventCounter(our_pubkey: empty_pub),
contacts: Contacts(our_pubkey: empty_pub),
contactCards: ContactCardManagerMock(),
mutelist_manager: MutelistManager(user_keypair: kp),
profiles: Profiles(ndb: .empty),
dms: DirectMessagesModel(our_pubkey: empty_pub),
@@ -216,9 +235,11 @@ fileprivate extension DamusState {
set { self.settings.latestRelayListEventIdHex = newValue }
}
@MainActor
var latestContactListEvent: NostrEvent? { self.contacts.event }
var bootstrapRelays: [RelayURL] { get_default_bootstrap_relays() }
var developerMode: Bool { self.settings.developer_mode }
var experimentalLocalRelayModelSupport: Bool { self.settings.enable_experimental_local_relay_model }
var relayModelCache: RelayModelCache
var relayFilters: RelayFilters
@@ -0,0 +1,307 @@
//
// DatabaseSnapshotManager.swift
// damus
//
// Created on 2025-01-20.
//
import Foundation
import OSLog
/// Manages periodic snapshots of the main NostrDB database to a shared container location.
///
/// This allows app extensions (like notification service extensions) to access a recent
/// read-only copy of the database for enhanced UX, while the main database resides in
/// the private container to avoid 0xdead10cc crashes and issues related to holding file locks on shared containers.
///
/// Snapshots are created periodically while the app is in the foreground, since the database
/// only gets updated when the app is active.
actor DatabaseSnapshotManager {
/// Minimum interval between snapshots (in seconds)
private static let minimumSnapshotInterval: TimeInterval = 60 * 60 // 1 hour
/// Key for storing last snapshot timestamp in UserDefaults
private static let lastSnapshotDateKey = "lastDatabaseSnapshotDate"
private let ndb: Ndb
private var snapshotTimerTask: Task<Void, Never>? = nil
var snapshotTimerTickCount: Int = 0
var snapshotCount: Int = 0
/// Initialize the snapshot manager with a NostrDB instance
/// - Parameter ndb: The NostrDB instance to snapshot
init(ndb: Ndb) {
self.ndb = ndb
}
// MARK: - Periodic tasks management
/// Start the periodic snapshot timer.
///
/// This should be called when the app enters the foreground.
/// The timer will fire periodically to check if a snapshot is needed.
func startPeriodicSnapshots() {
// Don't start if already running
guard snapshotTimerTask == nil else {
Log.debug("Snapshot timer already running", for: .storage)
return
}
Log.info("Starting periodic database snapshot timer", for: .storage)
snapshotTimerTask = Task(priority: .utility) { [weak self] in
while !Task.isCancelled {
guard let self else { return }
Log.debug("Snapshot timer - tick", for: .storage)
await self.increaseSnapshotTimerTickCount()
do {
try await self.createSnapshotIfNeeded()
}
catch {
Log.error("Failed to create snapshot: %{public}@", for: .storage, error.localizedDescription)
}
try? await Task.sleep(for: .seconds(60 * 5), tolerance: .seconds(10))
}
}
}
/// Stop the periodic snapshot timer.
///
/// This should be called when the app enters the background.
func stopPeriodicSnapshots() async {
guard snapshotTimerTask != nil else {
return
}
Log.info("Stopping periodic database snapshot timer", for: .storage)
snapshotTimerTask?.cancel()
await snapshotTimerTask?.value
snapshotTimerTask = nil
}
// MARK: - Snapshotting
/// Perform a database snapshot if needed.
///
/// This method checks if enough time has passed since the last snapshot and creates a new one if necessary.
@discardableResult
func createSnapshotIfNeeded() async throws -> Bool {
guard shouldCreateSnapshot() else {
Log.debug("Skipping snapshot - minimum interval not yet elapsed", for: .storage)
return false
}
try await self.performSnapshot()
return true
}
/// Check if a snapshot should be created based on the last snapshot time.
private func shouldCreateSnapshot() -> Bool {
guard let lastSnapshotDate = UserDefaults.standard.object(forKey: Self.lastSnapshotDateKey) as? Date else {
return true // No snapshot has been created yet
}
let timeSinceLastSnapshot = Date().timeIntervalSince(lastSnapshotDate)
return timeSinceLastSnapshot >= Self.minimumSnapshotInterval
}
/// Perform the actual snapshot operation.
///
/// Creates a storage-efficient snapshot by creating a new temporary Ndb instance
/// and selectively copying only the necessary notes (profiles, mute lists, contact lists).
func performSnapshot() async throws {
guard let snapshotPath = Ndb.snapshot_db_path else {
throw SnapshotError.pathsUnavailable
}
Log.info("Starting nostrdb snapshot to %{public}@", for: .storage, snapshotPath)
try await createSelectiveSnapshot(to: snapshotPath)
// Update the last snapshot date
UserDefaults.standard.set(Date(), forKey: Self.lastSnapshotDateKey)
Log.info("Database snapshot completed successfully", for: .storage)
self.snapshotCount += 1
}
/// Creates a selective snapshot containing only profiles, mute lists, and contact lists.
///
/// This method:
/// 1. Creates a temporary Ndb instance in a temp directory
/// 2. Queries the source database for relevant notes
/// 3. Writes each note to the temporary database
/// 4. Atomically moves the temporary database to the final destination
private func createSelectiveSnapshot(to snapshotPath: String) async throws {
let fileManager = FileManager.default
// Create a temporary directory for the snapshot
let tempDir = FileManager.default.temporaryDirectory
let tempSnapshotPath = tempDir.appendingPathComponent("snapshot_temp_\(UUID().uuidString)")
do {
try fileManager.createDirectory(atPath: tempSnapshotPath.path, withIntermediateDirectories: true)
} catch {
throw SnapshotError.directoryCreationFailed(error)
}
// Ensure cleanup on error
defer {
try? fileManager.removeItem(atPath: tempSnapshotPath.path)
}
Log.debug("Created temporary snapshot directory at %{public}@", for: .storage, tempSnapshotPath.path)
// Create a new Ndb instance in the temporary directory
guard let snapshotNdb = Ndb(path: tempSnapshotPath.path, owns_db_file: true) else {
throw SnapshotError.failedToCreateSnapshotDatabase
}
defer {
snapshotNdb.close()
}
Log.debug("Created temporary Ndb instance for snapshot", for: .storage)
// Query and copy notes to snapshot database
try await copyNotesToSnapshot(snapshotNdb: snapshotNdb)
Log.debug("Copied notes to snapshot database", for: .storage)
// Close the snapshot database before moving files
snapshotNdb.close()
// Atomically move the temporary database to the final destination
try await moveSnapshotToFinalDestination(from: tempSnapshotPath.path, to: snapshotPath)
Log.debug("Moved snapshot to final destination", for: .storage)
}
/// Queries the source database and copies relevant notes to the snapshot database.
private func copyNotesToSnapshot(snapshotNdb: Ndb) async throws {
let filters = try createSnapshotFilters()
Log.debug("Querying source database with %d filters", for: .storage, filters.count)
var totalNotesCopied = 0
for filter in filters {
let noteKeys = try ndb.query(filters: [filter], maxResults: 100_000)
Log.debug("Found %d notes for filter", for: .storage, noteKeys.count)
for noteKey in noteKeys {
// Get the note from source database and copy to snapshot
try ndb.lookup_note_by_key(noteKey, borrow: { unownedNote in
// Convert the note to owned, encode to JSON, and process into snapshot database
guard let ownedNote = unownedNote?.toOwned() else {
Log.error("Failed to get unowned note", for: .storage)
return
}
// Process the note into the snapshot database
// Implementation note: This does not _immediately_ add the event to the new Ndb.
// It goes into the ingester queue first for later processing.
// This raises the question: How to guarantee that all notes will be saved to the new
// snapshot Ndb before we close it?
//
// The answer is that when `Ndb.close` is called, it actually waits for the ingester task
// to finish processing its queue unless the queue is full (an edge case).
try snapshotNdb.add(event: ownedNote)
totalNotesCopied += 1
})
}
}
Log.info("Copied %d notes to snapshot database", for: .storage, totalNotesCopied)
}
/// Creates filters for querying profiles, mute lists, and contact lists.
private func createSnapshotFilters() throws -> [NdbFilter] {
// Filter for profile metadata (kind 0)
let profileFilter = try NdbFilter(from: NostrFilter(kinds: [.metadata]))
// Filter for contact lists (kind 3)
let contactsFilter = try NdbFilter(from: NostrFilter(kinds: [.contacts]))
// Filter for mute lists (kind 10000)
let muteListFilter = try NdbFilter(from: NostrFilter(kinds: [.mute_list]))
return [profileFilter, contactsFilter, muteListFilter]
}
/// Atomically moves the snapshot from temporary location to final destination.
private func moveSnapshotToFinalDestination(from tempPath: String, to finalPath: String) async throws {
let fileManager = FileManager.default
// Remove existing snapshot if it exists
if fileManager.fileExists(atPath: finalPath) {
do {
try fileManager.removeItem(atPath: finalPath)
Log.debug("Removed existing snapshot at %{public}@", for: .storage, finalPath)
} catch {
throw SnapshotError.removeFailed(error)
}
}
// Create parent directory if needed
let parentDir = URL(fileURLWithPath: finalPath).deletingLastPathComponent().path
if !fileManager.fileExists(atPath: parentDir) {
do {
try fileManager.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch {
throw SnapshotError.directoryCreationFailed(error)
}
}
// Atomically move the temp snapshot to final destination
do {
try fileManager.moveItem(atPath: tempPath, toPath: finalPath)
Log.debug("Moved snapshot from %{public}@ to %{public}@", for: .storage, tempPath, finalPath)
} catch {
throw SnapshotError.moveFailed(error)
}
}
// MARK: - Stats functions
private func increaseSnapshotTimerTickCount() async {
self.snapshotTimerTickCount += 1
}
func resetStats() async {
self.snapshotTimerTickCount = 0
self.snapshotCount = 0
}
}
// MARK: - Error Types
enum SnapshotError: Error, LocalizedError {
case pathsUnavailable
case copyFailed(any Error)
case removeFailed(Error)
case directoryCreationFailed(Error)
case failedToCreateSnapshotDatabase
case moveFailed(Error)
var errorDescription: String? {
switch self {
case .pathsUnavailable:
return "Database paths are not available"
case .copyFailed(let code):
return "Failed to copy database (error code: \(code))"
case .removeFailed(let error):
return "Failed to remove existing snapshot: \(error.localizedDescription)"
case .directoryCreationFailed(let error):
return "Failed to create snapshot directory: \(error.localizedDescription)"
case .failedToCreateSnapshotDatabase:
return "Failed to create temporary snapshot database"
case .moveFailed(let error):
return "Failed to move snapshot to final destination: \(error.localizedDescription)"
}
}
}
@@ -0,0 +1,154 @@
//
// StorageStatsManager.swift
// damus
//
// Created by Daniel DAquino on 2026-02-20.
//
import Foundation
import Kingfisher
/// Storage statistics for various Damus data stores
struct StorageStats: Hashable {
/// Detailed breakdown of NostrDB storage by kind, indices, and other
let nostrdbDetails: NdbStats?
/// Size of the main NostrDB database file in bytes (total)
let nostrdbSize: UInt64
/// Size of the snapshot NostrDB database file in bytes
let snapshotSize: UInt64
/// Size of the Kingfisher image cache in bytes
let imageCacheSize: UInt64
/// Total storage used across all data stores
var totalSize: UInt64 {
return nostrdbSize + snapshotSize + imageCacheSize
}
/// Calculate the percentage of total storage used by a specific size
/// - Parameter size: The size to calculate percentage for
/// - Returns: Percentage value between 0.0 and 100.0
func percentage(for size: UInt64) -> Double {
guard totalSize > 0 else { return 0.0 }
return Double(size) / Double(totalSize) * 100.0
}
}
/// Manager for calculating storage statistics across Damus data stores
struct StorageStatsManager {
static let shared = StorageStatsManager()
private init() {}
/// Calculate storage statistics for all Damus data stores
///
/// This method runs all file operations on a background thread to avoid blocking
/// the main thread. It calculates:
/// - NostrDB database file size
/// - Detailed NostrDB breakdown (if ndb instance provided)
/// - Snapshot database file size
/// - Kingfisher image cache size
///
/// - Parameter ndb: Optional Ndb instance to get detailed storage breakdown
/// - Returns: StorageStats containing all calculated sizes
/// - Throws: Error if critical file operations fail
func calculateStorageStats(ndb: Ndb? = nil) async throws -> StorageStats {
// Run all file operations on background thread
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
let nostrdbSize = self.getNostrDBSize()
let snapshotSize = self.getSnapshotDBSize()
// Get detailed NostrDB stats if ndb instance provided
let nostrdbDetails: NdbStats? = ndb?.getStats(physicalSize: nostrdbSize)
// Kingfisher cache size requires async callback
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
let imageCacheSize: UInt64
switch result {
case .success(let size):
imageCacheSize = UInt64(size)
case .failure(let error):
Log.error("Failed to calculate Kingfisher cache size: %@", for: .storage, error.localizedDescription)
imageCacheSize = 0
}
let stats = StorageStats(
nostrdbDetails: nostrdbDetails,
nostrdbSize: nostrdbSize,
snapshotSize: snapshotSize,
imageCacheSize: imageCacheSize
)
continuation.resume(returning: stats)
}
} catch {
continuation.resume(throwing: error)
}
}
}
}
/// Get the size of the main NostrDB database file
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
private func getNostrDBSize() -> UInt64 {
guard let dbPath = Ndb.db_path else {
Log.error("Failed to get NostrDB path", for: .storage)
return 0
}
let dataFilePath = "\(dbPath)/\(Ndb.main_db_file_name)"
return getFileSize(at: dataFilePath, description: "NostrDB")
}
/// Get the size of the snapshot NostrDB database file
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
private func getSnapshotDBSize() -> UInt64 {
guard let snapshotPath = Ndb.snapshot_db_path else {
Log.error("Failed to get snapshot DB path", for: .storage)
return 0
}
let dataFilePath = "\(snapshotPath)/\(Ndb.main_db_file_name)"
return getFileSize(at: dataFilePath, description: "Snapshot DB")
}
/// Get the size of a file at the specified path
/// - Parameters:
/// - path: Full path to the file
/// - description: Human-readable description for logging
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
private func getFileSize(at path: String, description: String) -> UInt64 {
guard FileManager.default.fileExists(atPath: path) else {
Log.info("%@ file does not exist at path: %@", for: .storage, description, path)
return 0
}
do {
let attributes = try FileManager.default.attributesOfItem(atPath: path)
guard let fileSize = attributes[.size] as? UInt64 else {
Log.error("Failed to get size attribute for %@", for: .storage, description)
return 0
}
return fileSize
} catch {
Log.error("Failed to get file size for %@: %@", for: .storage, description, error.localizedDescription)
return 0
}
}
/// Format bytes into a human-readable string
/// - Parameter bytes: Number of bytes
/// - Returns: Formatted string (e.g., "45.3 MB", "1.2 GB")
static func formatBytes(_ bytes: UInt64) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useAll]
formatter.countStyle = .file
formatter.includesUnit = true
formatter.isAdaptive = true
return formatter.string(fromByteCount: Int64(bytes))
}
}
@@ -0,0 +1,169 @@
//
// StorageStatsViewHelper.swift
// damus
//
// Created by Daniel D'Aquino on 2026-02-25.
//
import Foundation
import SwiftUI
/// Shared helper functions for storage statistics views
/// Consolidates common logic between StorageSettingsView and NostrDBDetailView
enum StorageStatsViewHelper {
// MARK: - Category Ranges
/// Computes cumulative ranges for angle selection in pie charts (iOS 17+)
/// - Parameter categories: Array of storage categories
/// - Returns: Array of tuples containing category ID and cumulative range
static func computeCategoryRanges(for categories: [StorageCategory]) -> [(category: String, range: Range<Double>)] {
var total: UInt64 = 0
return categories.map { category in
let newTotal = total + category.size
let result = (category: category.id, range: Double(total)..<Double(newTotal))
total = newTotal
return result
}
}
// MARK: - Storage Stats Loading
/// Load storage statistics asynchronously
/// - Parameter ndb: The NostrDB instance
/// - Returns: Calculated storage statistics
/// - Throws: Error if storage calculation fails
@concurrent
static func loadStorageStatsAsync(ndb: Ndb) async throws -> StorageStats {
return try await StorageStatsManager.shared.calculateStorageStats(ndb: ndb)
}
// MARK: - Export Preparation
/// Prepare export text for storage statistics on background thread
/// - Parameters:
/// - stats: The storage statistics to export
/// - formatter: Closure that formats the stats into text
/// - Returns: Formatted text ready for export
@concurrent
static func prepareExportText(
stats: StorageStats,
formatter: @escaping @concurrent (StorageStats) async -> String
) async -> String {
return await formatter(stats)
}
// MARK: - Text Formatting
/// Format storage statistics as exportable text
/// - Parameter stats: The storage statistics to format
/// - Returns: Formatted text representation of storage stats
@concurrent
static func formatStorageStatsAsText(_ stats: StorageStats) async -> String {
// Build categories list
let categories = [
StorageCategory(
id: "nostrdb",
title: NSLocalizedString("NostrDB", comment: "Label for main NostrDB database"),
icon: "internaldrive.fill",
color: .blue,
size: stats.nostrdbSize
),
StorageCategory(
id: "snapshot",
title: NSLocalizedString("Snapshot Database", comment: "Label for snapshot database"),
icon: "doc.on.doc.fill",
color: .purple,
size: stats.snapshotSize
),
StorageCategory(
id: "cache",
title: NSLocalizedString("Image Cache", comment: "Label for Kingfisher image cache"),
icon: "photo.fill",
color: .orange,
size: stats.imageCacheSize
)
]
var text = "Damus Storage Statistics\n"
text += "Generated: \(Date().formatted(date: .abbreviated, time: .shortened))\n"
text += String(repeating: "=", count: 50) + "\n\n"
// Top-level Categories
text += "Storage Breakdown:\n"
text += String(repeating: "-", count: 50) + "\n"
for category in categories {
let percentage = stats.percentage(for: category.size)
let titlePadded = category.title.padding(toLength: 25, withPad: " ", startingAt: 0)
let sizePadded = StorageStatsManager.formatBytes(category.size).padding(toLength: 10, withPad: " ", startingAt: 0)
text += "\(titlePadded) \(sizePadded) (\(String(format: "%.1f", percentage))%)\n"
}
text += String(repeating: "-", count: 50) + "\n"
let totalTitlePadded = "Total Storage".padding(toLength: 25, withPad: " ", startingAt: 0)
let totalSizePadded = StorageStatsManager.formatBytes(stats.totalSize).padding(toLength: 10, withPad: " ", startingAt: 0)
text += "\(totalTitlePadded) \(totalSizePadded)\n\n"
// Add NostrDB detailed breakdown if available
if let details = stats.nostrdbDetails {
text += await formatNostrDBDetails(details: details)
}
return text
}
/// Format NostrDB statistics as exportable text
/// - Parameter stats: The storage statistics containing NostrDB details
/// - Returns: Formatted text representation of NostrDB stats breakdown
@concurrent
static func formatNostrDBStatsAsText(_ stats: StorageStats) async -> String {
guard let details = stats.nostrdbDetails else {
return "NostrDB details not available"
}
var text = "Damus NostrDB Detailed Statistics\n"
text += "Generated: \(Date().formatted(date: .abbreviated, time: .shortened))\n"
text += String(repeating: "=", count: 50) + "\n\n"
text += await formatNostrDBDetails(details: details)
return text
}
// MARK: - Private Helpers
/// Format NostrDB details section
/// - Parameter details: The NostrDB statistics details
/// - Returns: Formatted text representation of NostrDB details
@concurrent
private static func formatNostrDBDetails(details: NdbStats) async -> String {
var text = String(repeating: "=", count: 50) + "\n\n"
text += "NostrDB Detailed Breakdown:\n"
text += String(repeating: "-", count: 50) + "\n"
// Per-database breakdown (sorted by size, already done in getStats)
if !details.databaseStats.isEmpty {
text += "\nDatabases:\n"
for dbStat in details.databaseStats {
let percentage = details.totalSize > 0 ? Double(dbStat.totalSize) / Double(details.totalSize) * 100.0 : 0.0
let dbNamePadded = dbStat.database.displayName.padding(toLength: 30, withPad: " ", startingAt: 0)
let sizePadded = StorageStatsManager.formatBytes(dbStat.totalSize).padding(toLength: 12, withPad: " ", startingAt: 0)
text += "\(dbNamePadded) \(sizePadded) (\(String(format: "%.1f", percentage))%)\n"
// Only show keys/values breakdown if both exist
if dbStat.keySize > 0 && dbStat.valueSize > 0 {
text += " Keys: \(StorageStatsManager.formatBytes(dbStat.keySize)), Values: \(StorageStatsManager.formatBytes(dbStat.valueSize))\n"
}
}
}
text += "\n" + String(repeating: "-", count: 50) + "\n"
let nostrdbTitlePadded = "NostrDB Total".padding(toLength: 30, withPad: " ", startingAt: 0)
let nostrdbSizePadded = StorageStatsManager.formatBytes(details.totalSize).padding(toLength: 12, withPad: " ", startingAt: 0)
text += "\(nostrdbTitlePadded) \(nostrdbSizePadded)\n"
return text
}
}
+7 -14
View File
@@ -79,8 +79,7 @@ extension Block {
guard let url = URL(string: block.as_str()) else { return nil }
self = .url(url)
case BLOCK_INVOICE:
guard let b = Block(invoice: block.block.invoice) else { return nil }
self = b
self = Block(invoice: block.block.invoice)
case BLOCK_MENTION_BECH32:
guard let b = Block(bech32: block.block.mention_bech32) else { return nil }
self = b
@@ -113,26 +112,20 @@ fileprivate extension Block {
}
fileprivate extension Block {
/// Failable initializer for the C-backed type `invoice_block_t`.
init?(invoice: ndb_invoice_block) {
guard let invoice = invoice_block_as_invoice(invoice) else { return nil }
self = .invoice(invoice)
/// Initializer for the C-backed type `invoice_block_t`.
init(invoice: ndb_invoice_block) {
self = .invoice(invoice_block_as_invoice(invoice))
}
}
func invoice_block_as_invoice(_ invoice: ndb_invoice_block) -> Invoice? {
/// Converts a C-backed invoice block to a Swift Invoice.
func invoice_block_as_invoice(_ invoice: ndb_invoice_block) -> Invoice {
let invstr = invoice.invstr.as_str()
let b11 = invoice.invoice
guard let description = convert_invoice_description(b11: b11) else {
return nil
}
let description = convert_invoice_description(b11: b11)
let amount: Amount = b11.amount == 0 ? .any : .specific(Int64(b11.amount))
return Invoice(description: description, amount: amount, string: invstr, expiry: b11.expiry, created_at: b11.timestamp)
}
fileprivate extension Block {
+18 -2
View File
@@ -27,19 +27,23 @@ enum Marker: String {
}
}
/// A reference to a note event, with optional relay hint, marker, and author pubkey.
/// Per NIP-10: `["e", <event-id>, <relay-url>, <marker>, <pubkey>]`
struct NoteRef: IdType, TagConvertible, Equatable {
let note_id: NoteId
let relay: String?
let marker: Marker?
let pubkey: Pubkey?
var id: Data {
self.note_id.id
}
init(note_id: NoteId, relay: String? = nil, marker: Marker? = nil) {
init(note_id: NoteId, relay: String? = nil, marker: Marker? = nil, pubkey: Pubkey? = nil) {
self.note_id = note_id
self.relay = relay
self.marker = marker
self.pubkey = pubkey
}
static func note_id(_ note_id: NoteId) -> NoteRef {
@@ -50,19 +54,26 @@ struct NoteRef: IdType, TagConvertible, Equatable {
self.note_id = NoteId(data)
self.relay = nil
self.marker = nil
self.pubkey = nil
}
/// Generates a tag array per NIP-10: `["e", <event-id>, <relay-url>, <marker>, <pubkey>]`
var tag: [String] {
var t = ["e", self.hex()]
if let marker {
t.append(relay ?? "")
t.append(marker.rawValue)
if let pubkey {
t.append(pubkey.hex())
}
} else if let relay {
t.append(relay)
}
return t
}
/// Parses a NoteRef from a tag per NIP-10: `["e", <event-id>, <relay-url>, <marker>, <pubkey>]`
/// Only parses pubkey from position 4 when a valid marker is present in position 3.
static func from_tag(tag: TagSequence) -> NoteRef? {
guard tag.count >= 2 else { return nil }
@@ -78,14 +89,19 @@ struct NoteRef: IdType, TagConvertible, Equatable {
var relay: String? = nil
var marker: Marker? = nil
var pubkey: Pubkey? = nil
if tag.count >= 3, let r = i.next() {
relay = r.string()
if tag.count >= 4, let m = i.next() {
marker = Marker(m)
// Only parse pubkey when marker is recognized per NIP-10
if marker != nil, tag.count >= 5, let pk = i.next(), let pubkeyData = pk.id() {
pubkey = Pubkey(pubkeyData)
}
}
}
return NoteRef(note_id: note_id, relay: relay, marker: marker)
return NoteRef(note_id: note_id, relay: relay, marker: marker, pubkey: pubkey)
}
}
@@ -46,7 +46,8 @@ class ActionBarModel: ObservableObject {
self.relays = relays
}
func update(damus: DamusState, evid: NoteId) {
@MainActor
func update(damus: DamusState, evid: NoteId) async {
self.likes = damus.likes.counts[evid] ?? 0
self.boosts = damus.boosts.counts[evid] ?? 0
self.zaps = damus.zaps.event_counts[evid] ?? 0
@@ -58,7 +59,7 @@ class ActionBarModel: ObservableObject {
self.our_zap = damus.zaps.our_zaps[evid]?.first
self.our_reply = damus.replies.our_reply(evid)
self.our_quote_repost = damus.quote_reposts.our_events[evid]
self.relays = (damus.nostrNetwork.pool.seen[evid] ?? []).count
self.relays = (await damus.nostrNetwork.relayURLsThatSawNote(id: evid) ?? []).count
self.objectWillChange.send()
}
@@ -36,10 +36,15 @@ struct EventActionBar: View {
self.swipe_context = swipe_context
}
var lnurl: String? {
damus_state.profiles.lookup_with_timestamp(event.pubkey)?.map({ pr in
pr?.lnurl
}).value
@State var lnurl: String? = nil
// Fetching an LNURL is expensive enough that it can cause a hitch. Use a special backgroundable function to fetch the value.
// Fetch on `.onAppear`
nonisolated func fetchLNURL() {
let lnurl = try? damus_state.profiles.lookup_lnurl(event.pubkey)
DispatchQueue.main.async {
self.lnurl = lnurl
}
}
var show_like: Bool {
@@ -82,8 +87,10 @@ struct EventActionBar: View {
var like_swipe_button: some View {
SwipeAction(image: "shaka", backgroundColor: DamusColors.adaptableGrey) {
send_like(emoji: damus_state.settings.default_emoji_reaction)
self.swipe_context?.state.wrappedValue = .closed
Task {
await send_like(emoji: damus_state.settings.default_emoji_reaction)
self.swipe_context?.state.wrappedValue = .closed
}
}
.swipeButtonStyle()
.accessibilityLabel(NSLocalizedString("React with default reaction emoji", comment: "Accessibility label for react button"))
@@ -131,7 +138,7 @@ struct EventActionBar: View {
if bar.liked {
//notify(.delete, bar.our_like)
} else {
send_like(emoji: emoji)
Task { await send_like(emoji: emoji) }
}
}
@@ -176,32 +183,42 @@ struct EventActionBar: View {
let should_hide_repost = hide_items_without_activity && bar.boosts == 0
let should_hide_reactions = hide_items_without_activity && bar.likes == 0
let zap_model = self.damus_state.events.get_cache_data(self.event.id).zaps_model
let should_hide_zap = hide_items_without_activity && zap_model.zap_total > 0
let should_hide_zap = hide_items_without_activity && zap_model.zap_total == 0
let should_hide_share_button = hide_items_without_activity
// Only render the bar if at least one action is visible; avoids empty overlays/dots.
let has_any_action = (!should_hide_chat_bubble && damus_state.keypair.privkey != nil)
|| !should_hide_repost
|| (show_like && !should_hide_reactions)
|| (!should_hide_zap && self.lnurl != nil)
|| !should_hide_share_button
return HStack(spacing: options.contains(.no_spread) ? 10 : 0) {
if damus_state.keypair.privkey != nil && !should_hide_chat_bubble {
self.reply_button
}
if !should_hide_repost {
self.space_if_spread
self.repost_button
}
if show_like && !should_hide_reactions {
self.space_if_spread
self.like_button
}
if let lnurl = self.lnurl, !should_hide_zap {
self.space_if_spread
NoteZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: zap_model)
}
if !should_hide_share_button {
self.space_if_spread
self.share_button
return Group {
if has_any_action {
HStack(spacing: options.contains(.no_spread) ? 10 : 0) {
if damus_state.keypair.privkey != nil && !should_hide_chat_bubble {
self.reply_button
}
if !should_hide_repost {
self.space_if_spread
self.repost_button
}
if show_like && !should_hide_reactions {
self.space_if_spread
self.like_button
}
if let lnurl = self.lnurl, !should_hide_zap {
self.space_if_spread
NoteZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: zap_model)
}
if !should_hide_share_button {
self.space_if_spread
self.share_button
}
}
}
}
}
@@ -218,8 +235,15 @@ struct EventActionBar: View {
}
}
var event_relay_url_strings: [RelayURL] {
let relays = damus_state.nostrNetwork.relaysForEvent(event: event)
@State var event_relay_url_strings: [RelayURL] = []
func updateEventRelayURLStrings() async {
let newValue = await fetchEventRelayURLStrings()
self.event_relay_url_strings = newValue
}
func fetchEventRelayURLStrings() async -> [RelayURL] {
let relays = await damus_state.nostrNetwork.relaysForEvent(event: event)
if !relays.isEmpty {
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0 }
}
@@ -230,7 +254,11 @@ struct EventActionBar: View {
var body: some View {
self.content
.onAppear {
self.bar.update(damus: damus_state, evid: self.event.id)
Task.detached(priority: .background, operation: {
await self.bar.update(damus: damus_state, evid: self.event.id)
self.fetchLNURL()
await self.updateEventRelayURLStrings()
})
}
.sheet(isPresented: $show_share_action, onDismiss: { self.show_share_action = false }) {
if #available(iOS 16.0, *) {
@@ -258,7 +286,10 @@ struct EventActionBar: View {
}
.onReceive(handle_notify(.update_stats)) { target in
guard target == self.event.id else { return }
self.bar.update(damus: self.damus_state, evid: target)
Task {
await self.bar.update(damus: self.damus_state, evid: target)
await self.updateEventRelayURLStrings()
}
}
.onReceive(handle_notify(.liked)) { liked in
if liked.id != event.id {
@@ -271,9 +302,9 @@ struct EventActionBar: View {
}
}
func send_like(emoji: String) {
func send_like(emoji: String) async {
guard let keypair = damus_state.keypair.to_full(),
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
let like_ev = await make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
return
}
@@ -281,7 +312,7 @@ struct EventActionBar: View {
generator.impactOccurred()
damus_state.nostrNetwork.postbox.send(like_ev)
await damus_state.nostrNetwork.postbox.send(like_ev)
}
// MARK: Helper structures
@@ -13,6 +13,7 @@ struct EventDetailBar: View {
let target_pk: Pubkey
@ObservedObject var bar: ActionBarModel
@State var relays: [RelayURL] = []
init(state: DamusState, target: NoteId, target_pk: Pubkey) {
self.state = state
@@ -61,7 +62,6 @@ struct EventDetailBar: View {
}
if bar.relays > 0 {
let relays = Array(state.nostrNetwork.pool.seen[target] ?? [])
NavigationLink(value: Route.UserRelays(relays: relays)) {
let nounString = pluralizedString(key: "relays_count", count: bar.relays)
let noun = Text(nounString).foregroundColor(.gray)
@@ -70,6 +70,18 @@ struct EventDetailBar: View {
.buttonStyle(PlainButtonStyle())
}
}
.onAppear {
Task { await self.updateSeenRelays() }
}
.onReceive(handle_notify(.update_stats)) { noteId in
guard noteId == target else { return }
Task { await self.updateSeenRelays() }
}
}
func updateSeenRelays() async {
let relays = await Array(state.nostrNetwork.relayURLsThatSawNote(id: target) ?? [])
self.relays = relays
}
}
@@ -27,8 +27,15 @@ struct ShareAction: View {
self._show_share = show_share
}
var event_relay_url_strings: [RelayURL] {
let relays = userProfile.damus.nostrNetwork.relaysForEvent(event: event)
@State var event_relay_url_strings: [RelayURL] = []
func updateEventRelayURLStrings() async {
let newValue = await fetchEventRelayURLStrings()
self.event_relay_url_strings = newValue
}
func fetchEventRelayURLStrings() async -> [RelayURL] {
let relays = await userProfile.damus.nostrNetwork.relaysForEvent(event: event)
if !relays.isEmpty {
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0 }
}
@@ -80,8 +87,13 @@ struct ShareAction: View {
}
}
}
.onReceive(handle_notify(.update_stats), perform: { noteId in
guard noteId == event.id else { return }
Task { await self.updateEventRelayURLStrings() }
})
.onAppear() {
userProfile.subscribeToFindRelays()
Task { await self.updateEventRelayURLStrings() }
}
.onDisappear() {
userProfile.unsubscribeFindRelays()
@@ -57,13 +57,13 @@ struct ReportView: View {
.padding()
}
func do_send_report() {
func do_send_report() async {
guard let selected_report_type,
let ev = NostrEvent(content: report_message, keypair: keypair.to_keypair(), kind: 1984, tags: target.reportTags(type: selected_report_type)) else {
return
}
postbox.send(ev)
await postbox.send(ev)
report_sent = true
report_id = bech32_note_id(ev.id)
@@ -116,7 +116,7 @@ struct ReportView: View {
Section(content: {
Button(send_report_button_text) {
do_send_report()
Task { await do_send_report() }
}
.disabled(selected_report_type == nil)
}, footer: {
@@ -19,13 +19,15 @@ struct RepostAction: View {
Button {
dismiss()
guard let keypair = self.damus_state.keypair.to_full(),
let boost = make_boost_event(keypair: keypair, boosted: self.event, relayURL: damus_state.nostrNetwork.relaysForEvent(event: self.event).first) else {
return
Task {
guard let keypair = self.damus_state.keypair.to_full(),
let boost = await make_boost_event(keypair: keypair, boosted: self.event, relayURL: damus_state.nostrNetwork.relaysForEvent(event: self.event).first) else {
return
}
await damus_state.nostrNetwork.postbox.send(boost)
}
damus_state.nostrNetwork.postbox.send(boost)
} label: {
Label(NSLocalizedString("Repost", comment: "Button to repost a note"), image: "repost")
.frame(maxWidth: .infinity, minHeight: 50, maxHeight: 50, alignment: .leading)
@@ -27,7 +27,7 @@ struct Reposted: View {
// Show profile picture of the reposter only if the reposter is not the author of the reposted note.
if pubkey != target.pubkey {
ProfilePicView(pubkey: pubkey, size: eventview_pfp_size(.small), highlight: .none, profiles: damus.profiles, disable_animation: damus.settings.disable_animation)
ProfilePicView(pubkey: pubkey, size: eventview_pfp_size(.small), highlight: .none, profiles: damus.profiles, disable_animation: damus.settings.disable_animation, damusState: damus)
.onTapGesture {
show_profile_action_sheet_if_enabled(damus_state: damus, pubkey: pubkey)
}
@@ -38,7 +38,7 @@ struct Reposted: View {
}
NavigationLink(value: Route.Reposts(reposts: .reposts(state: damus, target: target.id))) {
Text(people_reposted_text(profiles: damus.profiles, pubkey: pubkey, reposts: reposts))
Text(verbatim: people_reposted_text(profiles: damus.profiles, pubkey: pubkey, reposts: reposts))
.font(.subheadline)
.foregroundColor(.gray)
}
+35 -24
View File
@@ -36,9 +36,24 @@ struct ChatEventView: View {
@State var selected_emoji: Emoji?
@State private var isOnTopHalfOfScreen: Bool = false
@ObservedObject var bar: ActionBarModel
@StateObject private var bar: ActionBarModel
@Environment(\.swipeViewGroupSelection) var swipeViewGroupSelection
init(event: NostrEvent, selected_event: NostrEvent, prev_ev: NostrEvent?, next_ev: NostrEvent?, damus_state: DamusState, thread: ThreadModel, scroll_to_event: ((_ id: NoteId) -> Void)?, focus_event: (() -> Void)?, highlight_bubble: Bool) {
self.event = event
self.selected_event = selected_event
self.prev_ev = prev_ev
self.next_ev = next_ev
self.damus_state = damus_state
self.thread = thread
self.scroll_to_event = scroll_to_event
self.focus_event = focus_event
self.highlight_bubble = highlight_bubble
// Initialize @StateObject using wrappedValue
_bar = StateObject(wrappedValue: make_actionbar_model(ev: event.id, damus: damus_state))
}
enum PopoverState: String {
case closed
case open_emoji_selector
@@ -83,7 +98,7 @@ struct ChatEventView: View {
var profile_picture_view: some View {
VStack {
ProfilePicView(pubkey: event.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation)
ProfilePicView(pubkey: event.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation, damusState: damus_state)
.onTapGesture {
show_profile_action_sheet_if_enabled(damus_state: damus_state, pubkey: event.pubkey)
}
@@ -100,9 +115,7 @@ struct ChatEventView: View {
// MARK: Zapping properties
var lnurl: String? {
damus_state.profiles.lookup_with_timestamp(event.pubkey)?.map({ pr in
pr?.lnurl
}).value
try? damus_state.profiles.lookup_lnurl(event.pubkey)
}
var zap_target: ZapTarget {
ZapTarget.note(id: event.id, author: event.pubkey)
@@ -130,22 +143,22 @@ struct ChatEventView: View {
}
}
if let replying_to = event.direct_replies(),
replying_to != selected_event.id {
ReplyQuoteView(keypair: damus_state.keypair, quoter: event, event_id: replying_to, state: damus_state, thread: thread, options: reply_quote_options)
if let reply_ref = event.direct_reply_ref(),
reply_ref.note_id != selected_event.id {
ReplyQuoteView(keypair: damus_state.keypair, quoter: event, event_id: reply_ref.note_id, state: damus_state, thread: thread, options: reply_quote_options, relayHint: reply_ref.relay)
.background(is_ours ? DamusColors.adaptablePurpleBackground2 : DamusColors.adaptableGrey2)
.foregroundColor(is_ours ? Color.damusAdaptablePurpleForeground : Color.damusAdaptableBlack)
.cornerRadius(5)
.onTapGesture {
self.scroll_to_event?(replying_to)
self.scroll_to_event?(reply_ref.note_id)
}
}
let blur_images = should_blur_images(settings: damus_state.settings, contacts: damus_state.contacts, ev: event, our_pubkey: damus_state.pubkey)
NoteContentView(damus_state: damus_state, event: event, blur_images: blur_images, size: .normal, options: [.truncate_content])
.padding(2)
if let mention = first_eref_mention(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
MentionView(damus_state: damus_state, mention: mention)
if let mention = first_eref_mention_with_hints(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
MentionView(damus_state: damus_state, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
.background(DamusColors.adaptableWhite)
.clipShape(RoundedRectangle(cornerSize: CGSize(width: 10, height: 10)))
}
@@ -197,8 +210,10 @@ struct ChatEventView: View {
}
.onChange(of: selected_emoji) { newSelectedEmoji in
if let newSelectedEmoji {
send_like(emoji: newSelectedEmoji.value)
popover_state = .closed
Task {
await send_like(emoji: newSelectedEmoji.value)
popover_state = .closed
}
}
}
}
@@ -233,9 +248,9 @@ struct ChatEventView: View {
)
}
func send_like(emoji: String) {
func send_like(emoji: String) async {
guard let keypair = damus_state.keypair.to_full(),
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: await damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
return
}
@@ -244,7 +259,7 @@ struct ChatEventView: View {
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.impactOccurred()
damus_state.nostrNetwork.postbox.send(like_ev)
await damus_state.nostrNetwork.postbox.send(like_ev)
}
var action_bar: some View {
@@ -338,21 +353,17 @@ struct ChatEventView: View {
}
#Preview {
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false)
}
#Preview {
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
return ChatEventView(event: test_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
return ChatEventView(event: test_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false)
}
#Preview {
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
return ChatEventView(event: test_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: true, bar: bar)
return ChatEventView(event: test_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: true)
}
#Preview {
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
return ChatEventView(event: test_super_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
return ChatEventView(event: test_super_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false)
}
+164 -5
View File
@@ -23,9 +23,79 @@ struct ChatroomThreadView: View {
@State var showStickyHeader: Bool = false
@State var untrustedSectionOffset: CGFloat = 0
// Add state for reading progress (longform articles)
@State private var readingProgress: CGFloat = 0
@State private var viewportHeight: CGFloat = 0
@State private var contentTopY: CGFloat = 0
@State private var contentBottomY: CGFloat = 0
@State private var initialTopY: CGFloat? = nil
// Focus mode: auto-hide chrome (nav bar + tab bar) during longform reading
@State private var chromeHidden: Bool = false
@State private var lastScrollY: CGFloat = 0
/// Minimum scroll distance before triggering chrome hide/show
private let scrollThreshold: CGFloat = 15
private static let untrusted_network_section_id = "untrusted-network-section"
private static let sticky_header_adjusted_anchor = UnitPoint(x: UnitPoint.top.x, y: 0.2)
/// Returns true if the selected event is a longform article (kind 30023).
var isLongformEvent: Bool {
thread.selected_event.kind == 30023
}
/// Updates reading progress based on scroll position.
private func updateReadingProgress() {
guard thread.selected_event.kind == 30023 else { return }
guard viewportHeight > 0 else { return }
// Capture initial position on first update
if initialTopY == nil {
initialTopY = contentTopY
}
guard let startY = initialTopY else { return }
// Content height is constant (bottom - top in global coords)
let contentHeight = contentBottomY - contentTopY
guard contentHeight > 0 else { return }
// How much we've scrolled from initial position
// As we scroll down, contentTopY decreases, so scrolled = startY - currentTopY
let scrolled = startY - contentTopY
let maxScroll = max(contentHeight - viewportHeight, 1)
let progress = scrolled / maxScroll
readingProgress = min(max(progress, 0), 1)
}
/// Updates chrome visibility based on scroll direction (longform only).
/// Scrolling down hides chrome; tap to restore (scroll up does not restore).
private func updateChromeVisibility(newY: CGFloat) {
guard isLongformEvent else { return }
let delta = newY - lastScrollY
// Only hide chrome on scroll down, don't restore on scroll up (use tap instead)
if delta < -scrollThreshold && !chromeHidden {
withAnimation(.easeInOut(duration: 0.25)) {
chromeHidden = true
}
notify(.display_tabbar(false))
}
// Always update lastScrollY to prevent stale delta accumulation
lastScrollY = newY
}
/// Shows chrome (nav bar + tab bar) - called on tap or when leaving view.
private func showChrome() {
guard chromeHidden else { return }
withAnimation(.easeInOut(duration: 0.25)) {
chromeHidden = false
}
notify(.display_tabbar(true))
}
func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) {
let adjustedAnchor: UnitPoint = showStickyHeader ? ChatroomThreadView.sticky_header_adjusted_anchor : .top
@@ -46,7 +116,14 @@ struct ChatroomThreadView: View {
}
func trusted_event_filter(_ event: NostrEvent) -> Bool {
!damus.settings.show_trusted_replies_first || damus.contacts.is_in_friendosphere(event.pubkey)
// Always trust our own replies; otherwise gate by trusted network when the setting is enabled
if event.pubkey == damus.pubkey {
return true
}
if !damus.settings.show_trusted_replies_first {
return true
}
return damus.contacts.is_in_friendosphere(event.pubkey)
}
func ThreadedSwipeViewGroup(scroller: ScrollViewProxy, events: [NostrEvent]) -> some View {
@@ -64,8 +141,7 @@ struct ChatroomThreadView: View {
focus_event: {
self.set_active_event(scroller: scroller, ev: ev)
},
highlight_bubble: highlighted_note_id == ev.id,
bar: make_actionbar_model(ev: ev.id, damus: damus)
highlight_bubble: highlighted_note_id == ev.id
)
.id(ev.id)
.matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center)
@@ -109,6 +185,22 @@ struct ChatroomThreadView: View {
ZStack(alignment: .top) {
ScrollView(.vertical) {
VStack(spacing: 0) {
// Top scroll position tracker
GeometryReader { geo in
Color.clear
.onChange(of: geo.frame(in: .global).minY) { newY in
contentTopY = newY
updateReadingProgress()
updateChromeVisibility(newY: newY)
}
.onAppear {
contentTopY = geo.frame(in: .global).minY
lastScrollY = geo.frame(in: .global).minY
}
}
.frame(height: 1)
LazyVStack(alignment: .leading, spacing: 8) {
// MARK: - Parents events view
ForEach(thread.parent_events, id: \.id) { parent_event in
@@ -156,7 +248,8 @@ struct ChatroomThreadView: View {
ThreadedSwipeViewGroup(scroller: scroller, events: trusted_events)
}
}
.padding(.top)
// Remove top padding for longform articles with sepia to eliminate gap
.padding(.top, isLongformEvent && damus.settings.longform_sepia_mode ? 0 : nil)
// MARK: - Children view - outside trusted network
if !untrusted_events.isEmpty {
@@ -215,11 +308,41 @@ struct ChatroomThreadView: View {
}
}
// Bottom scroll position tracker - placed before EndBlock so we measure article content, not padding
GeometryReader { geo in
Color.clear
.onChange(of: geo.frame(in: .global).minY) { newY in
contentBottomY = newY
updateReadingProgress()
}
.onAppear {
contentBottomY = geo.frame(in: .global).minY
}
}
.frame(height: 1)
EndBlock()
HStack {}
.frame(height: tabHeight + getSafeAreaBottom())
} // End VStack wrapper
}
.background(
GeometryReader { geo in
Color.clear
.onAppear {
viewportHeight = geo.size.height
}
.onChange(of: geo.size.height) { newHeight in
// Reset baseline on significant height change (orientation, text size)
if abs(newHeight - viewportHeight) > 50 {
initialTopY = nil
}
viewportHeight = newHeight
updateReadingProgress()
}
}
)
if showStickyHeader && !untrusted_events.isEmpty {
VStack {
@@ -234,6 +357,15 @@ struct ChatroomThreadView: View {
.transition(.move(edge: .top).combined(with: .opacity))
.zIndex(1)
}
// Reading progress bar - show for longform articles
if thread.selected_event.kind == 30023 {
VStack(spacing: 0) {
ReadingProgressBar(progress: readingProgress)
Spacer()
}
.zIndex(100)
}
}
.onReceive(handle_notify(.post), perform: { notify in
switch notify {
@@ -251,11 +383,37 @@ struct ChatroomThreadView: View {
}
.onAppear() {
thread.subscribe()
scroll_to_event(scroller: scroller, id: thread.selected_event.id, delay: 0.1, animate: false)
// Use .top anchor for longform articles so they open at the title,
// keep .bottom for regular notes to preserve parent context visibility
let anchor: UnitPoint = thread.selected_event.known_kind == .longform ? .top : .bottom
scroll_to_event(scroller: scroller, id: thread.selected_event.id, delay: 0.1, animate: false, anchor: anchor)
// Ensure chrome is visible when view appears (handles interrupted transitions)
if isLongformEvent {
chromeHidden = false
notify(.display_tabbar(true))
}
}
.onChange(of: thread.selected_event.id) { _ in
// Reset reading progress when switching to a different event
initialTopY = nil
readingProgress = 0
// Restore chrome when switching events (user tapped to select)
showChrome()
}
.onDisappear() {
thread.unsubscribe()
showChrome() // Restore chrome when leaving view
}
.navigationBarHidden(chromeHidden && isLongformEvent)
// Tap anywhere to show chrome when hidden (doesn't block other gestures)
.simultaneousGesture(
TapGesture()
.onEnded { _ in
if isLongformEvent && chromeHidden {
showChrome()
}
}
)
}
}
}
@@ -279,3 +437,4 @@ struct ChatroomView_Previews: PreviewProvider {
}
}
}
+29 -45
View File
@@ -56,12 +56,7 @@ class ThreadModel: ObservableObject {
/// The damus state, needed to access the relay pool and load the thread events
let damus_state: DamusState
private let profiles_subid = UUID().description
private let base_subid = UUID().description
private let meta_subid = UUID().description
private var subids: [String] {
return [profiles_subid, base_subid, meta_subid]
}
private var listener: Task<Void, Never>?
// MARK: Initialization
@@ -86,17 +81,6 @@ class ThreadModel: ObservableObject {
// MARK: Relay pool subscription management
/// Unsubscribe from events in the relay pool. Call this when unloading the view
func unsubscribe() {
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: base_subid)
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: meta_subid)
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: profiles_subid)
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid)
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: meta_subid)
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid)
Log.info("unsubscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
}
/// Subscribe to events in this thread. Call this when loading the view.
func subscribe() {
var meta_events = NostrFilter()
@@ -127,10 +111,19 @@ class ThreadModel: ObservableObject {
let base_filters = [event_filter, ref_events]
let meta_filters = [meta_events, quote_events]
Log.info("subscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event)
damus_state.nostrNetwork.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event)
self.listener?.cancel()
self.listener = Task {
Log.info("subscribing to thread %s ", for: .render, original_event.id.hex())
for await event in damus_state.nostrNetwork.reader.streamIndefinitely(filters: base_filters + meta_filters) {
event.justUseACopy({ handle_event(ev: $0) })
}
}
}
func unsubscribe() {
self.listener?.cancel()
self.listener = nil
}
/// Adds an event to this thread.
@@ -175,34 +168,25 @@ class ThreadModel: ObservableObject {
///
/// Marked as private because it is this class' responsibility to load events, not the view's. Simplify the interface
@MainActor
private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
let (sub_id, done) = handle_subid_event(pool: damus_state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sid, ev in
guard subids.contains(sid) else {
return
private func handle_event(ev: NostrEvent) {
if ev.known_kind == .zap {
process_zap_event(state: damus_state, ev: ev) { zap in
}
if ev.known_kind == .zap {
process_zap_event(state: damus_state, ev: ev) { zap in
}
} else if ev.is_textlike {
// handle thread quote reposts, we just count them instead of
// adding them to the thread
if let target = ev.is_quote_repost, target == self.selected_event.id {
//let _ = self.damus_state.quote_reposts.add_event(ev, target: target)
} else {
self.add_event(ev, keypair: damus_state.keypair)
}
} else if ev.is_textlike {
// handle thread quote reposts, we just count them instead of
// adding them to the thread
if let target = ev.is_quote_repost, target == self.selected_event.id {
//let _ = self.damus_state.quote_reposts.add_event(ev, target: target)
} else {
self.add_event(ev, keypair: damus_state.keypair)
}
}
guard done, let sub_id, subids.contains(sub_id) else {
return
else if ev.known_kind == .boost {
damus_state.boosts.add_event(ev, target: original_event.id)
}
if sub_id == self.base_subid {
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
load_profiles(context: "thread", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(Array(event_map.events)), damus_state: damus_state, txn: txn)
else if ev.known_kind == .like {
damus_state.likes.add_event(ev, target: original_event.id)
}
}
+34 -3
View File
@@ -7,6 +7,9 @@
import SwiftUI
/// Displays a compact preview of the event being replied to.
///
/// Supports NIP-10 relay hints to fetch events from relays not in the user's pool.
struct ReplyQuoteView: View {
let keypair: Keypair
let quoter: NostrEvent
@@ -14,13 +17,30 @@ struct ReplyQuoteView: View {
let state: DamusState
@ObservedObject var thread: ThreadModel
let options: EventViewOptions
let relayHint: String?
init(keypair: Keypair, quoter: NostrEvent, event_id: NoteId, state: DamusState, thread: ThreadModel, options: EventViewOptions, relayHint: String? = nil) {
self.keypair = keypair
self.quoter = quoter
self.event_id = event_id
self.state = state
self.thread = thread
self.options = options
self.relayHint = relayHint
}
@State var can_show_event = true
func update_should_show_event(event: NdbNote) async {
self.can_show_event = await should_show_event(event: event, damus_state: state)
}
func content(event: NdbNote) -> some View {
ZStack(alignment: .leading) {
VStack(alignment: .leading) {
HStack(alignment: .center) {
if should_show_event(event: event, damus_state: state) {
ProfilePicView(pubkey: event.pubkey, size: 14, highlight: .reply, profiles: state.profiles, disable_animation: false)
if can_show_event {
ProfilePicView(pubkey: event.pubkey, size: 14, highlight: .reply, profiles: state.profiles, disable_animation: false, damusState: state)
let blur_images = should_blur_images(settings: state.settings, contacts: state.contacts, ev: event, our_pubkey: state.pubkey)
NoteContentView(damus_state: state, event: event, blur_images: blur_images, size: .small, options: options)
.font(.callout)
@@ -56,6 +76,17 @@ struct ReplyQuoteView: View {
Group {
if let event = state.events.lookup(event_id) {
self.content(event: event)
.onAppear {
Task { await self.update_should_show_event(event: event) }
}
} else if let relayHint, let relayURL = RelayURL(relayHint) {
// Event not in cache - try to fetch using relay hint
EventLoaderView(damus_state: state, event_id: event_id, relayHints: [relayURL]) { loaded_event in
self.content(event: loaded_event)
.onAppear {
Task { await self.update_should_show_event(event: loaded_event) }
}
}
}
}
}
@@ -0,0 +1,128 @@
import Foundation
import SwiftUI
/// Manages user's favorites using NIP-81 contact cards
class ContactCardManager: ContactCard {
private(set) var favorites: Set<Pubkey> = []
private var latestContactCardEvents: [Pubkey: NostrEvent] = [:]
public static let FAVORITE_TAG = "favorite"
public static let CONTACT_SET = "n"
public static let TARGET_PUBLIC_KEY = "d"
public init() {}
func isFavorite(_ pubkey: Pubkey) -> Bool {
favorites.contains(pubkey)
}
func toggleFavorite(_ pubkey: Pubkey, postbox: PostBox, keyPair: FullKeypair?) {
if favorites.contains(pubkey) {
favorites.remove(pubkey)
handleFavorite(target: pubkey, favorite: false, postbox: postbox, keypair: keyPair)
} else {
favorites.insert(pubkey)
handleFavorite(target: pubkey, favorite: true, postbox: postbox, keypair: keyPair)
}
}
func loadEvent(_ ev: NostrEvent, pubkey: Pubkey) {
guard let kind = ev.known_kind, kind == .contact_card else {
return
}
// we only care about our contact cards
guard ev.pubkey == pubkey else {
return
}
var targetPubkey: Pubkey?
var isFavorite = false
for tag in ev.tags {
guard tag.count >= 2 else { continue }
let tagType = tag[0].string()
let tagValue = tag[1].string()
if tagType == Self.TARGET_PUBLIC_KEY {
targetPubkey = Pubkey(hex: tagValue)
} else if tagType == Self.CONTACT_SET && tagValue == Self.FAVORITE_TAG {
isFavorite = true
}
}
guard let targetPubkey else {
return
}
// Only process if this event is new
if let existingEvent = latestContactCardEvents[targetPubkey] {
guard ev.created_at > existingEvent.created_at else {
return
}
}
if isFavorite {
favorites.insert(targetPubkey)
} else {
favorites.remove(targetPubkey)
}
latestContactCardEvents[targetPubkey] = ev
notify(.favoriteUpdated())
}
var filter: (NostrEvent) -> Bool {
{ [weak self] ev in
guard let self else { return false }
return self.isFavorite(ev.pubkey)
}
}
private func createFavoriteContactCard(keypair: FullKeypair, target: Pubkey) -> NostrEvent? {
let kind = NostrKind.contact_card.rawValue
let tags = [
[Self.TARGET_PUBLIC_KEY, target.hex()],
[Self.CONTACT_SET, Self.FAVORITE_TAG]
]
return NostrEvent(content: "", keypair: keypair.to_keypair(), kind: kind, tags: tags)
}
private func createUnfavoriteContactCard(keypair: FullKeypair, target: Pubkey) -> NostrEvent? {
let kind = NostrKind.contact_card.rawValue
let tags = [
[Self.TARGET_PUBLIC_KEY, target.hex()]
]
return NostrEvent(content: "", keypair: keypair.to_keypair(), kind: kind, tags: tags)
}
private func handleFavorite(target: Pubkey, favorite: Bool, postbox: PostBox, keypair: FullKeypair?) {
guard let keypair else {
return
}
let ev: NostrEvent?
if favorite {
ev = createFavoriteContactCard(keypair: keypair, target: target)
} else {
ev = createUnfavoriteContactCard(keypair: keypair, target: target)
}
guard let ev else {
return
}
if favorite {
favorites.insert(target)
} else {
favorites.remove(target)
}
Task { await postbox.send(ev) }
latestContactCardEvents[target] = ev
notify(.favoriteUpdated())
}
}
protocol ContactCard {
func isFavorite(_ pubkey: Pubkey) -> Bool
func toggleFavorite(_ pubkey: Pubkey, postbox: PostBox, keyPair: FullKeypair?)
func loadEvent(_ ev: NostrEvent, pubkey: Pubkey)
var filter: (NostrEvent) -> Bool { get }
var favorites: Set<Pubkey> { get }
}
@@ -0,0 +1,26 @@
import Foundation
class ContactCardManagerMock: ContactCard {
var event: NostrEvent?
var favorites: Set<Pubkey> = []
func isFavorite(_ pubkey: Pubkey) -> Bool {
favorites.contains(pubkey)
}
func toggleFavorite(_ pubkey: Pubkey, postbox: PostBox, keyPair: FullKeypair?) {
if favorites.contains(pubkey) {
favorites.remove(pubkey)
} else {
favorites.insert(pubkey)
}
}
func loadEvent(_ ev: NostrEvent, pubkey: Pubkey) {
event = ev
}
var filter: ((_ ev: NostrEvent) -> Bool) {
{ ev in self.favorites.contains(ev.pubkey) }
}
}
@@ -0,0 +1,40 @@
import SwiftUI
struct FavoriteButtonView: View {
let pubkey: Pubkey
let damus_state: DamusState
@State private var favorite: Bool
init(pubkey: Pubkey, damus_state: DamusState) {
self.pubkey = pubkey
self.damus_state = damus_state
self._favorite = State(initialValue: damus_state.contactCards.isFavorite(pubkey))
}
var body: some View {
Button(
action: {
damus_state.contactCards.toggleFavorite(
pubkey,
postbox: damus_state.nostrNetwork.postbox,
keyPair: damus_state.keypair.to_full()
)
favorite.toggle()
}) {
Image(favorite ? "heart.fill" : "heart")
.foregroundColor(favorite ? DamusColors.purple : .primary)
.font(.system(size: 16, weight: .medium))
}
.buttonStyle(PlainButtonStyle())
}
}
struct FavoriteButtonView_Previews: PreviewProvider {
static var previews: some View {
FavoriteButtonView(
pubkey: test_pubkey,
damus_state: test_damus_state
)
}
}
+4 -4
View File
@@ -63,7 +63,7 @@ struct DMChatView: View, KeyboardReadable {
var Header: some View {
return NavigationLink(value: Route.ProfileByKey(pubkey: pubkey)) {
HStack {
ProfilePicView(pubkey: pubkey, size: 24, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
ProfilePicView(pubkey: pubkey, size: 24, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation, damusState: damus_state)
ProfileName(pubkey: pubkey, damus: damus_state)
}
@@ -108,7 +108,7 @@ struct DMChatView: View, KeyboardReadable {
Button(
role: .none,
action: {
send_message()
Task { await send_message() }
}
) {
Label("", image: "send")
@@ -124,7 +124,7 @@ struct DMChatView: View, KeyboardReadable {
*/
}
func send_message() {
func send_message() async {
let tags = [["p", pubkey.hex()]]
guard let post_blocks = parse_post_blocks(content: dms.draft)?.blocks else {
return
@@ -138,7 +138,7 @@ struct DMChatView: View, KeyboardReadable {
dms.draft = ""
damus_state.nostrNetwork.postbox.send(dm)
await damus_state.nostrNetwork.postbox.send(dm)
handle_incoming_dm(ev: dm, our_pubkey: damus_state.pubkey, dms: damus_state.dms, prev_events: NewEventsBits())
+2 -2
View File
@@ -17,8 +17,8 @@ struct DMView: View {
var Mention: some View {
Group {
if let mention = first_eref_mention(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
BuilderEventView(damus: damus_state, event_id: mention.ref)
if let mention = first_eref_mention_with_hints(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
BuilderEventView(damus: damus_state, event_id: mention.noteId, relayHints: mention.relayHints)
} else {
EmptyView()
}
@@ -15,7 +15,8 @@ enum DMType: Hashable {
struct DirectMessagesView: View {
let damus_state: DamusState
let home: HomeModel
@State var dm_type: DMType = .friend
@ObservedObject var model: DirectMessagesModel
@ObservedObject var settings: UserSettingsStore
@@ -37,6 +38,12 @@ struct DirectMessagesView: View {
}
.padding(.horizontal)
}
.refreshable {
// Fetch full DM history without the `since` optimization.
// This allows users to manually sync older DMs that may have
// been missed due to the optimized network filter.
await home.fetchFullDMHistory()
}
.padding(.bottom, tabHeight)
}
@@ -122,6 +129,7 @@ struct DirectMessagesView: View {
}
}
@MainActor
func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageModel]) -> Bool {
for dm in dms {
if !FriendFilter.friends_of_friends.filter(contacts: contacts, pubkey: dm.pubkey) {
@@ -135,6 +143,6 @@ func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageMo
struct DirectMessagesView_Previews: PreviewProvider {
static var previews: some View {
let ds = test_damus_state
DirectMessagesView(damus_state: ds, model: ds.dms, settings: ds.settings, subtitle: .constant(nil))
DirectMessagesView(damus_state: ds, home: HomeModel(), model: ds.dms, settings: ds.settings, subtitle: .constant(nil))
}
}
+17 -4
View File
@@ -7,21 +7,34 @@
import SwiftUI
/// A view that displays an embedded/quoted Nostr event.
///
/// Supports NIP-01/NIP-10 relay hints to fetch events from relays not in the user's pool.
struct BuilderEventView: View {
let damus: DamusState
let event_id: NoteId
let event: NostrEvent?
let relayHints: [RelayURL]
/// Creates a builder event view with a pre-loaded event.
init(damus: DamusState, event: NostrEvent) {
self.event = event
self.damus = damus
self.event_id = event.id
self.relayHints = []
}
init(damus: DamusState, event_id: NoteId) {
/// Creates a builder event view that will load the event by ID.
///
/// - Parameters:
/// - damus: The app's shared state.
/// - event_id: The ID of the event to load.
/// - relayHints: Optional relay URLs where the event may be found (per NIP-01/NIP-10).
init(damus: DamusState, event_id: NoteId, relayHints: [RelayURL] = []) {
self.event_id = event_id
self.damus = damus
self.event = nil
self.relayHints = relayHints
}
func Event(event: NostrEvent) -> some View {
@@ -39,7 +52,7 @@ struct BuilderEventView: View {
if let event {
self.Event(event: event)
} else {
EventLoaderView(damus_state: damus, event_id: self.event_id) { loaded_event in
EventLoaderView(damus_state: damus, event_id: self.event_id, relayHints: relayHints) { loaded_event in
self.Event(event: loaded_event)
}
}
@@ -13,26 +13,36 @@ struct EventTop: View {
let event: NostrEvent
let pubkey: Pubkey
let is_anon: Bool
let size: EventViewKind
let options: EventViewOptions
init(state: DamusState, event: NostrEvent, pubkey: Pubkey, is_anon: Bool) {
init(state: DamusState, event: NostrEvent, pubkey: Pubkey, is_anon: Bool, size: EventViewKind, options: EventViewOptions) {
self.state = state
self.event = event
self.pubkey = pubkey
self.is_anon = is_anon
self.size = size
self.options = options
}
func ProfileName(is_anon: Bool) -> some View {
let pk = is_anon ? ANON_PUBKEY : self.pubkey
return EventProfileName(pubkey: pk, damus: state, size: .normal)
return EventProfileName(pubkey: pk, damus: state, size: size)
}
var body: some View {
HStack(alignment: .center, spacing: 0) {
ProfileName(is_anon: is_anon)
TimeDot()
RelativeTime(time: state.events.get_cache_data(event.id).relative_time)
RelativeTime(time: state.events.get_cache_data(event.id).relative_time, size: size, font_size: state.settings.font_size)
if let clientTag = event.clientTag {
TimeDot()
ClientTagLabel(clientTag: clientTag, size: size, font_size: state.settings.font_size)
}
Spacer()
EventMenuContext(damus: state, event: event)
if !options.contains(.no_context_menu) {
EventMenuContext(damus: state, event: event)
}
}
.lineLimit(1)
}
@@ -40,6 +50,20 @@ struct EventTop: View {
struct EventTop_Previews: PreviewProvider {
static var previews: some View {
EventTop(state: test_damus_state, event: test_note, pubkey: test_note.pubkey, is_anon: false)
EventTop(state: test_damus_state, event: test_note, pubkey: test_note.pubkey, is_anon: false, size: .normal, options: [])
}
}
/// Displays the client name that published an event (e.g., "via Damus").
struct ClientTagLabel: View {
let clientTag: ClientTagMetadata
let size: EventViewKind
let font_size: Double
var body: some View {
Text(String(format: NSLocalizedString("via %@", comment: "Label indicating which client published the event"), clientTag.name))
.font(eventviewsize_to_font(size, font_size: font_size))
.foregroundColor(.gray)
.lineLimit(1)
}
}
@@ -9,10 +9,12 @@ import SwiftUI
struct RelativeTime: View {
@ObservedObject var time: RelativeTimeModel
let size: EventViewKind
let font_size: Double
var body: some View {
Text(verbatim: "\(time.value)")
.font(.system(size: 16))
.font(eventviewsize_to_font(size, font_size: font_size))
.foregroundColor(.gray)
}
}
@@ -20,6 +22,6 @@ struct RelativeTime: View {
struct RelativeTime_Previews: PreviewProvider {
static var previews: some View {
RelativeTime(time: RelativeTimeModel())
RelativeTime(time: RelativeTimeModel(), size: .normal, font_size: 1.0)
}
}
@@ -38,14 +38,10 @@ func reply_desc(ndb: Ndb, event: NostrEvent, replying_to: NostrEvent?, locale: L
return NSLocalizedString("Replying to self", bundle: bundle, comment: "Label to indicate that the user is replying to themself.")
}
guard let profile_txn = NdbTxn(ndb: ndb) else {
return ""
}
let names: [String] = pubkeys.map { pk in
let prof = ndb.lookup_profile_with_txn(pk, txn: profile_txn)
let profile = try? ndb.lookup_profile_and_copy(pk)
return Profile.displayName(profile: prof?.profile, pubkey: pk).username.truncate(maxLength: 50)
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
}
let uniqueNames = NSOrderedSet(array: names).array as! [String]
+31 -5
View File
@@ -13,7 +13,9 @@ struct EventBody: View {
let size: EventViewKind
let should_blur_img: Bool
let options: EventViewOptions
@Environment(\.colorScheme) var colorScheme
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind, should_blur_img: Bool? = nil, options: EventViewOptions) {
self.damus_state = damus_state
self.event = event
@@ -29,11 +31,35 @@ struct EventBody: View {
var body: some View {
if event.known_kind == .longform {
LongformPreviewBody(state: damus_state, ev: event, options: options, header: true)
let isFullArticle = !options.contains(.truncate_content)
let sepiaEnabled = damus_state.settings.longform_sepia_mode
// truncated longform bodies are just the preview
if !options.contains(.truncate_content) {
note_content
if isFullArticle && sepiaEnabled {
// Wrap in single sepia container to eliminate gaps
VStack(spacing: 0) {
LongformPreviewBody(
state: damus_state,
ev: event,
options: options,
header: true,
sepiaEnabled: true
)
note_content
}
.background(DamusColors.sepiaBackground(for: colorScheme))
} else {
LongformPreviewBody(
state: damus_state,
ev: event,
options: options,
header: true,
sepiaEnabled: false
)
// truncated longform bodies are just the preview
if isFullArticle {
note_content
}
}
} else if event.known_kind == .highlight {
HighlightBodyView(state: damus_state, ev: event, options: options)
+103 -36
View File
@@ -7,74 +7,141 @@
import SwiftUI
/// This view handles the loading logic for Nostr events, so that you can easily use views that require `NostrEvent`, even if you only have a `NoteId`
/// This view handles the loading logic for Nostr events, so that you can easily use views that require `NostrEvent`, even if you only have a `NoteId`.
///
/// Supports NIP-01/NIP-10 relay hints to fetch events from relays not in the user's pool.
struct EventLoaderView<Content: View>: View {
let damus_state: DamusState
let event_id: NoteId
let relayHints: [RelayURL]
@State var event: NostrEvent?
@State var subscription_uuid: String = UUID().description
@State private var eventNotFound: Bool = false
@State private var isReloading: Bool = false
let content: (NostrEvent) -> Content
init(damus_state: DamusState, event_id: NoteId, @ViewBuilder content: @escaping (NostrEvent) -> Content) {
/// Creates an event loader view.
///
/// - Parameters:
/// - damus_state: The app's shared state.
/// - event_id: The ID of the event to load.
/// - relayHints: Optional relay URLs where the event may be found (per NIP-01/NIP-10).
/// - content: A view builder that receives the loaded event.
init(damus_state: DamusState, event_id: NoteId, relayHints: [RelayURL] = [], @ViewBuilder content: @escaping (NostrEvent) -> Content) {
self.damus_state = damus_state
self.event_id = event_id
self.relayHints = relayHints
self.content = content
let event = damus_state.events.lookup(event_id)
_event = State(initialValue: event)
}
func unsubscribe() {
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subscription_uuid)
}
func subscribe(filters: [NostrFilter]) {
damus_state.nostrNetwork.pool.register_handler(sub_id: subscription_uuid, handler: handle_event)
damus_state.nostrNetwork.pool.send(.subscribe(.init(filters: filters, sub_id: subscription_uuid)))
}
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
guard case .nostr_event(let nostr_response) = ev else {
return
/// Loads the event from nostrdb or the network using relay hints or the default relay pool.
///
/// This method attempts to fetch the event via `nostrNetwork.reader.lookup`, which first checks
/// nostrdb for a cached copy, then queries the network if not found locally. Network queries use
/// either the specified relay hints (if provided) or the user's relay pool (if no hints are provided).
/// On success, sets `event` and clears `eventNotFound`. On failure, sets `eventNotFound` to true.
///
/// Side effects:
/// - Updates `event` with the fetched event on success
/// - Updates `eventNotFound` flag based on the result
/// - Logs debug information when relay hints are used
func load() async {
let targetRelays = relayHints.isEmpty ? nil : relayHints
#if DEBUG
if let targetRelays, !targetRelays.isEmpty {
print("[relay-hints] EventLoaderView: Loading event \(event_id.hex().prefix(8))... with \(targetRelays.count) relay hint(s): \(targetRelays.map { $0.absoluteString })")
}
guard case .event(let id, let nostr_event) = nostr_response else {
return
#endif
let lender = try? await damus_state.nostrNetwork.reader.lookup(noteId: self.event_id, to: targetRelays)
if let foundEvent = lender?.justGetACopy() {
event = foundEvent
eventNotFound = false
}
guard id == subscription_uuid else {
return
else {
// Handle nil case: event was not found
eventNotFound = true
}
if event != nil {
return
#if DEBUG
if let targetRelays, !targetRelays.isEmpty {
print("[relay-hints] EventLoaderView: Event \(event_id.hex().prefix(8))... loaded: \(event != nil)")
}
event = nostr_event
unsubscribe()
#endif
}
func load() {
subscribe(filters: [
NostrFilter(ids: [self.event_id], limit: 1)
])
/// Retries loading the event and displays loading state during the operation.
///
/// This method sets the `isReloading` flag to true, calls `load()`, and resets
/// the flag when complete. It is typically triggered by user action (e.g., "Try Again" button).
///
/// Side effects:
/// - Updates `isReloading` to true during the operation
/// - Delegates to `load()`, which updates `event` and `eventNotFound`
/// - Resets `isReloading` to false after completion
func retry() async {
isReloading = true
await load()
isReloading = false
}
var body: some View {
VStack {
VStack {
if let event {
self.content(event)
} else if eventNotFound {
not_found
} else {
ProgressView().padding()
}
}
.onAppear {
.task {
guard event == nil else {
return
}
self.load()
await self.load()
}
}
var not_found: some View {
VStack(spacing: 0) {
LoadableNostrEventView.SomethingWrong(
imageSystemName: "questionmark.app",
heading: NSLocalizedString("Note not found", comment: "Heading for the event loader view in a not found error state."),
description: NSLocalizedString("This note may have been deleted, or it might not be available on the relays you're connected to.", comment: "Text for the event loader view when it is unable to find the note the user is looking for"),
advice: NSLocalizedString("Try checking your internet connection, expanding your relay list, or contacting the person who quoted this note.", comment: "Tips on what to do if a quoted note cannot be found.")
)
Button(action: {
Task {
await retry()
}
}) {
HStack {
if !isReloading {
Image(systemName: "arrow.clockwise")
Text("Try Again", comment: "Button label to retry loading a note that was not found")
}
else {
ProgressView()
Text("Retrying…", comment: "Button label for the retry-in-progress state when loading a note")
}
}
.font(.headline)
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Color.secondary)
.cornerRadius(10)
}
.disabled(isReloading)
.opacity(isReloading ? 0.6 : 1.0)
.padding(.bottom, 20)
}
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
)
}
}
+5 -5
View File
@@ -64,8 +64,8 @@ struct MenuItems: View {
self.profileModel = profileModel
}
var event_relay_url_strings: [RelayURL] {
let relays = damus_state.nostrNetwork.relaysForEvent(event: event)
func event_relay_url_strings() async -> [RelayURL] {
let relays = await damus_state.nostrNetwork.relaysForEvent(event: event)
if !relays.isEmpty {
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0 }
}
@@ -88,7 +88,7 @@ struct MenuItems: View {
}
Button {
UIPasteboard.general.string = Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))
Task { UIPasteboard.general.string = Bech32Object.encode(.nevent(NEvent(event: event, relays: await event_relay_url_strings()))) }
} label: {
Label(NSLocalizedString("Copy note ID", comment: "Context menu option for copying the ID of the note."), image: "note-book")
}
@@ -122,7 +122,7 @@ struct MenuItems: View {
if let full_keypair = self.damus_state.keypair.to_full(),
let new_mutelist_ev = toggle_from_mutelist(keypair: full_keypair, prev: damus_state.mutelist_manager.event, to_toggle: .thread(event.thread_id(), duration?.date_from_now)) {
damus_state.mutelist_manager.set_mutelist(new_mutelist_ev)
damus_state.nostrNetwork.postbox.send(new_mutelist_ev)
Task { await damus_state.nostrNetwork.postbox.send(new_mutelist_ev) }
}
let muted = damus_state.mutelist_manager.is_event_muted(event)
isMutedThread = muted
@@ -152,7 +152,7 @@ struct MenuItems: View {
profileModel.subscribeToFindRelays()
}
.onDisappear() {
profileModel.unsubscribeFindRelays()
profileModel.findRelaysListener?.cancel()
}
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ struct EventProfile: View {
var body: some View {
HStack(alignment: .center, spacing: 10) {
ProfilePicView(pubkey: pubkey, size: pfp_size, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation, show_zappability: true)
ProfilePicView(pubkey: pubkey, size: pfp_size, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation, show_zappability: true, damusState: damus_state)
.onTapGesture {
show_profile_action_sheet_if_enabled(damus_state: damus_state, pubkey: pubkey)
}
+11 -9
View File
@@ -35,12 +35,12 @@ struct EventShell<Content: View>: View {
!options.contains(.no_action_bar)
}
func get_mention(ndb: Ndb) -> Mention<NoteId>? {
func get_mention(ndb: Ndb) -> NoteMentionWithHints? {
if self.options.contains(.nested) || self.options.contains(.no_mentions) {
return nil
}
return first_eref_mention(ndb: ndb, ev: event, keypair: state.keypair)
return first_eref_mention_with_hints(ndb: ndb, ev: event, keypair: state.keypair)
}
var ActionBar: some View {
@@ -63,9 +63,11 @@ struct EventShell<Content: View>: View {
}
VStack(alignment: .leading) {
EventTop(state: state, event: event, pubkey: pubkey, is_anon: is_anon)
UserStatusView(status: state.profiles.profile_data(pubkey).status, show_general: state.settings.show_general_statuses, show_music: state.settings.show_music_statuses)
EventTop(state: state, event: event, pubkey: pubkey, is_anon: is_anon, size: options.contains(.small_text) ? .small : .normal, options: options)
if !options.contains(.no_status) {
UserStatusView(status: state.profiles.profile_data(pubkey).status, show_general: state.settings.show_general_statuses, show_music: state.settings.show_music_statuses)
}
if !options.contains(.no_replying_to) {
ReplyPart(events: state.events, event: event, keypair: state.keypair, ndb: state.ndb)
@@ -74,7 +76,7 @@ struct EventShell<Content: View>: View {
content
if let mention = get_mention(ndb: state.ndb) {
MentionView(damus_state: state, mention: mention)
MentionView(damus_state: state, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
}
if has_action_bar {
@@ -93,7 +95,7 @@ struct EventShell<Content: View>: View {
Pfp(is_anon: is_anon)
VStack(alignment: .leading, spacing: 2) {
EventTop(state: state, event: event, pubkey: pubkey, is_anon: is_anon)
EventTop(state: state, event: event, pubkey: pubkey, is_anon: is_anon, size: options.contains(.small_text) ? .small : .normal, options: options)
UserStatusView(status: state.profiles.profile_data(pubkey).status, show_general: state.settings.show_general_statuses, show_music: state.settings.show_music_statuses)
ReplyPart(events: state.events, event: event, keypair: state.keypair, ndb: state.ndb)
ProxyView(event: event)
@@ -106,7 +108,7 @@ struct EventShell<Content: View>: View {
if !options.contains(.no_mentions),
let mention = get_mention(ndb: state.ndb)
{
MentionView(damus_state: state, mention: mention)
MentionView(damus_state: state, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
.padding(.horizontal)
}
+12 -4
View File
@@ -21,12 +21,14 @@ struct EventView: View {
let options: EventViewOptions
let damus: DamusState
let pubkey: Pubkey
let highlightTerms: [String]
init(damus: DamusState, event: NostrEvent, pubkey: Pubkey? = nil, options: EventViewOptions = []) {
init(damus: DamusState, event: NostrEvent, pubkey: Pubkey? = nil, options: EventViewOptions = [], highlightTerms: [String] = []) {
self.event = event
self.options = options
self.damus = damus
self.pubkey = pubkey ?? event.pubkey
self.highlightTerms = highlightTerms
}
var body: some View {
@@ -34,6 +36,11 @@ struct EventView: View {
if event.known_kind == .boost {
if let inner_ev = event.get_inner_event(cache: damus.events) {
RepostedEvent(damus: damus, event: event, inner_ev: inner_ev, options: options)
} else if let target = event.repostTarget() {
// Inner event not in cache - load using relay hints from e tag (NIP-18)
EventLoaderView(damus_state: damus, event_id: target.noteId, relayHints: target.relayHints) { loaded_event in
RepostedEvent(damus: damus, event: event, inner_ev: loaded_event, options: options)
}
} else {
EmptyView()
}
@@ -48,7 +55,7 @@ struct EventView: View {
} else if event.known_kind == .highlight {
HighlightView(state: damus, event: event, options: options)
} else {
TextEvent(damus: damus, event: event, pubkey: pubkey, options: options)
TextEvent(damus: damus, event: event, pubkey: pubkey, options: options, highlightTerms: highlightTerms)
//.padding([.top], 6)
}
}
@@ -56,6 +63,7 @@ struct EventView: View {
}
// blame the porn bots for this code
@MainActor
func should_blur_images(settings: UserSettingsStore, contacts: Contacts, ev: NostrEvent, our_pubkey: Pubkey, booster_pubkey: Pubkey? = nil) -> Bool {
if settings.undistractMode {
return true
@@ -78,6 +86,7 @@ func should_blur_images(settings: UserSettingsStore, contacts: Contacts, ev: Nos
}
// blame the porn bots for this code too
@MainActor
func should_blur_images(damus_state: DamusState, ev: NostrEvent) -> Bool {
return should_blur_images(
settings: damus_state.settings,
@@ -106,7 +115,7 @@ func format_date(date: Date, time_style: DateFormatter.Style = .short) -> String
func make_actionbar_model(ev: NoteId, damus: DamusState) -> ActionBarModel {
let model = ActionBarModel.empty()
model.update(damus: damus, evid: ev)
Task { await model.update(damus: damus, evid: ev) }
return model
}
@@ -158,4 +167,3 @@ struct EventView_Previews: PreviewProvider {
.padding()
}
}
+16 -5
View File
@@ -7,19 +7,30 @@
import SwiftUI
/// A view that renders an inline mention of a Nostr event.
///
/// Supports NIP-01/NIP-10 relay hints to fetch events from relays not in the user's pool.
struct MentionView: View {
let damus_state: DamusState
let mention: Mention<NoteId>
init(damus_state: DamusState, mention: Mention<NoteId>) {
let relayHints: [RelayURL]
/// Creates a mention view.
///
/// - Parameters:
/// - damus_state: The app's shared state.
/// - mention: The mention containing the note ID.
/// - relayHints: Optional relay URLs where the event may be found (per NIP-01/NIP-10).
init(damus_state: DamusState, mention: Mention<NoteId>, relayHints: [RelayURL] = []) {
self.damus_state = damus_state
self.mention = mention
self.relayHints = relayHints
}
var body: some View {
EventLoaderView(damus_state: damus_state, event_id: mention.ref) { event in
EventLoaderView(damus_state: damus_state, event_id: mention.ref, relayHints: relayHints) { event in
EventMutingContainerView(damus_state: damus_state, event: event) {
BuilderEventView(damus: damus_state, event_id: mention.ref)
BuilderEventView(damus: damus_state, event_id: mention.ref, relayHints: relayHints)
}
}
}
+27 -29
View File
@@ -11,10 +11,10 @@ class EventsModel: ObservableObject {
let state: DamusState
let target: NoteId
let kind: QueryKind
let sub_id = UUID().uuidString
let profiles_id = UUID().uuidString
var events: EventHolder
@Published var loading: Bool
var loadingTask: Task<Void, Never>?
enum QueryKind {
case kind(NostrKind)
@@ -68,42 +68,40 @@ class EventsModel: ObservableObject {
}
func subscribe() {
state.nostrNetwork.pool.subscribe(sub_id: sub_id,
filters: [get_filter()],
handler: handle_nostr_event)
loadingTask?.cancel()
loadingTask = Task {
DispatchQueue.main.async { self.loading = true }
outerLoop: for await item in state.nostrNetwork.reader.advancedStream(filters: [get_filter()]) {
switch item {
case .event(let lender):
Task {
await lender.justUseACopy({ event in
if await events.insert(event) {
DispatchQueue.main.async { self.objectWillChange.send() }
}
})
}
case .eose:
break outerLoop
case .ndbEose:
DispatchQueue.main.async { self.loading = false }
break
case .networkEose:
break
}
}
DispatchQueue.main.async { self.loading = false }
}
}
func unsubscribe() {
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
loadingTask?.cancel()
}
@MainActor
private func handle_event(relay_id: RelayURL, ev: NostrEvent) {
if events.insert(ev) {
objectWillChange.send()
}
}
func handle_nostr_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
guard case .nostr_event(let nev) = ev, nev.subid == self.sub_id
else {
return
}
switch nev {
case .event(_, let ev):
handle_event(relay_id: relay_id, ev: ev)
case .notice:
break
case .ok:
break
case .auth:
break
case .eose:
self.loading = false
guard let txn = NdbTxn(ndb: self.state.ndb) else {
return
}
load_profiles(context: "events_model", profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events.all_events), damus_state: state, txn: txn)
}
}
}
@@ -21,64 +21,69 @@ class LoadableNostrEventViewModel: ObservableObject {
let damus_state: DamusState
let note_reference: NoteReference
@Published var state: ThreadModelLoadingState = .loading
/// The time period after which it will give up loading the view.
/// Written in nanoseconds
let TIMEOUT: UInt64 = 10 * 1_000_000_000 // 10 seconds
init(damus_state: DamusState, note_reference: NoteReference) {
self.damus_state = damus_state
self.note_reference = note_reference
Task { await self.load() }
}
func load() async {
// Start the loading process in a separate task to manage the timeout independently.
let loadTask = Task { @MainActor in
self.state = await executeLoadingLogic(note_reference: self.note_reference)
}
// Setup a timer to cancel the load after the timeout period
let timeoutTask = Task { @MainActor in
try await Task.sleep(nanoseconds: TIMEOUT)
loadTask.cancel() // This sends a cancellation signal to the load task.
self.state = .not_found
}
await loadTask.value
timeoutTask.cancel() // Cancel the timeout task if loading finishes earlier.
/// Waits for relay connection then loads the referenced event.
///
/// Timeout is handled by `awaitConnection()` (30 s built-in).
func load() async {
await damus_state.nostrNetwork.awaitConnection()
self.state = await executeLoadingLogic(note_reference: self.note_reference)
}
/// Asynchronously find an event from NostrDB or from the network (if not available on NostrDB)
private func loadEvent(noteId: NoteId) async -> NostrEvent? {
let res = await find_event(state: damus_state, query: .event(evid: noteId))
/// Loads the Nostr event identified by `noteId`, optionally restricting the lookup to specific relays.
/// - Parameters:
/// - relays: An array of relay URLs to restrict the lookup to. If empty, the lookup is not restricted to any relays.
/// - Returns: The `NostrEvent` matching `noteId` if found, `nil` otherwise.
private func loadEvent(noteId: NoteId, relays: [RelayURL]) async -> NostrEvent? {
let targetRelays = relays.isEmpty ? nil : relays
let res = await damus_state.nostrNetwork.reader.findEvent(query: .event(evid: noteId, find_from: targetRelays))
guard let res, case .event(let ev) = res else { return nil }
return ev
}
/// Gets the note reference and tries to load it, outputting a new state for this view model.
/// Resolve a NoteReference into a ThreadModelLoadingState describing how the referenced note should be presented.
///
/// For a `.note_id` reference this attempts to load the event (honoring optional relay hints) and maps event kinds as follows:
/// - `.text` or `.highlight` `.loaded` with a `Route.Thread`.
/// - `.dm` `.loaded` with a `Route.DMChat` for the corresponding DM model.
/// - `.like` follows the first referenced note ID (propagating the same relay hints) and resolves it recursively.
/// - `.zap` or `.zap_request` resolves a zap and, if found, returns `.loaded` with a `Route.Zaps`.
/// - any other known kind `.unknown_or_unsupported_kind`.
/// If the event cannot be retrieved or a required referenced note/zap is missing, returns `.not_found`.
///
/// For an `.naddr` reference this looks up the event (using relays from the NAddr if provided) and returns `.loaded` with a `Route.Thread` when found or `.not_found` when not found.
/// - Parameter note_reference: The note identifier to resolve; may include relay hints for relay-aware lookup.
/// - Returns: A `ThreadModelLoadingState` indicating the resolved presentation route (`.loaded(route: Route)`), `.not_found` when the note or required referenced data is missing, or `.unknown_or_unsupported_kind` when the event kind cannot be presented.
private func executeLoadingLogic(note_reference: NoteReference) async -> ThreadModelLoadingState {
switch note_reference {
case .note_id(let note_id):
guard let ev = await self.loadEvent(noteId: note_id) else { return .not_found }
case .note_id(let note_id, let relays):
guard let ev = await self.loadEvent(noteId: note_id, relays: relays) else { return .not_found }
guard let known_kind = ev.known_kind else { return .unknown_or_unsupported_kind }
switch known_kind {
case .text, .highlight, .voice_message:
case .text, .highlight, .longform:
return .loaded(route: Route.Thread(thread: ThreadModel(event: ev, damus_state: damus_state)))
case .dm:
let dm_model = damus_state.dms.lookup_or_create(ev.pubkey)
return .loaded(route: Route.DMChat(dms: dm_model))
case .like:
// Load the event that this reaction refers to.
guard let first_referenced_note_id = ev.referenced_ids.first else { return .not_found }
return await self.executeLoadingLogic(note_reference: .note_id(first_referenced_note_id))
// Pass the same relay hints - the referenced note is likely on the same relay as the reaction
return await self.executeLoadingLogic(note_reference: .note_id(first_referenced_note_id, relays: relays))
case .zap, .zap_request:
guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found }
return .loaded(route: Route.Zaps(target: zap.target))
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list, .interest_list:
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list, .interest_list, .contact_card, .live, .live_chat:
return .unknown_or_unsupported_kind
}
case .naddr(let naddr):
guard let event = await naddrLookup(damus_state: damus_state, naddr: naddr) else { return .not_found }
let targetRelays = naddr.relays.isEmpty ? nil : naddr.relays
guard let event = await damus_state.nostrNetwork.reader.lookup(naddr: naddr, to: targetRelays) else { return .not_found }
return .loaded(route: Route.Thread(thread: ThreadModel(event: event, damus_state: damus_state)))
}
}
@@ -91,7 +96,7 @@ class LoadableNostrEventViewModel: ObservableObject {
}
enum NoteReference: Hashable {
case note_id(NoteId)
case note_id(NoteId, relays: [RelayURL])
case naddr(NAddr)
}
}
@@ -271,5 +276,5 @@ extension LoadableNostrEventView {
}
#Preview("Loadable") {
LoadableNostrEventView(state: test_damus_state, note_reference: .note_id(test_thread_note_1.id))
}
LoadableNostrEventView(state: test_damus_state, note_reference: .note_id(test_thread_note_1.id, relays: []))
}
+184 -41
View File
@@ -72,8 +72,9 @@ func render_immediately_available_note_content(ndb: Ndb, ev: NostrEvent, profile
}
do {
let blocks = try NdbBlockGroup.from(event: ev, using: ndb, and: keypair)
return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
return try NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blocks in
return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
})
}
catch {
// TODO: Improve error handling in the future, bubbling it up so that the view can decide how display errors. Keep legacy behavior for now.
@@ -83,12 +84,6 @@ func render_immediately_available_note_content(ndb: Ndb, ev: NostrEvent, profile
actor ContentRenderer {
func render_note_content(ndb: Ndb, ev: NostrEvent, profiles: Profiles, keypair: Keypair) async -> NoteArtifacts {
if ev.known_kind == .dm {
// Use the enhanced render_immediately_available_note_content which now handles DMs properly
// by decrypting and parsing the content with ndb_parse_content
return render_immediately_available_note_content(ndb: ndb, ev: ev, profiles: profiles, keypair: keypair)
}
let result = try? await ndb.waitFor(noteId: ev.id, timeout: 3)
return render_immediately_available_note_content(ndb: ndb, ev: ev, profiles: profiles, keypair: keypair)
}
}
@@ -196,8 +191,7 @@ func render_blocks(blocks: borrowing NdbBlockGroup, profiles: Profiles, can_hide
let url_type = classify_url(url)
urls.append(url_type)
case .invoice(let invoice_block):
guard let invoice = invoice_block.as_invoice() else { break }
invoices.append(invoice)
invoices.append(invoice_block.as_invoice())
default:
break
}
@@ -263,9 +257,9 @@ func render_blocks(blocks: borrowing NdbBlockGroup, profiles: Profiles, can_hide
return .loopReturn(str + CompatibleText(stringLiteral: reduce_text_block(ind: index, hide_text_index: hide_text_index_argument, txt: txt.as_str())))
case .hashtag(let htag):
return .loopReturn(str + hashtag_str(htag.as_str()))
case .invoice(let invoice):
guard let inv = invoice.as_invoice() else { return .loopContinue }
invoices.append(inv)
case .invoice:
// Invoice already added in previewable-collection switch above
break
case .url(let url):
guard let url = URL(string: url.as_str()) else { return .loopContinue }
return .loopReturn(str + url_str(url))
@@ -319,8 +313,6 @@ func classify_url(_ url: URL) -> UrlType {
return .media(.image(url))
case "mp4", "mov", "m3u8":
return .media(.video(url))
case "m4a":
return .media(.audio(url))
default:
return .link(url)
}
@@ -335,8 +327,7 @@ func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage)
}
func getDisplayName(pk: Pubkey, profiles: Profiles) -> String {
let profile_txn = profiles.lookup(id: pk, txn_name: "getDisplayName")
let profile = profile_txn?.unsafeUnownedValue
let profile = try? profiles.lookup(id: pk)
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
}
@@ -389,11 +380,182 @@ struct LongformContent {
let markdown: MarkdownContent
let words: Int
/// Estimated reading time in minutes, based on average reading speed of 200 words per minute.
var estimatedReadTimeMinutes: Int {
return max(1, Int(ceil(Double(words) / 200.0)))
}
init(_ markdown: String) {
let blocks = [BlockNode].init(markdown: markdown)
// Pre-process markdown to ensure images are block-level (have blank lines around them)
// This prevents images from being parsed as inline within text paragraphs
let processedMarkdown = LongformContent.ensureBlockLevelImages(markdown)
let blocks = [BlockNode].init(markdown: processedMarkdown)
self.markdown = MarkdownContent(blocks: blocks)
self.words = count_markdown_words(blocks: blocks)
}
/// Ensures markdown images have blank lines around them so they parse as block-level elements.
/// Without blank lines, images followed by text are parsed as inline within a paragraph,
/// causing them to be clipped by SwiftUI's Text view.
///
/// Safety: This function excludes:
/// - Fenced code blocks (``` or ~~~), indented code blocks (4 spaces/tab), and inline code
/// - Images inside lists (lines starting with -, *, + followed by space)
/// - Images inside blockquotes (lines starting with >)
/// - Images inside tables (lines containing | with table structure)
///
/// Known limitations (images remain inline, may clip if mixed with text):
/// - Multi-line list items with images on continuation lines
/// - Reference-style images: ![alt][id]
/// - HTML <img> tags
/// - Inline code with nested backticks (e.g., `` `code` `` using longer delimiters)
private static func ensureBlockLevelImages(_ markdown: String) -> String {
// First, identify regions to exclude (fenced code blocks and inline code)
let excludedRanges = findExcludedRanges(in: markdown)
// Pattern matches markdown images: ![alt](url) or ![alt](url "title")
// Handles URLs with parentheses by matching balanced parens or escaped parens
let imagePattern = #"!\[[^\]]*\]\((?:[^()]+|\([^)]*\))+(?:\s+"[^"]*")?\)"#
guard let regex = try? NSRegularExpression(pattern: imagePattern, options: []) else {
return markdown
}
var result = markdown
let matches = regex.matches(in: result, options: [], range: NSRange(result.startIndex..., in: result))
// Process matches in reverse order to preserve indices
for match in matches.reversed() {
guard let range = Range(match.range, in: result) else { continue }
// Skip if this match is inside an excluded region (code block/inline code)
// Use String.Index comparison for correctness with non-ASCII content
if excludedRanges.contains(where: { $0.overlaps(range) }) {
continue
}
// Skip if inside a list, blockquote, or table context
if isInStructuredContext(result, at: range.lowerBound) {
continue
}
let imageMarkdown = String(result[range])
// Check what's before the image
let beforeIndex = range.lowerBound
let hasParagraphBreakBefore = beforeIndex == result.startIndex ||
result[result.index(before: beforeIndex)] == "\n" && (
beforeIndex == result.index(after: result.startIndex) ||
result[result.index(beforeIndex, offsetBy: -2)] == "\n"
)
// Check what's after the image
let afterIndex = range.upperBound
let hasParagraphBreakAfter = afterIndex == result.endIndex ||
result[afterIndex] == "\n" && (
result.index(after: afterIndex) == result.endIndex ||
result[result.index(after: afterIndex)] == "\n"
)
// Build replacement with proper paragraph breaks
var replacement = imageMarkdown
if !hasParagraphBreakBefore && beforeIndex != result.startIndex {
replacement = "\n\n" + replacement
}
if !hasParagraphBreakAfter && afterIndex != result.endIndex {
replacement = replacement + "\n\n"
}
if replacement != imageMarkdown {
result.replaceSubrange(range, with: replacement)
}
}
return result
}
/// Checks if the position is inside a list, blockquote, or table context.
/// Returns true if modifying this position would break markdown structure.
private static func isInStructuredContext(_ markdown: String, at position: String.Index) -> Bool {
// Find the start of the current line
var lineStart = position
while lineStart > markdown.startIndex {
let prevIndex = markdown.index(before: lineStart)
if markdown[prevIndex] == "\n" {
break
}
lineStart = prevIndex
}
// Get the line prefix (content before the image on this line)
let linePrefix = String(markdown[lineStart..<position])
let trimmedPrefix = linePrefix.trimmingCharacters(in: .whitespaces)
// Check for list markers: -, *, + followed by space (to avoid false positives)
if (trimmedPrefix.hasPrefix("- ") || trimmedPrefix.hasPrefix("* ") ||
trimmedPrefix.hasPrefix("+ ")) {
return true
}
// Check for numbered list: digit(s) followed by . or ) and space
let numberedListPattern = #"^\d+[.)]\s"#
if let numberedRegex = try? NSRegularExpression(pattern: numberedListPattern, options: []),
numberedRegex.firstMatch(in: trimmedPrefix, options: [], range: NSRange(trimmedPrefix.startIndex..., in: trimmedPrefix)) != nil {
return true
}
// Check for blockquote marker: >
if trimmedPrefix.hasPrefix(">") {
return true
}
// Check for table context: line has | at both start area and elsewhere (actual table row)
var lineEnd = position
while lineEnd < markdown.endIndex && markdown[lineEnd] != "\n" {
lineEnd = markdown.index(after: lineEnd)
}
let fullLine = String(markdown[lineStart..<lineEnd])
// Only treat as table if line has multiple | characters (actual table structure)
if fullLine.filter({ $0 == "|" }).count >= 2 {
return true
}
return false
}
/// Finds ranges in the markdown that should be excluded from image processing.
/// Returns ranges as String.Index for correct handling of non-ASCII content.
private static func findExcludedRanges(in markdown: String) -> [Range<String.Index>] {
var ranges: [Range<String.Index>] = []
let fullRange = NSRange(markdown.startIndex..., in: markdown)
// Find fenced code blocks (3+ backticks or tildes)
let fencedPattern = #"(?:^|\n)(`{3,}|~{3,}).*?(?:\n\1|\z)"#
guard let fencedRegex = try? NSRegularExpression(pattern: fencedPattern, options: [.dotMatchesLineSeparators]) else {
return ranges
}
ranges.append(contentsOf: fencedRegex.matches(in: markdown, options: [], range: fullRange)
.compactMap { Range($0.range, in: markdown) })
// Find indented code blocks (lines starting with 4 spaces or tab, preceded by blank line)
// Blank line may contain whitespace: \n followed by optional spaces/tabs then \n
let indentedPattern = #"(?:^|\n[ \t]*\n)((?:(?: |\t).+\n?)+)"#
guard let indentedRegex = try? NSRegularExpression(pattern: indentedPattern, options: []) else {
return ranges
}
ranges.append(contentsOf: indentedRegex.matches(in: markdown, options: [], range: fullRange)
.filter { $0.numberOfRanges > 1 }
.compactMap { Range($0.range(at: 1), in: markdown) })
// Find inline code (1+ backticks, matching pairs)
let inlinePattern = #"(`+)(?!`)[^`]*?\1"#
guard let inlineRegex = try? NSRegularExpression(pattern: inlinePattern, options: []) else {
return ranges
}
ranges.append(contentsOf: inlineRegex.matches(in: markdown, options: [], range: fullRange)
.compactMap { Range($0.range, in: markdown) })
return ranges
}
}
func count_markdown_words(blocks: [BlockNode]) -> Int {
@@ -454,8 +616,6 @@ enum UrlType {
return url
case .video(let url):
return url
case .audio(let url):
return url
}
case .link(let url):
return url
@@ -466,7 +626,7 @@ enum UrlType {
switch self {
case .media(let media_url):
switch media_url {
case .image, .audio:
case .image:
return nil
case .video(let url):
return url
@@ -482,28 +642,14 @@ enum UrlType {
switch media_url {
case .image(let url):
return url
case .video, .audio:
case .video:
return nil
}
case .link:
return nil
}
}
var is_audio: URL? {
switch self {
case .media(let media_url):
switch media_url {
case .audio(let url):
return url
case .image, .video:
return nil
}
case .link:
return nil
}
}
var is_link: URL? {
switch self {
case .media:
@@ -526,16 +672,13 @@ enum UrlType {
enum MediaUrl {
case image(URL)
case video(URL)
case audio(URL)
var url: URL {
switch self {
case .image(let url):
return url
case .video(let url):
return url
case .audio(let url):
return url
}
}
}
+306 -110
View File
@@ -10,6 +10,7 @@ import LinkPresentation
import NaturalLanguage
import MarkdownUI
import Translation
import UIKit
struct Blur: UIViewRepresentable {
var style: UIBlurEffect.Style = .systemUltraThinMaterial
@@ -45,9 +46,11 @@ struct NoteContentView: View {
let event: NostrEvent
@State var blur_images: Bool
@State var load_media: Bool = false
@State private var showLinksDropdown = false
let size: EventViewKind
let preview_height: CGFloat?
let options: EventViewOptions
let highlightTerms: [String]
@State var isAppleTranslationPopoverPresented: Bool = false
@@ -62,12 +65,13 @@ struct NoteContentView: View {
return self.artifacts_model.state.artifacts ?? .separated(.just_content(event.get_content(damus_state.keypair)))
}
init(damus_state: DamusState, event: NostrEvent, blur_images: Bool, size: EventViewKind, options: EventViewOptions) {
init(damus_state: DamusState, event: NostrEvent, blur_images: Bool, size: EventViewKind, options: EventViewOptions, highlightTerms: [String] = []) {
self.damus_state = damus_state
self.event = event
self.blur_images = blur_images
self.size = size
self.options = options
self.highlightTerms = highlightTerms
self.preview_height = lookup_cached_preview_size(previews: damus_state.previews, evid: event.id)
let cached = damus_state.events.get_cache_data(event.id)
self._preview_model = ObservedObject(wrappedValue: cached.preview_model)
@@ -167,29 +171,33 @@ struct NoteContentView: View {
}
func MainContent(artifacts: NoteArtifactsSeparated) -> some View {
VStack(alignment: .leading) {
if size == .selected {
if with_padding {
SelectableText(damus_state: damus_state, event: self.event, attributedString: artifacts.content.attributed, size: self.size)
.padding(.horizontal)
} else {
SelectableText(damus_state: damus_state, event: self.event, attributedString: artifacts.content.attributed, size: self.size)
}
} else {
if with_padding {
truncatedText(content: artifacts.content)
.padding(.horizontal)
} else {
truncatedText(content: artifacts.content)
}
}
let contentToRender = highlightedContent(artifacts.content)
if !options.contains(.no_translate) && (size == .selected || TranslationService.isAppleTranslationPopoverSupported || damus_state.settings.auto_translate) {
if with_padding {
translateView
.padding(.horizontal)
return VStack(alignment: .leading) {
if artifacts.content.attributed.characters.count != 0 {
if size == .selected {
if with_padding {
SelectableText(damus_state: damus_state, event: self.event, attributedString: contentToRender.attributed, size: self.size)
.padding(.horizontal)
} else {
SelectableText(damus_state: damus_state, event: self.event, attributedString: contentToRender.attributed, size: self.size)
}
} else {
translateView
if with_padding {
truncatedText(content: contentToRender)
.padding(.horizontal)
} else {
truncatedText(content: contentToRender)
}
}
if !options.contains(.no_translate) && (size == .selected || TranslationService.isAppleTranslationPopoverSupported || damus_state.settings.auto_translate) {
if with_padding {
translateView
.padding(.horizontal)
} else {
translateView
}
}
}
@@ -230,49 +238,196 @@ struct NoteContentView: View {
}
}
.padding(.top, artifacts.content.attributed.characters.count == 0 ? 7 : 0)
}
var has_previews: Bool {
!options.contains(.no_previews)
}
func loadMediaButton(artifacts: NoteArtifactsSeparated) -> some View {
Button(action: {
load_media = true
}, label: {
VStack(alignment: .leading) {
HStack {
Image("images")
Text("Load media", comment: "Button to show media in note.")
.fontWeight(.bold)
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
}
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
VStack(spacing: 0) {
HStack(spacing: 0) {
ForEach(artifacts.media.indices, id: \.self) { index in
Divider()
.frame(height: 1)
switch artifacts.media[index] {
case .image(let url), .video(let url), .audio(let url):
Text(abbreviateURL(url))
Button(action: {
load_media = true
}) {
HStack(spacing: 10) {
ZStack(alignment: .topTrailing) {
Image("images")
.foregroundStyle(DamusColors.neutral6)
.accessibilityHidden(true)
if artifacts.media.count > 1 {
Text(verbatim: "\(artifacts.media.count)")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(
Capsule()
.fill(DamusColors.neutral6)
)
.offset(x: 6, y: -6)
.accessibilityHidden(true)
}
}
Text(verbatim: "\(pluralizedString(key: "media_count", count: artifacts.media.count))")
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
.foregroundStyle(DamusColors.neutral6)
.multilineTextAlignment(.leading)
.padding(EdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10))
Spacer()
}
.padding(.vertical, 12)
.padding(.leading, 14)
.padding(.trailing, 8)
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
Rectangle()
.fill(DamusColors.neutral3)
.frame(width: 1)
.padding(.vertical, 8)
Button(action: {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
showLinksDropdown.toggle()
}
}) {
Image(systemName: showLinksDropdown ? "chevron.up.circle.fill" : "chevron.down.circle")
.font(.system(size: 16))
.foregroundStyle(DamusColors.neutral6)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
.accessibilityLabel(showLinksDropdown ? NSLocalizedString("Hide media links", comment: "Accessibility label for toggle button to hide media link list") : NSLocalizedString("Show media links", comment: "Accessibility label for toggle button to show media link list"))
}
.background(
RoundedRectangle(cornerRadius: 10)
.fill(DamusColors.neutral1.opacity(0.6))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(DamusColors.neutral3, lineWidth: 1)
)
.shadow(color: .black.opacity(0.05), radius: 2, y: 1)
)
if showLinksDropdown {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(artifacts.media.enumerated()), id: \.offset) { index, mediaItem in
if index > 0 {
Divider()
.background(DamusColors.neutral3)
}
mediaLinkRow(for: mediaItem, at: index)
}
}
.background(
RoundedRectangle(cornerRadius: 10)
.fill(DamusColors.neutral1.opacity(0.4))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(DamusColors.neutral3, lineWidth: 1)
)
.shadow(color: .black.opacity(0.05), radius: 2, y: 1)
)
.padding(.top, 6)
.transition(.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top)),
removal: .opacity
))
}
.background(DamusColors.neutral1)
.frame(minWidth: nil, maxWidth: .infinity, alignment: .center)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(DamusColors.neutral3, lineWidth: 1)
)
})
}
.padding(.horizontal)
}
@concurrent
func streamProfiles() async throws {
var mentionPubkeys: Set<Pubkey> = []
let event = await self.event.clone()
try await NdbBlockGroup.borrowBlockGroup(event: event, using: damus_state.ndb, and: damus_state.keypair, borrow: { blockGroup in
blockGroup.forEachBlock({ _, block in
guard let pubkey = block.mentionPubkey(tags: event.tags) else {
return .loopContinue
}
mentionPubkeys.insert(pubkey)
return .loopContinue
})
})
if mentionPubkeys.isEmpty {
return
}
// Only re-render on network updates, not cached profiles.
// Initial render already uses cached profile data via the view hierarchy.
for await profile in await damus_state.nostrNetwork.profilesManager.streamProfiles(pubkeys: mentionPubkeys, yieldCached: false) {
await load(force_artifacts: true)
}
}
@ViewBuilder
private func mediaLinkRow(for mediaItem: MediaUrl, at index: Int) -> some View {
switch mediaItem {
case .image(let url), .video(let url):
Button(action: {
load_media = true
}) {
HStack(spacing: 10) {
Image(systemName: "photo.circle.fill")
.font(.system(size: 14))
.foregroundStyle(DamusColors.neutral6)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(abbreviateURL(url))
.font(.system(size: 13))
.foregroundStyle(DamusColors.neutral6)
.lineLimit(1)
.truncationMode(.middle)
if let domain = url.host {
Text(domain)
.font(.system(size: 11))
.foregroundStyle(DamusColors.neutral6)
}
}
Spacer(minLength: 8)
HStack(spacing: 12) {
Button(action: {
UIPasteboard.general.string = url.absoluteString
let impactFeedback = UIImpactFeedbackGenerator(style: .light)
impactFeedback.impactOccurred()
}) {
Image(systemName: "doc.on.doc")
.font(.system(size: 14))
.foregroundStyle(DamusColors.neutral6)
}
.buttonStyle(PlainButtonStyle())
.accessibilityLabel(NSLocalizedString("Copy media link", comment: "Accessibility label for copy media link button"))
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.clear)
.contentShape(Rectangle())
)
}
.buttonStyle(PlainButtonStyle())
.accessibilityLabel(String(format: NSLocalizedString("Load %@", comment: "Accessibility label for button to load specific media item"), abbreviateURL(url)))
}
}
func load(force_artifacts: Bool = false) {
if case .loading = damus_state.events.get_cache_data(event.id).artifacts_model.state {
return
@@ -331,63 +486,82 @@ struct NoteContentView: View {
Group {
switch self.note_artifacts {
case .longform(let md):
Markdown(md.markdown)
.padding([.leading, .trailing, .top])
// Note: Do NOT apply .fixedSize to longform content - it prevents async images from expanding
// Limit line length to ~600pt for optimal readability (50-75 chars per line)
LongformMarkdownView(
markdown: md.markdown,
disableAnimation: damus_state.settings.disable_animation,
lineHeightMultiplier: damus_state.settings.longform_line_height,
sepiaEnabled: damus_state.settings.longform_sepia_mode
)
case .separated(let separated):
if #available(iOS 17.4, macOS 14.4, *) {
MainContent(artifacts: separated)
#if !targetEnvironment(macCatalyst)
.translationPresentation(isPresented: $isAppleTranslationPopoverPresented, text: event.get_content(damus_state.keypair))
#endif
.fixedSize(horizontal: false, vertical: true)
} else {
MainContent(artifacts: separated)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.fixedSize(horizontal: false, vertical: true)
}
var normalizedHighlightTerms: [String] {
var output: [String] = []
var seen = Set<String>()
let preparedTerms = highlightTerms
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.flatMap { term -> [String] in
if term.hasPrefix("#") {
let stripped = String(term.dropFirst())
return [term, stripped]
}
return [term]
}
for term in preparedTerms {
let lower = term.lowercased()
if !lower.isEmpty && seen.insert(lower).inserted {
output.append(lower)
}
}
return output
}
func highlightedContent(_ content: CompatibleText) -> CompatibleText {
guard !normalizedHighlightTerms.isEmpty else { return content }
var attributed = content.attributed
highlightAttributedString(&attributed)
return CompatibleText(attributed: attributed)
}
func highlightAttributedString(_ attributed: inout AttributedString) {
for term in normalizedHighlightTerms {
var searchStart = attributed.startIndex
while let range = attributed[searchStart...].range(of: term, options: .caseInsensitive) {
attributed[range].backgroundColor = DamusColors.highlight
searchStart = range.upperBound
}
}
}
var body: some View {
ArtifactContent
.onReceive(handle_notify(.profile_updated)) { profile in
guard let blockGroup = try? NdbBlockGroup.from(event: event, using: damus_state.ndb, and: damus_state.keypair) else {
return
}
let _: Int? = try? blockGroup.forEachBlock { index, block in
switch block {
case .mention(let m):
guard let typ = m.bech32_type else {
return .loopContinue
}
switch typ {
case .nprofile:
if m.bech32.nprofile.matches_pubkey(pk: profile.pubkey) {
load(force_artifacts: true)
}
case .npub:
if m.bech32.npub.matches_pubkey(pk: profile.pubkey) {
load(force_artifacts: true)
}
case .nevent: return .loopContinue
case .nrelay: return .loopContinue
case .nsec: return .loopContinue
case .note: return .loopContinue
case .naddr: return .loopContinue
}
case .text: return .loopContinue
case .hashtag: return .loopContinue
case .url: return .loopContinue
case .invoice: return .loopContinue
case .mention_index(_): return .loopContinue
}
return .loopContinue
}
.task {
try? await streamProfiles()
}
.onAppear {
load()
}
}
}
class NoteArtifactsParts {
@@ -458,7 +632,7 @@ struct BlurOverlayView: View {
.foregroundStyle(.white)
.bold()
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
Text(NSLocalizedString("Media from someone you don't follow", comment: "Label on the image blur mask"))
Text("Media from someone you don't follow", comment: "Label on the image blur mask")
.multilineTextAlignment(.center)
.foregroundStyle(Color.white)
.font(.title2)
@@ -477,8 +651,8 @@ struct BlurOverlayView: View {
let damus_state = damus_state
{
switch artifacts.media[0] {
case .image(let url), .video(let url), .audio(let url):
Text(abbreviateURL(url, maxLength: 30))
case .image(let url), .video(let url):
Text(verbatim: "\(abbreviateURL(url, maxLength: 30))")
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size * 0.8))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
@@ -536,24 +710,46 @@ struct NoteContentView_Previews: PreviewProvider {
}
func separate_images(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [MediaUrl]? {
guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else {
return nil
}
let urlBlocks: [URL] = (try? blockGroup.reduce(initialResult: Array<URL>()) { index, urls, block in
switch block {
case .url(let url):
guard let parsed_url = URL(string: url.as_str()) else {
return .loopContinue
return try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
let urlBlocks: [URL] = (blockGroup.reduce(initialResult: Array<URL>()) { index, urls, block in
switch block {
case .url(let url):
guard let parsed_url = URL(string: url.as_str()) else {
return .loopContinue
}
if classify_url(parsed_url).is_img != nil {
return .loopReturn(urls + [parsed_url])
}
default:
break
}
if classify_url(parsed_url).is_img != nil {
return .loopReturn(urls + [parsed_url])
}
default:
break
}
return .loopContinue
}) ?? []
let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
return mediaUrls.isEmpty ? nil : mediaUrls
return .loopContinue
}) ?? []
let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
return mediaUrls.isEmpty ? nil : mediaUrls
})
}
extension NdbBlock {
func mentionPubkey(tags: Tags) -> Pubkey? {
switch self {
case .mention(let mentionBlock):
guard let mention = MentionRef(block: mentionBlock) else {
return nil
}
return mention.pubkey
case .mention_index(let mentionIndex):
let tagPosition = Int(mentionIndex)
guard tagPosition >= 0, tagPosition < tags.count else {
return nil
}
guard let mention = MentionRef.from_tag(tag: tags[tagPosition]) else {
return nil
}
return mention.pubkey
default:
return nil
}
}
}
+17 -6
View File
@@ -11,20 +11,27 @@ struct SelectedEventView: View {
let damus: DamusState
let event: NostrEvent
let size: EventViewKind
@Environment(\.colorScheme) var colorScheme
var pubkey: Pubkey {
event.pubkey
}
@StateObject var bar: ActionBarModel
/// Whether to apply sepia styling to the entire view (for longform articles)
var useSepia: Bool {
event.known_kind == .longform && damus.settings.longform_sepia_mode
}
init(damus: DamusState, event: NostrEvent, size: EventViewKind) {
self.damus = damus
self.event = event
self.size = size
self._bar = StateObject(wrappedValue: make_actionbar_model(ev: event.id, damus: damus))
}
var body: some View {
HStack(alignment: .top) {
VStack(alignment: .leading) {
@@ -74,16 +81,20 @@ struct SelectedEventView: View {
}
.onReceive(handle_notify(.update_stats)) { target in
guard target == self.event.id else { return }
self.bar.update(damus: self.damus, evid: target)
Task { await self.bar.update(damus: self.damus, evid: target) }
}
.compositingGroup()
}
// Apply sepia background to outer HStack for full width coverage
// Note: foregroundStyle intentionally NOT applied here to preserve UI element contrast
// (buttons, icons, timestamps). Article text gets sepia styling via EventBody.
.background(useSepia ? DamusColors.sepiaBackground(for: colorScheme) : Color.clear)
}
var Mention: some View {
Group {
if let mention = first_eref_mention(ndb: damus.ndb, ev: event, keypair: damus.keypair) {
MentionView(damus_state: damus, mention: mention)
if let mention = first_eref_mention_with_hints(ndb: damus.ndb, ev: event, keypair: damus.keypair) {
MentionView(damus_state: damus, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
.padding(.horizontal)
}
}
+20 -3
View File
@@ -23,9 +23,13 @@ struct EventViewOptions: OptionSet {
static let truncate_content_very_short = EventViewOptions(rawValue: 1 << 11)
static let no_previews = EventViewOptions(rawValue: 1 << 12)
static let no_show_more = EventViewOptions(rawValue: 1 << 13)
static let small_text = EventViewOptions(rawValue: 1 << 14)
static let no_status = EventViewOptions(rawValue: 1 << 15)
static let no_context_menu = EventViewOptions(rawValue: 1 << 16)
static let embedded: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested]
static let embedded_text_only: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested, .no_media, .truncate_content_very_short, .no_previews]
static let live_chat: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .no_previews, .nested, .small_text, .no_status, .no_context_menu]
}
struct TextEvent: View {
@@ -34,13 +38,15 @@ struct TextEvent: View {
let pubkey: Pubkey
let options: EventViewOptions
let evdata: EventData
let highlightTerms: [String]
init(damus: DamusState, event: NostrEvent, pubkey: Pubkey, options: EventViewOptions) {
init(damus: DamusState, event: NostrEvent, pubkey: Pubkey, options: EventViewOptions, highlightTerms: [String] = []) {
self.damus = damus
self.event = event
self.pubkey = pubkey
self.options = options
self.evdata = damus.events.get_cache_data(event.id)
self.highlightTerms = highlightTerms
}
var body: some View {
@@ -51,12 +57,24 @@ struct TextEvent: View {
func EvBody(options: EventViewOptions) -> some View {
let blur_imgs = should_blur_images(settings: damus.settings, contacts: damus.contacts, ev: event, our_pubkey: damus.pubkey)
if options.contains(.small_text) {
return NoteContentView(
damus_state: damus,
event: event,
blur_images: blur_imgs,
size: .small,
options: options,
highlightTerms: highlightTerms)
}
return NoteContentView(
damus_state: damus,
event: event,
blur_images: blur_imgs,
size: .normal,
options: options
options: options,
highlightTerms: highlightTerms
)
}
@@ -73,4 +91,3 @@ struct TextEvent_Previews: PreviewProvider {
}
}
}
@@ -13,7 +13,7 @@ class FollowPackModel: ObservableObject {
@Published var loading: Bool = false
let damus_state: DamusState
let subid = UUID().description
var listener: Task<Void, Never>? = nil
let limit: UInt32 = 500
init(damus_state: DamusState) {
@@ -25,52 +25,36 @@ class FollowPackModel: ObservableObject {
func subscribe(follow_pack_users: [Pubkey]) {
loading = true
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
self.listener?.cancel()
self.listener = Task {
await self.listenForUpdates(follow_pack_users: follow_pack_users)
}
}
func unsubscribe(to: RelayURL? = nil) {
loading = false
self.listener?.cancel()
}
func listenForUpdates(follow_pack_users: [Pubkey]) async {
let to_relays = await damus_state.nostrNetwork.determineToRelays(filters: damus_state.relay_filters)
var filter = NostrFilter(kinds: [.text, .chat])
filter.until = UInt32(Date.now.timeIntervalSince1970)
filter.authors = follow_pack_users
filter.limit = 500
damus_state.nostrNetwork.pool.subscribe(sub_id: subid, filters: [filter], handler: handle_event, to: to_relays)
}
func unsubscribe(to: RelayURL? = nil) {
loading = false
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: to.map { [$0] })
}
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
guard case .nostr_event(let event) = conn_ev else {
return
}
switch event {
case .event(let sub_id, let ev):
guard sub_id == self.subid else {
return
}
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
{
if self.events.insert(ev) {
self.objectWillChange.send()
for await event in damus_state.nostrNetwork.reader.streamIndefinitely(filters: [filter], to: to_relays) {
await event.justUseACopy({ event in
let should_show_event = await should_show_event(state: damus_state, ev: event)
if event.is_textlike && should_show_event && !event.is_reply()
{
if await self.events.insert(event) {
DispatchQueue.main.async {
self.objectWillChange.send()
}
}
}
}
case .notice(let msg):
print("follow pack notice: \(msg)")
case .ok:
break
case .eose(let sub_id):
loading = false
if sub_id == self.subid {
unsubscribe(to: relay_id)
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
}
break
case .auth:
break
})
}
}
}
@@ -80,7 +80,7 @@ struct FollowPackBannerImage: View {
}
}
} else {
Text(NSLocalizedString("No cover image", comment: "Text letting user know there is no cover image."))
Text("No cover image", comment: "Text letting user know there is no cover image.")
.foregroundColor(.gray)
.frame(width: 350, height: 180)
Divider()
@@ -153,12 +153,11 @@ struct FollowPackPreviewBody: View {
}
HStack(alignment: .center) {
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true)
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true, damusState: state)
.onTapGesture {
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
}
let profile_txn = state.profiles.lookup(id: event.event.pubkey)
let profile = profile_txn?.unsafeUnownedValue
let profile = try? state.profiles.lookup(id: event.event.pubkey)
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
switch displayName {
case .one(let one):
@@ -66,7 +66,7 @@ struct FollowPackTimelineView<Content: View>: View {
.coordinateSpace(name: "scroll")
.onReceive(handle_notify(.scroll_to_top)) { () in
events.flush()
self.events.should_queue = false
self.events.set_should_queue(false)
scroll_to_event(scroller: scroller, id: "startblock", delay: 0.0, animate: true, anchor: .top)
}
}
@@ -131,12 +131,11 @@ struct FollowPackView: View {
}
HStack(alignment: .center) {
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true)
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true, damusState: state)
.onTapGesture {
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
}
let profile_txn = state.profiles.lookup(id: event.event.pubkey)
let profile = profile_txn?.unsafeUnownedValue
let profile = try? state.profiles.lookup(id: event.event.pubkey)
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
switch displayName {
case .one(let one):
@@ -9,17 +9,17 @@
import Foundation
func follow_reference(box: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, follow: FollowRef) -> NostrEvent? {
func follow_reference(box: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, follow: FollowRef) async -> NostrEvent? {
guard let ev = follow_user_event(our_contacts: our_contacts, keypair: keypair, follow: follow) else {
return nil
}
box.send(ev)
await box.send(ev)
return ev
}
func unfollow_reference(postbox: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, unfollow: FollowRef) -> NostrEvent? {
func unfollow_reference(postbox: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, unfollow: FollowRef) async -> NostrEvent? {
guard let cs = our_contacts else {
return nil
}
@@ -28,7 +28,7 @@ func unfollow_reference(postbox: PostBox, our_contacts: NostrEvent?, keypair: Fu
return nil
}
postbox.send(ev)
await postbox.send(ev)
return ev
}
@@ -7,6 +7,7 @@
import Foundation
@MainActor
class Contacts {
private var friends: Set<Pubkey> = Set()
private var friend_of_friends: Set<Pubkey> = Set()
@@ -96,6 +97,13 @@ class Contacts {
func get_friended_followers(_ pubkey: Pubkey) -> [Pubkey] {
return Array((pubkey_to_our_friends[pubkey] ?? Set()))
}
var friend_filter: (NostrEvent) -> Bool {
{ [weak self] ev in
guard let self else { return false }
return self.is_friend(ev.pubkey)
}
}
}
/// Delegate protocol for `Contacts`. Use this to listen to significant updates from a `Contacts` instance
@@ -14,8 +14,8 @@ class FollowersModel: ObservableObject {
@Published var contacts: [Pubkey]? = nil
var has_contact: Set<Pubkey> = Set()
let sub_id: String = UUID().description
let profiles_id: String = UUID().description
var listener: Task<Void, Never>? = nil
var profilesListener: Task<Void, Never>? = nil
var count: Int? {
guard let contacts = self.contacts else {
@@ -36,14 +36,22 @@ class FollowersModel: ObservableObject {
func subscribe() {
let filter = get_filter()
let filters = [filter]
//print_filters(relay_id: "following", filters: [filters])
self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
self.listener?.cancel()
self.listener = Task {
for await lender in damus_state.nostrNetwork.reader.streamIndefinitely(filters: filters) {
lender.justUseACopy({ self.handle_event(ev: $0) })
}
}
}
func unsubscribe() {
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
self.listener?.cancel()
self.profilesListener?.cancel()
self.listener = nil
self.profilesListener = nil
}
@MainActor
func handle_contact_event(_ ev: NostrEvent) {
if has_contact.contains(ev.pubkey) {
return
@@ -52,47 +60,10 @@ class FollowersModel: ObservableObject {
contacts?.append(ev.pubkey)
has_contact.insert(ev.pubkey)
}
func load_profiles<Y>(relay_id: RelayURL, txn: NdbTxn<Y>) {
let authors = find_profiles_to_fetch_from_keys(profiles: damus_state.profiles, pks: contacts ?? [], txn: txn)
if authors.isEmpty {
return
}
let filter = NostrFilter(kinds: [.metadata],
authors: authors)
damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event)
}
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
guard case .nostr_event(let nev) = ev else {
return
}
switch nev {
case .event(let sub_id, let ev):
guard sub_id == self.sub_id || sub_id == self.profiles_id else {
return
}
if ev.known_kind == .contacts {
handle_contact_event(ev)
}
case .notice(let msg):
print("followingmodel notice: \(msg)")
case .eose(let sub_id):
if sub_id == self.sub_id {
guard let txn = NdbTxn(ndb: self.damus_state.ndb) else { return }
load_profiles(relay_id: relay_id, txn: txn)
} else if sub_id == self.profiles_id {
damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
}
case .ok:
break
case .auth:
break
func handle_event(ev: NostrEvent) {
if ev.known_kind == .contacts {
Task { await handle_contact_event(ev) }
}
}
}
@@ -14,7 +14,7 @@ class FollowingModel {
let contacts: [Pubkey]
let hashtags: [Hashtag]
let sub_id: String = UUID().description
private var listener: Task<Void, Never>? = nil
init(damus_state: DamusState, contacts: [Pubkey], hashtags: [Hashtag]) {
self.damus_state = damus_state
@@ -22,11 +22,11 @@ class FollowingModel {
self.hashtags = hashtags
}
func get_filter<Y>(txn: NdbTxn<Y>) -> NostrFilter {
func get_filter() -> NostrFilter {
var f = NostrFilter(kinds: [.metadata])
f.authors = self.contacts.reduce(into: Array<Pubkey>()) { acc, pk in
// don't fetch profiles we already have
if damus_state.profiles.has_fresh_profile(id: pk, txn: txn) {
if (try? damus_state.profiles.has_fresh_profile(id: pk)) ?? false {
return
}
acc.append(pk)
@@ -34,26 +34,24 @@ class FollowingModel {
return f
}
func subscribe<Y>(txn: NdbTxn<Y>) {
let filter = get_filter(txn: txn)
func subscribe() {
let filter = get_filter()
if (filter.authors?.count ?? 0) == 0 {
needs_sub = false
return
}
let filters = [filter]
//print_filters(relay_id: "following", filters: [filters])
self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
self.listener?.cancel()
self.listener = Task {
for await item in self.damus_state.nostrNetwork.reader.advancedStream(filters: filters) {
// don't need to do anything here really
continue
}
}
}
func unsubscribe() {
if !needs_sub {
return
}
print("unsubscribing from following \(sub_id)")
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
}
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
// don't need to do anything here really
self.listener?.cancel()
self.listener = nil
}
}
@@ -151,8 +151,7 @@ struct FollowingView: View {
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onAppear {
guard let txn = NdbTxn(ndb: self.damus_state.ndb) else { return }
following.subscribe(txn: txn)
following.subscribe()
}
.onDisappear {
following.unsubscribe()
@@ -100,14 +100,10 @@ struct HighlightEvent {
return NSLocalizedString("Highlighted", bundle: bundle, comment: "Label to indicate that the user is highlighting their own post.")
}
guard let profile_txn = NdbTxn(ndb: ndb) else {
return ""
}
let names: [String] = pubkeys.map { pk in
let prof = ndb.lookup_profile_with_txn(pk, txn: profile_txn)
let profile = try? ndb.lookup_profile_and_copy(pk)
return Profile.displayName(profile: prof?.profile, pubkey: pk).username.truncate(maxLength: 50)
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
}
let uniqueNames: [String] = Array(Set(names))
@@ -63,8 +63,7 @@ struct HighlightEventRef: View {
.font(.system(size: 14, weight: .bold))
.lineLimit(1)
let profile_txn = damus_state.profiles.lookup(id: longform_event.event.pubkey, txn_name: "highlight-profile")
let profile = profile_txn?.unsafeUnownedValue
let profile = try? damus_state.profiles.lookup(id: longform_event.event.pubkey)
if let display_name = profile?.display_name {
Text(display_name)
@@ -0,0 +1,43 @@
//
// LabsExplainerView.swift
// damus
//
// Created by eric on 11/6/25.
//
import SwiftUI
struct LabsExplainerView: View {
let labName: String
let systemImage: String
let labDescription: String
var body: some View {
PurpleBackdrop {
VStack(alignment: .center) {
HStack {
Image(systemName: systemImage)
.resizable()
.foregroundColor(.white)
.frame(width: 25, height: 25)
Text(labName)
.font(.title)
.fontWeight(.bold)
.foregroundColor(.white)
}
Spacer()
Text(labDescription)
.foregroundColor(.white)
.multilineTextAlignment(.center)
Spacer()
}
.padding()
}
.overlay(Rectangle().frame(width: nil, height: 1, alignment: .top).foregroundColor(DamusColors.purple), alignment: .top)
.presentationDragIndicator(.visible)
.presentationDetents([.height(300)])
}
}
@@ -0,0 +1,40 @@
//
// LabsToggleView.swift
// damus
//
// Created by eric on 11/6/25.
//
import SwiftUI
struct LabsToggleView: View {
let toggleName: String
let systemImage: String
@Binding var isOn: Bool
@Binding var showInfo: Bool
var body: some View {
HStack {
HStack {
Toggle(toggleName, systemImage: systemImage, isOn: $isOn)
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.font(.title2)
.foregroundColor(.white)
.fontWeight(.bold)
}
.padding(15)
.background(DamusColors.black)
.cornerRadius(20)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(isOn ? DamusColors.purple : DamusColors.neutral6, lineWidth: 2)
)
Image("info")
.foregroundColor(DamusColors.purple)
.onTapGesture {
showInfo.toggle()
}
}
}
}
+68
View File
@@ -0,0 +1,68 @@
//
// DamusLabs.swift
// damus
//
// Created by eric on 10/17/25.
//
import SwiftUI
import StoreKit
struct DamusLabsView: View {
let damus_state: DamusState
@State var purple_account: DamusPurple.Account?
@State var show_intro_sheet: Bool = true
@State private var shouldDismissView = false
@Environment(\.dismiss) var dismiss
init(damus_state: DamusState) {
self.damus_state = damus_state
self.purple_account = nil
}
var body: some View {
NavigationView {
PurpleBackdrop {
VStack {
MainContent
}
}
.navigationBarHidden(true)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.navigationBarItems(leading: BackNav())
}
.onReceive(handle_notify(.switched_timeline)) { _ in
dismiss()
}
.onAppear {
notify(.display_tabbar(false))
}
.task {
if damus_state.purple.enable_purple {
self.purple_account = try? await damus_state.purple.get_maybe_cached_account(pubkey: damus_state.pubkey)
}
}
.ignoresSafeArea(.all)
}
var MainContent: some View {
VStack {
if let purple_account, purple_account.active == true {
DamusLabsExperiments(damus_state: damus_state, settings: damus_state.settings)
} else {
LabsLogoView()
.padding(.top, 125)
LabsIntroductionView(damus_state: damus_state)
}
}
}
}
struct DamusLabsView_Previews: PreviewProvider {
static var previews: some View {
DamusLabsView(damus_state: test_damus_state)
}
}
@@ -0,0 +1,86 @@
//
// DamusLabsExpirements.swift
// damus
//
// Created by eric on 10/24/25.
//
import SwiftUI
struct DamusLabsExperiments: View {
let damus_state: DamusState
@ObservedObject var settings: UserSettingsStore
@State var show_live_explainer: Bool = false
@State var show_favorites_explainer: Bool = false
@State var show_gifs_explainer: Bool = false
let live_label = NSLocalizedString("Live", comment: "Label for a toggle that enables an experimental feature")
let favorites_label = NSLocalizedString("Favorites", comment: "Label for a toggle that enables an experimental feature")
let gifs_label = NSLocalizedString("GIFs", comment: "Label for a toggle that enables an experimental feature")
var body: some View {
ScrollView {
LabsLogoView()
VStack(alignment: .leading, spacing: 30) {
PurpleViewPrimitives.SubtitleView(text: NSLocalizedString("As a subscriber, youre getting an early look at new and innovative tools. These are beta features — still being tested and tuned. Try them out, share your thoughts, and help us perfect whats next.", comment: "Damus Labs explainer"))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Text("More features coming soon!", comment: "Label indicating that more features for Damus Lab experiments are coming soon.")
.font(.title2)
.foregroundColor(.white)
.fontWeight(.bold)
.padding(.bottom, 2)
Spacer()
}
.padding(15)
.background(DamusColors.black)
.cornerRadius(15)
.padding(.top, 10)
LabsToggleView(toggleName: live_label, systemImage: "record.circle", isOn: $settings.live, showInfo: $show_live_explainer)
LabsToggleView(toggleName: favorites_label, systemImage: "heart.fill", isOn: $settings.enable_favourites_feature, showInfo: $show_favorites_explainer)
LabsToggleView(toggleName: gifs_label, systemImage: "smiley", isOn: $settings.enable_gifs_feature, showInfo: $show_gifs_explainer)
}
.padding([.trailing, .leading], 20)
.padding(.bottom, 50)
Image("damooseLabs")
.resizable()
.accessibilityHidden(true)
.aspectRatio(contentMode: .fill)
}
.ignoresSafeArea(edges: .bottom)
.sheet(isPresented: $show_live_explainer) {
LabsExplainerView(
labName: live_label,
systemImage: "record.circle",
labDescription: NSLocalizedString("This will allow you to see all the real-time live streams happening on Nostr! As well as let you view and interact in the Live Chat. Please keep in mind this is still a work in progress and issues are expected. When enabled you will see the Live option in your side menu.", comment: "Damus Labs feature explanation"))
}
.sheet(isPresented: $show_favorites_explainer) {
LabsExplainerView(
labName: favorites_label,
systemImage: "heart.fill",
labDescription: NSLocalizedString("This will allow you to pick users to be part of your favorites list. You can also switch your profile timeline to only see posts from your favorite contacts.", comment: "Damus Labs feature explanation"))
}
.sheet(isPresented: $show_gifs_explainer) {
LabsExplainerView(
labName: gifs_label,
systemImage: "",
labDescription: NSLocalizedString("This will allow you to easily add gifs from Tenor to your posts. You will see the GIF icon in the attachment bar when creating a post. Tapping it will show you all of tenor's featured GIFs. You can also search for GIFs.", comment: "Damus Labs feature explanation"))
}
}
}
#Preview {
PurpleBackdrop {
DamusLabsExperiments(damus_state: test_damus_state, settings: test_damus_state.settings)
}
}
@@ -0,0 +1,67 @@
//
// LabsLogoView.swift
// damus
//
// Created by eric on 10/17/25.
//
import SwiftUI
struct LabsLogoView: View {
var body: some View {
HStack(spacing: 20) {
Image("damus-dark-logo")
.resizable()
.frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 15.0))
.overlay(
RoundedRectangle(cornerRadius: 15)
.stroke(LinearGradient(
colors: [DamusColors.lighterPink.opacity(0.8), .white.opacity(0), DamusColors.deepPurple.opacity(0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing), lineWidth: 1)
)
.shadow(radius: 5)
.accessibilityHidden(true)
VStack(alignment: .leading) {
HStack(spacing: 0) {
Text("Labs ", comment: "Feature name")
.font(.system(size: 60.0).weight(.bold))
.foregroundColor(.white)
.tracking(-2)
.accessibilityHidden(true)
Image(systemName: "flask.fill")
.padding(.top, 25)
.foregroundStyle(
LinearGradient(
colors: [DamusColors.deepPurple, DamusColors.lighterPink],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.accessibilityHidden(true)
Image(systemName: "testtube.2")
.padding(.top, 25)
.foregroundStyle(
LinearGradient(
colors: [DamusColors.lighterPink, DamusColors.deepPurple],
startPoint: .bottomLeading,
endPoint: .topTrailing
)
)
.accessibilityHidden(true)
}
}
}
.padding(.bottom, 30)
.accessibilityLabel(NSLocalizedString("Damus Labs stylized logo", comment: "Accessibility label for a stylized Damus Labs logo"))
}
}
#Preview {
PurpleBackdrop {
LabsLogoView()
}
}
@@ -0,0 +1,53 @@
//
// LabsIntroduction.swift
// damus
//
// Created by eric on 10/17/25.
//
import SwiftUI
struct LabsIntroductionView: View {
let damus_state: DamusState
var body: some View {
VStack {
VStack(alignment: .leading, spacing: 30) {
PurpleViewPrimitives.SubtitleView(text: NSLocalizedString("Purple subscribers get first access to new and experimental features — fresh ideas straight from the lab.", comment: "Damus purple subscription pitch"))
.multilineTextAlignment(.center)
HStack {
NavigationLink(destination: DamusPurpleView(damus_state: damus_state)) {
HStack(spacing: 10) {
Spacer()
Text("Learn more about Purple", comment: "Button to learn more about the Damus Purple subscription.")
.foregroundColor(Color.white)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background {
RoundedRectangle(cornerRadius: 12)
.fill(PinkGradient)
}
}
}
}
.padding([.trailing, .leading], 30)
.padding(.bottom, 20)
Image("damooseLabs")
.resizable()
.aspectRatio(contentMode: .fill)
}
}
}
#Preview {
PurpleBackdrop {
LabsIntroductionView(damus_state: test_damus_state)
}
}
@@ -0,0 +1,99 @@
//
// LiveChatModel.swift
// damus
//
// Created by eric on 8/7/25.
//
import Foundation
/// The data model for the LiveEventHome view
class LiveChatModel: ObservableObject {
var events: EventHolder
@Published var loading: Bool = false
let damus_state: DamusState
let root: String
let dtag: String
var subscriptionTask: Task<Void, any Error>? = nil
let limit: UInt32 = 1000
init(damus_state: DamusState, root: String, dtag: String) {
self.damus_state = damus_state
self.root = root
self.dtag = dtag
self.events = EventHolder(on_queue: { ev in
preload_events(state: damus_state, events: [ev])
})
}
@MainActor
func filter_muted() {
events.filter { should_show_event(state: damus_state, ev: $0) }
self.objectWillChange.send()
}
@MainActor
func set(loading: Bool) {
self.loading = loading
}
func subscribe() {
subscriptionTask?.cancel()
subscriptionTask = Task {
await set(loading: true)
let live_chat_filter = NostrFilter(kinds: [.live_chat])
let to_relays = await damus_state.nostrNetwork.ourRelayDescriptors
.map { $0.url }
.filter { !damus_state.relay_filters.is_filtered(timeline: .search, relay_id: $0) }
for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [live_chat_filter], to: to_relays) {
switch item {
case .event(let lender):
await lender.justUseACopy({ await handle_event(event: $0) })
case .eose:
continue
case .ndbEose:
await set(loading: false)
case .networkEose:
continue
}
}
}
}
@MainActor
func unsubscribe(to: RelayURL? = nil) {
set(loading: false)
subscriptionTask?.cancel()
}
func handle_event(event: NostrEvent) async {
for tag in event.tags {
guard tag.count >= 2 else { continue }
switch tag[0].string() {
case "a":
let atag = tag[1].string()
let split = atag.split(separator: ":")
if root != split[1] {
return
}
if dtag != split[2] {
return
}
default:
break
}
}
await MainActor.run {
if should_show_event(state: damus_state, ev: event) {
if self.events.insert(event) {
self.objectWillChange.send()
}
}
}
}
}
@@ -0,0 +1,136 @@
//
// LiveChatHomeView.swift
// damus
//
// Created by eric on 8/7/25.
//
import SwiftUI
struct LiveChatHomeView: View, KeyboardReadable {
let state: DamusState
let event: LiveEvent
@StateObject var model: LiveChatModel
@State private var chat_message = ""
@FocusState private var isTextFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: state)
filters.append(fstate.filter)
return ContentFilters(filters: filters).filter
}
var Footer: some View {
HStack(spacing: 0) {
ChatInput
Button(
role: .none,
action: {
Task { await send_chat() }
}
) {
Label("", image: "send")
.font(.title)
}
.disabled(chat_message.isEmpty)
}
.safeAreaInset(edge: .bottom) {
Color.clear.frame(height: 10)
}
}
func send_chat() async {
guard
let keypair = state.keypair.to_full(),
let liveChat = make_live_chat_event(keypair: keypair, content: chat_message, root: event.event.pubkey.hex(), dtag: event.uuid ?? "", relayURL: nil)
else {
return
}
await state.nostrNetwork.postbox.send(liveChat)
chat_message = ""
end_editing()
}
var ChatInput: some View {
HStack{
TextField(NSLocalizedString("Chat", comment: "Placeholder text to prompt entry of chat message."), text: $chat_message)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.focused($isTextFieldFocused)
}
.padding(10)
.background(.secondary.opacity(0.2))
.cornerRadius(20)
.padding(.horizontal, 15)
}
func scroll_to_end(_ scroller: ScrollViewProxy, animated: Bool = false) {
if animated {
withAnimation {
scroller.scrollTo("endblock")
}
} else {
scroller.scrollTo("endblock")
}
}
var Chat: some View {
ScrollViewReader { scroller in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
let events = model.events.events
ForEach(Array(zip(events, events.indices).reversed()).filter { should_show_event(state: state, ev: $0.0)}, id: \.0.id) { (ev, ind) in
TextEvent(damus: state, event: ev, pubkey: ev.pubkey, options: .live_chat)
}
EndBlock(height: 1)
}
}
.dismissKeyboardOnTap()
.onAppear {
scroll_to_end(scroller)
}.onChange(of: model.events.events.count) { _ in
scroll_to_end(scroller, animated: true)
}
.padding(.top, 5)
Footer
.onReceive(keyboardPublisher) { visible in
guard visible else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
scroll_to_end(scroller, animated: true)
}
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Live Chat", comment: "Title for the live stream chat.")
.fontWeight(.bold)
.padding(5)
LiveStreamViewers(state: state, currentParticipants: event.currentParticipants ?? 0, preview: false)
}
Divider()
Chat
}
.onReceive(handle_notify(.new_mutes)) { _ in
self.model.filter_muted()
}
.onAppear {
model.subscribe()
}
.onDisappear {
model.unsubscribe()
}
}
}
@@ -0,0 +1,136 @@
//
// LiveChatTimeline.swift
// damus
//
// Created by eric on 8/7/25.
//
import SwiftUI
struct LiveChatTimelineView<Content: View>: View {
@ObservedObject var events: EventHolder
@Binding var loading: Bool
let damus: DamusState
let show_friend_icon: Bool
let filter: (NostrEvent) -> Bool
let content: Content?
let apply_mute_rules: Bool
init(events: EventHolder, loading: Binding<Bool>, headerHeight: Binding<CGFloat>, headerOffset: Binding<CGFloat>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
self.events = events
self._loading = loading
self.damus = damus
self.show_friend_icon = show_friend_icon
self.filter = filter
self.apply_mute_rules = apply_mute_rules
self.content = content?()
}
init(events: EventHolder, loading: Binding<Bool>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
self.events = events
self._loading = loading
self.damus = damus
self.show_friend_icon = show_friend_icon
self.filter = filter
self.apply_mute_rules = apply_mute_rules
self.content = content?()
}
func scroll_to_end(_ scroller: ScrollViewProxy, animated: Bool = false) {
if animated {
withAnimation {
scroller.scrollTo("endblock")
}
} else {
scroller.scrollTo("endblock")
}
}
var body: some View {
ScrollViewReader { scroller in
ScrollView {
if let content {
content
}
Color.clear
.id("startblock")
.frame(height: 0)
LiveChatInnerView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
.redacted(reason: loading ? .placeholder : [])
.shimmer(loading)
.disabled(loading)
.background {
GeometryReader { proxy -> Color in
handle_scroll_queue(proxy, queue: self.events)
return Color.clear
}
}
}
.coordinateSpace(name: "scroll")
.onReceive(handle_notify(.scroll_to_top)) { () in
events.flush()
self.events.set_should_queue(false)
scroll_to_event(scroller: scroller, id: "startblock", delay: 0.0, animate: true, anchor: .top)
}
}
.onAppear {
events.flush()
}
}
}
struct LiveChatInnerView: View {
@ObservedObject var events: EventHolder
let state: DamusState
let filter: (NostrEvent) -> Bool
init(events: EventHolder, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true) {
self.events = events
self.state = damus
self.filter = apply_mute_rules ? { filter($0) && !damus.mutelist_manager.is_event_muted($0) } : filter
}
var event_options: EventViewOptions {
if self.state.settings.truncate_timeline_text {
return [.wide, .truncate_content]
}
return [.wide]
}
var body: some View {
LazyVStack(spacing: 0) {
let events = self.events.events
if events.isEmpty {
EmptyTimelineView()
} else {
let evs = events.filter(filter)
let indexed = Array(zip(evs, 0...))
ForEach(indexed, id: \.0.id) { tup in
let ev = tup.0
let ind = tup.1
if ev.kind == NostrKind.live_chat.rawValue {
LiveChatView(state: state, ev: ev)
.padding(.top, 7)
.onAppear {
let to_preload =
Array([indexed[safe: ind+1]?.0,
indexed[safe: ind+2]?.0,
indexed[safe: ind+3]?.0,
indexed[safe: ind+4]?.0,
indexed[safe: ind+5]?.0
].compactMap({ $0 }))
preload_events(state: state, events: to_preload)
}
}
}
}
}
.padding(.bottom)
}
}
@@ -0,0 +1,38 @@
//
// LiveChatView.swift
// damus
//
// Created by eric on 8/7/25.
//
import SwiftUI
import Kingfisher
struct LiveChatView: View {
let state: DamusState
let event: NostrEvent
@Environment(\.colorScheme) var colorScheme
@ObservedObject var artifacts: NoteArtifactsModel
init(state: DamusState, ev: NostrEvent) {
self.state = state
self.event = ev
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
}
func content_filter(_ pubkeys: [Pubkey]) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: self.state)
filters.append({ pubkeys.contains($0.pubkey) })
return ContentFilters(filters: filters).filter
}
var body: some View {
VStack(alignment: .leading) {
TextEvent(damus: state, event: event, pubkey: event.pubkey, options: [.no_action_bar,.small_pfp,.wide,.no_previews,.small_text])
}
.padding(.bottom, 1)
}
}
@@ -0,0 +1,71 @@
//
// LiveEvent.swift
// damus
//
// Created by eric on 7/10/25.
//
import Foundation
enum LiveEventStatus: String {
case planned = "SCHEDULED"
case live = "LIVE"
case ended = "ENDED"
}
struct LiveEvent: Hashable {
let event: NostrEvent
var uuid: String? = nil
var title: String? = nil
var summary: String? = nil
var image: URL? = nil
var streaming: URL? = nil
var recording: URL? = nil
var starts: String? = nil
var ends: String? = nil
var status: LiveEventStatus? = nil
var currentParticipants: Int? = nil
var totalParticipants: Int? = nil
var pinned: String? = nil
var hashtags: [String]? = nil
var publicKeys: [Pubkey] = []
static func parse(from ev: NostrEvent) -> LiveEvent {
var liveEvent = LiveEvent(event: ev)
for tag in ev.tags {
guard tag.count >= 2 else { continue }
switch tag[0].string() {
case "title": liveEvent.title = tag[1].string()
case "d": liveEvent.uuid = tag[1].string()
case "image": liveEvent.image = URL(string: tag[1].string())
case "summary": liveEvent.summary = tag[1].string()
case "streaming": liveEvent.streaming = URL(string: tag[1].string())
case "recording": liveEvent.recording = URL(string: tag[1].string())
case "starts": liveEvent.starts = tag[1].string()
case "ends": liveEvent.ends = tag[1].string()
case "status":
if tag[1].string() == "planned" {
liveEvent.status = .planned
} else if tag[1].string() == "live" {
liveEvent.status = .live
} else if tag[1].string() == "ended" {
liveEvent.status = .ended
}
case "current_participants": liveEvent.currentParticipants = Int(tag[1].string())
case "total_participants": liveEvent.totalParticipants = Int(tag[1].string())
case "pinned": liveEvent.pinned = tag[1].string()
case "t":
if (liveEvent.hashtags?.append(tag[1].string())) == nil {
liveEvent.hashtags = [tag[1].string()]
}
case "p":
liveEvent.publicKeys.append(Pubkey(Data(hex: tag[1].string())))
default:
break
}
}
return liveEvent
}
}
@@ -0,0 +1,97 @@
//
// LiveEventModel.swift
// damus
//
// Created by eric on 7/25/25.
//
import Foundation
/// The data model for the LiveEventHome view
class LiveEventModel: ObservableObject {
var events: EventHolder
@Published var loading: Bool = false
let damus_state: DamusState
var subscriptionTask: Task<Void, any Error>? = nil
var seen_dtag: Set<String> = Set()
init(damus_state: DamusState) {
self.damus_state = damus_state
self.events = EventHolder(on_queue: { ev in
preload_events(state: damus_state, events: [ev])
})
}
@MainActor
func filter_muted() {
events.filter { should_show_event(state: damus_state, ev: $0) }
self.objectWillChange.send()
}
/// Helper function to set the `loading` member in the correct actor
@MainActor
private func set(loading: Bool) {
self.loading = loading
}
func subscribe() {
subscriptionTask?.cancel()
subscriptionTask = Task {
await self.set(loading: true)
var live_event_filter = NostrFilter(kinds: [.live])
live_event_filter.until = UInt32(Date.now.timeIntervalSince1970)
let calendar = Calendar.current
let twoWeeksAgo = calendar.date(byAdding: .day, value: -14, to: Date())!
live_event_filter.since = UInt32(twoWeeksAgo.timeIntervalSince1970)
let to_relays = await damus_state.nostrNetwork.ourRelayDescriptors
.map { $0.url }
.filter { !damus_state.relay_filters.is_filtered(timeline: .search, relay_id: $0) }
for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [live_event_filter], to: to_relays) {
switch item {
case .event(let lender):
await lender.justUseACopy({ await handle_event(ev: $0) })
case .eose:
continue
case .ndbEose:
await self.set(loading: false)
case .networkEose:
continue
}
}
}
}
@MainActor
func unsubscribe() {
self.set(loading: false)
subscriptionTask?.cancel()
}
func handle_event(ev: NostrEvent) async {
let should_show_event = await should_show_event(state: damus_state, ev: ev)
if ev.is_textlike && should_show_event && !ev.is_reply()
{
for tag in ev.tags {
guard tag.count >= 2 else { continue }
if tag[0].string() == "d" {
if seen_dtag.contains(tag[1].string()) {
return
} else {
seen_dtag.insert(tag[1].string())
}
}
}
await MainActor.run {
if self.events.insert(ev) {
self.objectWillChange.send()
}
}
}
}
}
@@ -0,0 +1,65 @@
//
// LiveStreamBanner.swift
// damus
//
// Created by eric on 8/8/25.
//
import SwiftUI
import Kingfisher
struct LiveStreamBanner: View {
let state: DamusState
let options: EventViewOptions
var image: URL? = nil
var preview: Bool
func Placeholder(url: URL, preview: Bool) -> some View {
Group {
if let meta = state.events.lookup_img_metadata(url: url),
case .processed(let blurhash) = meta.state {
Image(uiImage: blurhash)
.resizable()
.frame(minWidth: UIScreen.main.bounds.width, minHeight: preview ? 200 : 200, maxHeight: preview ? 200 : 200)
} else {
DamusColors.adaptableWhite
}
}
}
func titleImage(url: URL, preview: Bool) -> some View {
KFAnimatedImage(url)
.callbackQueue(.dispatch(.global(qos:.background)))
.backgroundDecode(true)
.imageContext(.note, disable_animation: state.settings.disable_animation)
.image_fade(duration: 0.25)
.cancelOnDisappear(true)
.configure { view in
view.framePreloadCount = 3
}
.background {
Placeholder(url: url, preview: preview)
}
.aspectRatio(contentMode: .fill)
.frame(minWidth: UIScreen.main.bounds.width, minHeight: preview ? 200 : 200, maxHeight: preview ? 200 : 200)
.kfClickable()
.cornerRadius(1)
}
var body: some View {
if let url = image {
if (self.options.contains(.no_media)) {
EmptyView()
} else {
titleImage(url: url, preview: preview)
}
} else {
Text("No cover image", comment: "Text letting user know there is no cover image.")
.bold()
.foregroundColor(.white)
.frame(width: UIScreen.main.bounds.width, height: 200)
.background(DamusGradient.gradient.opacity(0.75))
Divider()
}
}
}
@@ -0,0 +1,40 @@
//
// LiveStreamProfile.swift
// damus
//
// Created by eric on 8/8/25.
//
import SwiftUI
struct LiveStreamProfile: View {
var state: DamusState
var pubkey: Pubkey
var size: CGFloat = 25
var body: some View {
HStack {
ProfilePicView(pubkey: pubkey, size: size, highlight: .custom(DamusColors.neutral3, 1.0), profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true, damusState: state)
.onTapGesture {
state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
}
let profile = try? state.profiles.lookup(id: pubkey)
let displayName = Profile.displayName(profile: profile, pubkey: pubkey)
switch displayName {
case .one(let one):
Text(one)
.font(.subheadline).foregroundColor(.gray)
case .both(username: let username, displayName: let displayName):
HStack(spacing: 6) {
Text(verbatim: displayName)
.font(.subheadline).foregroundColor(.gray)
Text(verbatim: "@\(username)")
.font(.subheadline).foregroundColor(.gray)
}
}
}
.padding(5)
}
}

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