Compare commits

..

638 Commits

Author SHA1 Message Date
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
Daniel D’Aquino
719a0c8cb0 Merge pull request #3209 from damus-io/translations
Translations
2025-08-25 18:53:36 -07:00
89ad22833d Reduce default zap amount and deduplicate from preset zap amount items
Changelog-Changed: Reduced default zap amount and deduplicated from preset zap amount items
Closes: https://github.com/damus-io/damus/issues/3198
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-08-25 18:47:14 -07:00
transifex-integration[bot]
9407c75d60 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2025-08-25 14:43:27 +00:00
transifex-integration[bot]
c4e6e5e6a7 Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2025-08-25 00:36:13 +00:00
transifex-integration[bot]
592e9f9405 Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2025-08-25 00:36:02 +00:00
transifex-integration[bot]
d924485bb3 Translate InfoPlist.strings in zh_CN
100% translated source file: 'InfoPlist.strings'
on 'zh_CN'.
2025-08-24 13:56:16 +00:00
transifex-integration[bot]
b774f28427 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-08-24 01:22:28 +00:00
transifex-integration[bot]
deae6c0636 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2025-08-23 19:40:14 +00:00
transifex-integration[bot]
da386f3bcd Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-08-23 17:57:15 +00:00
55dbb46bb5 Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-08-23 11:08:07 -04:00
transifex-integration[bot]
dc8e647c34 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2025-08-23 11:05:14 -04:00
transifex-integration[bot]
eb25ff3584 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2025-08-23 11:05:14 -04:00
transifex-integration[bot]
0ae03fc3f3 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2025-08-23 11:05:14 -04:00
transifex-integration[bot]
e60f74eb9f Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2025-08-23 11:05:14 -04:00
transifex-integration[bot]
0d75f9cdd9 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2025-08-23 11:05:14 -04:00
transifex-integration[bot]
33a3ddbfd6 Translate Localizable.stringsdict in pt_PT
100% translated source file: 'Localizable.stringsdict'
on 'pt_PT'.
2025-08-23 11:05:14 -04:00
transifex-integration[bot]
6555531846 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-08-23 11:05:14 -04:00
transifex-integration[bot]
97b9d06774 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-08-23 11:05:14 -04:00
transifex-integration[bot]
198448b114 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-08-23 11:05:13 -04:00
transifex-integration[bot]
a0333058a6 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-08-23 11:05:13 -04:00
transifex-integration[bot]
e640d5185e Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-08-23 11:05:13 -04:00
transifex-integration[bot]
9723718bc5 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-08-23 11:05:13 -04:00
transifex-integration[bot]
08e19fd395 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-08-23 11:05:13 -04:00
transifex-integration[bot]
7f39c3c4b2 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-08-23 11:05:13 -04:00
transifex-integration[bot]
cd3314c068 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-08-23 11:05:12 -04:00
f73c0ec1c4 Add support for scanning nprofile QR codes
Changelog-Added: Added support for scanning nprofile QR codes

Closes: https://github.com/damus-io/damus/issues/2671
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-08-18 19:10:51 -07:00
Daniel D’Aquino
05b62c5860 Fix edge case around bolt11 invoice parsing
Changelog-None
Closes: https://github.com/damus-io/damus/issues/3190
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-13 12:54:46 -07:00
Daniel D’Aquino
fae061cec0 Fix MAX_PREFIX parameter on bolt11 parsing logic
Closes: https://github.com/damus-io/damus/issues/3187
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
4570ba797c Verify events at RelayConnection
This commit introduces a verification step at the relay connection
level, to help ensure notes get validated at the source and prevent
security issues associated with untrusted relays.

`RelayConnection.swift` — the source that initially handles WebSocket
messages — was analyzed, and measures were put in place to prevent
(or at least minimize) unverified nostr event data being spread
throughout the app.

The following measures were taken:
1. A note verification step was added prior to the `self.handleEvent(.nostr_event(ev))` call (which sends a Nostr response to the rest of the app for logical handling).
    a. From code analysis, there is only one such call in `RelayConnection.swift`.
2. `NostrConnectionEvent`, the object that gets passed to event handlers, had its interface modified to remove the "message" case, since:
    a. that could be a source of unverified nostr events.
    b. it is redundant an unneeded due to the `.nostr_event` case.
    c. there were no usages of it around the codebase
3. The raw websocket event handler had its label renamed to "handleUnverifiedWSEvent", to make it clear to the caller about the verification status of the data.
    a. Usages of this were inspected and no significant risk was detected.
4. A new `verify` method in NdbNote was created to verify Nostr notes, and unit tests were added to confirm tampering detections around all the major fields in a Nostr note.
5. Care was taken to ensure the performance regression is as little as
   possible.

It is worth noting that we will not need this once the local relay model
architecture is introduced, since that architecture ensures note
validation before it reaches the rest of the application and the user.

In other words, this is a temporary fix.

However, since the migration to that new architecture is a major
undertaking that will take some time to be completed, this fix was written
in order to address security concerns while the migration is unfinished.

This fix was written in a way that attempts to be as effective as
possible in reducing security risks without a risky and lenghty
refactor of the code that would delay the fix from being published.

Changelog-Fixed: Improved security around note validation
Closes: https://github.com/damus-io/damus/issues/1341
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
d1ea081018 Fix regressions in note content rendering logic
Changelog-None
Closes: https://github.com/damus-io/damus/issues/3150
Closes: https://github.com/damus-io/damus/issues/3158
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
682704b2cb Fix quoted note regression
This fixes a regression that caused quoted notes not to appear.

Changelog-None
Closes: https://github.com/damus-io/damus/issues/3163
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
176f1a338a Fix app swap crash
This commit fixes a crash that occurred when swapping between Damus and
other apps.

When Damus enters background mode, NostrDB is closed and its resources
released. When Damus re-enters foreground mode, NostrDB is reopened.

However, an issue with the transaction inheritance logic
caused a race condition where a side menu profile lookup would get an
obsolete transaction containing pointers that have been freedwhen
NostrDB was closed, causing a "use-after-free" memory error.

The issue was fixed by improving the transaction inheritance logic to
double-check if the "generation" counter (which auto increments when
Damus closes and re-opens) matches the generation marked on the
thread-specific transaction. This effectively prevents lookups from
inheriting an obsolete transaction from a previous NostrDB generation.

Closes: https://github.com/damus-io/damus/issues/3167
Changelog-Fixed: Fixed an issue where the app would crash when swapping between apps
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
fc1eb326e8 Render profile bios
Note: This brings us closer to feature parity with the master branch, so there
is no changelog item to be added

Closes: https://github.com/damus-io/damus/issues/3156
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
5e420187e0 Fix highlight comment rendering
Closes: https://github.com/damus-io/damus/issues/3129
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
4815c8a6f7 Fix nprofile parsing failure
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
f42ae0673d Reword subscript out-of-bounds assertion
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
474e2d8d57 Disable bai kanji test
To be fixed on https://github.com/damus-io/damus/issues/3154

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
95a91bed7e Disable invoice block parsing tests
It was decided on a standup meeting that this feature is not important
and failing tests can be disabled.

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
ff12d8bd7e Prevent crash from ndb search test
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
f8245a7b0e Update Invoice tests to use the new blocks interface, and fix reverse blocks iteration indexing
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
4036995348 Remove deprecated nrelay uses from tests
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
5b6534fd56 Fix stack corruption in bech32 parsing
This commit fixes a stack corruption issue caused by
an off-by-one error in one of the functions responsible
for parsing bech32 entities.

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
bdd10cccaa Do not show images twice
This commit fixes a logical error in the blocks rendering function.

Changelog-None
Closes: https://github.com/damus-io/damus/issues/3133
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
e9f4cbe881 Make NdbBlock ~Copyable for better lifetime safety
Changelog-None
Closes: https://github.com/damus-io/damus/issues/3127
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
91abd187d3 Improve lifetime handling in collectBlocks
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
b9d8b1dbf3 Fix blocks_size calculation
Previously two addresses from different memory regions were being
subtracted, which will lead to the incorrect number. This commit
improves the calculation.

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
12a7b483a0 Fix incorrect buffer size argument in block parsing
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
caa7802bce Fix broken DM rendering
Currently NostrDB does not seem to handle encryption/decryption of DMs.
Since NostrDB now controls the block parsing process and fetches note
contents directly from the database, we have to add a specific condition
that injects decrypted content directly to the ndb content parser.

This is done in conjunction with some minor refactoring to `NdbBlocks`
and associated structs, as in C those are separated between the content
string and the offsets for each block, but in Swift this is more
ergonomically represented as a standalone/self-containing object.

No changelog entry is added because the previously broken version was
never released to the public, and therefore this fix produces no
user-facing changes compared to the last released version.

Changelog-None
Closes: https://github.com/damus-io/damus/issues/3106
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
9c47d2e0bd Temporarily disable broken tests
Some tests have been broken at some point during the nostrdb migration.
Disable them for now and address them later
(https://github.com/damus-io/damus/issues/3112)

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
5cd5a249ce Add justfile
This makes it easier to work from the command line when needed

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
c86b3a999d Enable address sanitizer for debug configuration
NostrDB relies on manual memory management, so it is a good idea to
enable the address sanitizer on debug configurations, as it helps find
memory-related issues on the app, which will allow us to identify memory
issues and potential crashes earlier in the development process.

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
b5afa3c0b4 Wait for note in NostrDB before rendering it
Closes: https://github.com/damus-io/damus/issues/2885
Changelog-Changed: Use NostrDB for rendering note contents
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
8f32c81b6c Create NostrDB streaming and async lookup interfaces
This commit introduces new interfaces for working with NostrDB from
Swift, including `NostrFilter` conversion, subscription streaming via
AsyncStreams and lookup/wait functions.

No user-facing changes.

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
William Casarin
f8185d0ca5 fixes
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
eb99584501 project: remove some references
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
919f644cba add assert to catch potential bug
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
690e1347e0 test: fix broken tests
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
744bf4bb07 ndb: add subscription callback initializers
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
475940aa01 Fix relay compile issue
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
28a06af534 Switch over to use use blocks from nostrdb
This is still kind of broken until queries are switched over to nostrdb.
Will do this next

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
208b3331ca optimized id matching function
doesn't need to create a copy of the id

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
5b1f0c4714 c: remove some unused files from project
some binding dir stoppers, and configurator
2025-08-11 16:40:01 -07:00
William Casarin
249e765642 c: re-add damus-only C stuff 2025-08-11 16:40:01 -07:00
William Casarin
712624f515 nostrdb: fix iOS crash on latest version
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
6e7b3b94d7 nostrdb: cleanup previous patch
I wanted to not amend this since we've already applied
it in the nostrdb-update branch on damus and I don't want
to conflict

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
Daniel D’Aquino
969a2b656e nostrdb: Fix heap buffer overflow
The Address Sanitizer detected a heap buffer overflow during a memcpy operation
in nostrdb.c associated with note parsing.

It was found that not enough memory was being allocated to the buffer to
support all the content parsing.

Allocation size was increased to support the memory needed for the
parsing operations. However, the new number was not carefully calculated
as we will not run into this code path once we switch to the local relay
model.

Changelog-Fixed: Fixed memory error in nostrdb
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-08-11 16:40:01 -07:00
William Casarin
d8e7b4707e nostrdb: nip19: add kind to naddr & nevent
Add support for type KIND for bech32-encoded entities naddr and nevent
as specified in NIP-19.

Co-authored-by: kernelkind <kernelkind@gmail.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
a51618cfd3 nostrdb: print-search-keys: add size of key information
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
82da5da4d3 nostrdb: fix compile issues on macOS
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
37f9c93705 nostrdb: Implement nip50 fulltext searching
This adds support for nip50 fulltext searches. This allows you to use
the nostrdb query interface for executing fulltext searches instead of
the typical `ndb_text_search` api. The benefits of this include a
standardized query interface that also further filters on other fields
in the filter.

Changelog-Added: Add nip50 search filters and queries
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
094cf5e8cc nostrdb: nip50: add filter argument to fulltext search
Update fulltext search queries to include an optional filter. This can
be used to narrow down the fulltext search. This is another step towards
nip50 support in nostrdb.

I noticed the code was exiting dubiously in certain situations... so we
fix that as well. It's possible we were missing search results because
of this.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
46541694a0 nostrdb: search: sort search terms from largest to smallest
Add a helper for sorting search words from largest to smallest. This
should help search performance. For example, let's say our search index
is like so:

"the pokemon is cool"

the
the
the
...
* 1000

Our root word search would have to start 1000 new recursive queries. By
sorting by the largest word:

pokemon
pokemon
pokemon
...
* 10

We only have to do 10 recursive searches, assuming larger words are less
common, which will likely be the case most of the time

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
04d4ff4e99 nostrdb: refactor: a few small formatting changes
No functional changes, just formatting cleanups

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
2d02766461 nostrdb: filter: add ndb_filter_find_search helper
This can be used to quicky pull the search string
from a filter

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
1e6873c879 nostrdb: nip50: add support for search field in filters
We will be using this for our nip50 search support

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
d3496af5cc nostrdb: filter: fix ndb_filter_init_with and make public
This fixes an allocation issue with ndb_filter_init_with for small
page sizes. instead of allocating the buffer around pages, we allocate
based on total buffer size.

Fixes: f7aac3215575 ("filter: introduce ndb_filter_init_with")
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
ec798bdeb2 nostrdb: debug: fix debug logs
We forgot to move one DEBUG instance to NDB_LOG

Fixes: b4c2ff3d270a ("Only log to stdout if NDB_LOG is defined")
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
fa9b952295 nostrdb: add is_replaceable_kind helper
we will be using this to detect replaceable kinds

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
27f55bc09f nostrdb: refactor: use kind variable for clarity
almost no reason to do this, but whatever

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
52845a52bb nostrdb: remove ndb_writer_queue_note (dead code)
This doesn't seem to be used at all

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
4e27cca12b nostrdb: filter: introduce ndb_filter_init_with
Just a static function for now for creating smaller filter sizes. We
will use this for filters that we know are small so that we don't have
to allocate so many pages at once. It's likely the OS will only allocate
a single page anyways, but its nice to be explicit.

Changelog-Added: Add ndb_filter_init_with
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
franzap
98e9ba25da nostrdb: bug: use indices[i] as index is not defined
Closes: https://github.com/damus-io/nostrdb/pull/66
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
Ken Sedgwick
e6cb6c938b nostrdb: Only log to stdout if NDB_LOG is defined
Closes: https://github.com/damus-io/nostrdb/pull/64
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:01 -07:00
William Casarin
af5961ce26 nostrdb: query: add missing since check to kind query
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
58de0025aa nostrdb: monitor: lock monitor when we're freeing subscriptions
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
c931108741 nostrdb: subs: fix memory leak in ndb_subscribe
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
20255198fd nostrdb: bug: add missing break statement
probably harmless but it writes the note twice...

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
289a8e262a nostrdb: migrations: make migrations asyncronous
This also seems to fix some issues with older migrations.

Fixes: https://github.com/damus-io/nostrdb/issues/58
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
05baba9c03 nostrdb: flags: make some indexes optional
Make fulltext indices and note blocks optional. This will be useful for
quickly building databases when testing, since more stuff in the write
queue when writing can slow things down.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
e0461d3458 nostrdb: writer: rename any_note to needs_commit
This is a bit more clear as to what this variable actually means

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
62aa72c215 nostrdb: leak: fix memory leak when failing to write like stats
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
287b35a8fb nostrdb: migration: dont fail v3 -> v4 on 0 migrations
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
478d7b4060 nostrdb: add authors query plan
This fixes author queries

Fixes: https://github.com/damus-io/nostrdb/issues/52
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
2c4728508b nostrdb: earlier since check in ndb_query_plan_execute_created_at
this avoids a lookup if we dont need it

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
d24a3f0ce5 nostrdb: simplify ndb_query_plan_execute_ids
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
efba599779 nostrdb: ids: fix typo in ndb_query_plan_execute_ids
We should be specifying that we've matched the id here, not authors. Not
that this would have any effect.. but still.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
19243d49e1 nostrdb: always show migration text
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
6845d0df47 nostrdb: migrate notes to have pubkey indices
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
8e79ad582a nostrdb: add note pubkey and pubkey_kind indices
We need these for profile queries

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
282c02eed4 nostrdb: add ndb_db_is_index
This function can be used to check if a db is an index or not. We
will use this in future functions that rebuild indices.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
155ac27bb5 nostrdb: introduce ndb_id_u64_ts
This will be the key used by our note_profile_kind indee

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
be1d149f4b nostrdb: misc: move some functions around
because this will make the changes nicer

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
9e0dc47e98 nostrdb: rename: ndb_u64_tsid to ndb_u64_ts
technically more accurate. we are about to introduce a new type called:

	ndb_ts_u64_id

which would be confusing if we didn't do this

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
0916b14b32 nostrdb: make the subscription monitor threadsafe
This was the only thing that wasn't threadsafe. Add a simple mutex
instead of a queue so that polling is quick.

This also means we can't really return the internal subscriptions
anymore, so we remove that for now until we have a safer
interface.

Fixes: https://github.com/damus-io/nostrdb/issues/55
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
6818d001f2 nostrdb: mem: reduce default queue size
This was overkill and was using lots of memory

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
4bf9160502 nostrdb: fix heap corruption on windows
windows thinks this is heap corruption... so I
guess we have to trust it.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
02df1e209b nostrdb: windows: fix threading bugs
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
3186b0e1d3 nostrdb: fix windows build
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
de0935582c nostrdb: ndb_filter_{eq,is_subset_of}: make interfaces const
this makes rust happier

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
573de6b881 nostrdb: ndb_filter_is_subset_of
subset testing for filters. Can be used to see if one subset is
redundant in the presence of a another in the local relay model

Changelog-Added: Add ndb_filter_is_subset_of
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
44ab702792 nostrdb: add ndb_filter_eq
filter equality testing. this works because field elements are sorted

Changelog-Added: Add ndb_filter_eq for filter equality testing
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
1fdf234c46 nostrdb: rename get_elems to find_elements
This is more accurate

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
3018200e95 nostrdb: add ndb_subscription_filters
Expose a way to get the set of filters for a subscription. On the rust
side, we should likely ndb_filter_clone each filter asap, because the
result of this function will only be valid up until the subscription
ends.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
47b79fc02e nostrdb: ingest: support kind 6 reposts
This also enables processing raw json via ndb import

Fixes: https://github.com/damus-io/nostrdb/issues/46
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
0c483bb55a nostrdb: print search keys to stdout
otherwise it's way too annoying to grep

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
ddd30054e8 nostrdb: nostrdb: fix ndb_builder_find_str.
This will find strings which match the beginning of other strings,
which seems wrong.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
30c5225ed0 nostrdb: content_parser: fix incorrect comment.
Sure, this format would be nice, but it's not what the code does.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
8c446f804c nostrdb: filter: retain const variant of get_int_elemnet
otherwise rust gets bitchy at as

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
e92018aee5 nostrdb: filter: allow mutable int elements
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
cfb140472d nostrdb: bolt11: remove unneeded fields.
If we make unknown_field simply discard, we can remove decoders and
have them discard those fields.

Now we can cut down struct bolt11 to only the fields needed by
invoice.c, and also speed up parsing a little.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
2f5fd54297 nostrdb: bolt11: update to latest version from CLN
Copy the latest, which has parsing fixes.  We make a new explicit
"bolt11_decode_minimal" which doesn't check sigs, rather than neutering
the bolt11_decode logic.

As a bonus, this now correctly parses "LIGHTNING:BECH32..." format
(upper case, with prefix).

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
02e970eb9b nostrdb: Makefile: fix missing dependencies on bolt11 headers.
I wondered by `make check` was giving strange errors, until I realized it wasn't fully rebuilding.

Also, remove leftover CCAN files I missed previously.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
b4b84e6895 nostrdb: resync with repo
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
7831ede057 nostrdb: ccan: update to latest.
Only change for us: CCAN_TAL_NEVER_RETURN_NULL can be defined if
we don't override tal error handling.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
a8d7d971b1 nostrdb: ccan: sync with normal versions.
This is the version of CCAN which CLN was using at the time these
were taken.  Unfortunately lots of whitespace has been changed,
but AFAICT no source changes.

Here's the command I ran (with ../ccan checked out to 1ae4c432):

```
make update-ccan CCAN_NEW="alignof array_size build_assert check_type container_of cppmagic likely list mem short_types str structeq take tal tal/str typesafe_cb utf8 endian crypto/sha256"
```

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
201cdd7edc nostrdb: Makefile: build using ccan/ versions of files.
Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
e3ca6ca5b4 nostrdb: bolt11: move utf8_check into local function.
It isn't actually in the CCAN module (though it probably should be!).
So it breaks when we update.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
494386d211 nostrdb: ccan: copy ccan files into their own subdirectory.
This lets them be updated/bugfixed together.  I just copied them for now,
didn't change anything else.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
6c53bc75f2 nostrdb: content_parser: fix blocks_size
we are crossing cursors

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
6001063754 nostrdb: nostrdb: fix ndb_builder_find_str.
This will find strings which match the beginning of other strings,
which seems wrong.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
eb0a1ee807 nostrdb: content_parser: fix incorrect comment.
Sure, this format would be nice, but it's not what the code does.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
827731b9cb nostrdb: filter: retain const variant of get_int_elemnet
otherwise rust gets bitchy at as

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
56d44d0004 nostrdb: filter: allow mutable int elements
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
7742c8fb3c nostrdb: bolt11: remove unneeded fields.
If we make unknown_field simply discard, we can remove decoders and
have them discard those fields.

Now we can cut down struct bolt11 to only the fields needed by
invoice.c, and also speed up parsing a little.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
7f2ee78512 nostrdb: bolt11: update to latest version from CLN
Copy the latest, which has parsing fixes.  We make a new explicit
"bolt11_decode_minimal" which doesn't check sigs, rather than neutering
the bolt11_decode logic.

As a bonus, this now correctly parses "LIGHTNING:BECH32..." format
(upper case, with prefix).

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
4d75894bc4 nostrdb: Makefile: fix missing dependencies on bolt11 headers.
I wondered by `make check` was giving strange errors, until I realized it wasn't fully rebuilding.

Also, remove leftover CCAN files I missed previously.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
bbed448ccb nostrdb: ndb_filter_from_json
Changelog-Added: Add method for parsing filter json
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
3fb4d81d48 nostrdb: src: delete copies outside ccan/ dirs.
Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
fc30b68c40 nostrdb: Makefile: build using ccan/ versions of files.
Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Rusty Russell
0ac25b7aa3 nostrdb: bolt11: move utf8_check into local function.
It isn't actually in the CCAN module (though it probably should be!).
So it breaks when we update.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
b326f007f2 nostrdb: expose filter introspection methods
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
a86d8416fc nostrdb: expose ndb_filter_get_elements
This can be used to iterate though filter elements

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
b5c57dc935 nostrdb: make more things const
rust is happier this way

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
7d6814a481 nostrdb: add ndb_filter_json method
Changelog-Added: Add ndb_filter_json method for creating json filters
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
8dd048681b nostrdb: Fix issue where id tag filters are pushed as strings
When creating filters, sometimes IDs are pushed as strings, so if there
is ever a 0 byte, the id prematurely ends, causing the filter to not
match

Fixes: https://github.com/rust-nostr/nostr/issues/454
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
2d02a17af6 nostrdb: fix bech32 parsing and add test
was off by one

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
3171959d85 nostrdb: debug: improve tag index display
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
bca3716e33 nostrdb: fix note content parsing bug with damus.io urls
Changelog-Fixed: Fixed bug where non-bech32 damus io urls would cause corruption
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
57db252783 nostrdb: ndb_note_json: return length
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
319579f912 nostrdb: ndb: dump json in filters and fulltext queries
This is much more useful

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
92e1e4b08f nostrdb: api: add ndb_note_json
add a way to write an ndb note as json to a buffer

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
ffc50bb2c1 nostrdb: fix realloc corruption
can't figure out why this is happening, but let's disable it for now
while we test. we shouldn't hit this code path anyways once we switch
over to local notes in damus ios

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
a562be009d nostrdb: add ability to register a subscription callback
Since Damus iOS is not an immediate-mode UI like android, we would
rather not poll for results. Instead we need a way to register a
callback function that is called when we get new subscription results.

This is also useful on the android side, allowing us to request a new
frame to draw when we have new results, instead of drawing every second.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
30c9bc7db7 nostrdb: add ndb_unsubscribe
We didn't have a way to unsubscribe from subscriptions. Now we do!

Apps like notecrumbs may open up many local subscriptions based on
incoming requests. We may need to make the MAX_SUBSCRIPTIONS size much
larger, but this should be okish for now.

Changelog-Added: Add ndb_unsubscribe to unsubscribe from subscriptions
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
0ac03df841 nostrdb: build: fix compile warning
A small size_t/uint64 conversion issue

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
db99b4f4d4 nostrdb: fix dubious looking parens logic
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
cc9585b6e3 nostrdb: plan: use a less efficient plan for author query plans
This is less efficient for now but we don't have a small-author-list
query plan yet.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
bd17dcfac6 nostrdb: plan: add created_at query plan
This introduces the basic created_at query plan. We scan the created_at
+ id index in descending order looking for items that match a filter.
This is a very general query plan, but might not be very efficient for
anything other than local timelines.

Changelog-Added: Add general created_at query plan for timelines
Closes: https://github.com/damus-io/nostrdb/issues/26
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
25e91b386c nostrdb: cores: just set to 2 on unknown platforms
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
560e9e53cd nostrdb: fix a few note size compile issues
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
1c1e5fa2a0 nostrdb: random: add getrandom fallback for android
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
2d5f86b142 nostrdb: filter: make sure clone copies metadata
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
89686d758a nostrdb: filter: make sure to return clone errors
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
6c26add1da nostrdb: filter: add ndb_filter_clone
Clone filters when moving them into subscriptions. This will allow us to
fix the double free issue on the rust side.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
3c5a83392e nostrdb: filter: use relative data offsets for easy cloning
Instead of storing exact pointers inside of our filter elements, just
store offsets. This will allow us to clone filters very easily without
having to mess around with fixing up the pointers afterwards.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
1c63c3b9bb nostrdb: filter: add ndb_filter_end
This is a pretty scary looking function that realloc our large variable
filter buffer into a compact one. This saves up a bunch of memory when
we are done building the filter.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
0bd4717e01 nostrdb: query: include note size in query results
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
bebd531b58 nostrdb: return number of items popped when polling
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
5788c077c4 nostrdb: silence annoying debug
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
1b77b4f0e0 nostrdb: filters: copy filter metadata into subscription
This fixes a few ownership issues

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
62625c6ff3 nostrdb: ndb: add ndb_poll_for_notes
The polling variant of ndb_wait_for_notes. This makes more sense for
realtime apps like notedeck

Changelog-Added: Add ndb_poll_for_notes
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
c8d88058d4 nostrdb: queue: switch to prot_queue_try_pop_all
This allows you to `try pop` multiple items instead of 1

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
b8bef86ea1 nostrdb: port kernelkind's to the new bech32 parser
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
b128330b2a nostrdb: tce: fix build for previous TCE change
Fixes: 34093cd1 ("tce: add AUTH to-client-event")
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
934ea80f85 nostrdb: blocks: add word count interface
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
588cebd18d nostrdb: header: add ptr helpers for swift
swift is kind of dumb when it comes to opaque pointers

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
William Casarin
ccca6e58ec nostrdb: strblock: add typedef
I don't technically need this but it helps a lot on the swift side
of things since I already have code that uses this identifier of a
similar structure

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:40:00 -07:00
Charlie Fish
c1befa5221 nostrdb/tce: add AUTH to-client-event
This was committed to damus, but this should be in nostrdb or else we
will lose it when we update.

Damus: 84cfeb1604 ("nip42: add initial relay auth support")
Link: https://groups.google.com/a/damus.io/g/patches/c/Zx3dk01e0yg/m/t59TsVkXAQAJ
Signed-off-by: Charlie Fish <contact@charlie.fish>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
8b3c86c5de nostrdb/query: add tag index and tag queries
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
05c5a6dacb nostrdb/filter: don't end field if we don't have one active
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
1a6568deca nostrdb/perf: add some flamegraph helpers to makefile
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
1b2f4c41df nostrdb/fix macos build
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
25bcf9c243 nostrdb/ndb: measure query performance
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
3993679cc0 nostrdb/query: support until for kind query plans
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
e302bf37fa nostrdb/ndb: add inital query command
still very early, but works for kinds!

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
a45f4d3087 nostrdb/Query Plans
Instead of running queries off filters directly, we do some simple
heuristics and determine a reasonable query plan for the given filter.

To test this, also add a kind index query plan and add a test for it.

We still need tag, author, and created_at index scans. This is up next!

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
d598e178c1 nostrdb/index: make sure kind index is DUPSORT + INTEGERDUP
We will probably need a migration for this?

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
77601e77ee nostrdb/filter: rename FILTER_GENERIC to FILTER_TAG
it's a bit more intuitive

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
206efba58a nostrdb/cleanup: remove old dbscan stuff
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
a84749cd07 nostrdb/debug: add print_kind_keys helper
I needed this for debugging kind queries

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
shuoer86
099b588be2 nostrdb/Fix typos
Closes: https://github.com/damus-io/nostrdb/pull/25
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
75c7adddb8 nostrdb/query: implement kind queries
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
9f1b9ab945 nostrdb/Initial nostrdb queries
Still a lot more work to do, but this is at least a proof of concept for
querying nostrdb using filters.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
b2080a946e nostrdb/cursor: fix bug when pushing last element
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
942e47a720 nostrdb/query: extract ndb_cursor_start
This is useful for positioning LMDB cursors at the start of a query. We
will be re-using this in the upcoming query code

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
6dbf3416b9 nostrdb/cursor: remove old array code
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
2b14acd62f nostrdb/filter: don't allow adding id elements on kinds
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
267a9ac54b nostrdb/ocd: small cleanup
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
8b03ed6175 nostrdb/filters: remove ndb_filter_group from public API
We can just use a list of filters instead when subscribing

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
6cd7b945ca nostrdb/filter: use binary search for large contact list filters
This is much more efficient than linear scans

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:59 -07:00
William Casarin
e5e6735129 nostrdb/filter: sort filter elements
If they are sorted we can do binary search when matching filters like
how strfry does it.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
9c2f7a931c nostrdb/subs: always fail when calling wait_for_notes on a subid of 0
this is an invalid subscription id

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
b1bbf355de nostrdb/subs: notify on profile notes as well
We missed this in the original subscription code

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
d7a2064786 nostrdb/debug: add a few more debug statement
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
4d14ca8d0a nostrdb/filters: add ndb_filter_group_init function
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
81d65cd5bf nostrdb/subs: subs and monitor cleanup
We need to free these resources when we're done with them.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
f03d8a5ac9 nostrdb/search: don't enforce sequential tokens
This makes it a bit more flexible, but maybe we can add quoting in the
future that re-enables this. Or maybe a search option

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
0df18ae1a4 nostrdb/test: switch reaction test to use subscriptions
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
8c5ec32eaa nostrdb/Initial nostrdb relay subscriptions
This adds some initial code for the nostrdb relay subscription monitor.

When new notes are written to the database, they are checked against
active subscriptions. If any of the subscriptions are matched, the note
primary key is written to the inbox queue for that subscription.

We also add an ndb_wait_for_notes() method that simply waits for notes
to be written by the subscription monitor.

Changelog-Added: Added filter subscriptions
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
bdedf8bd8c nostrdb/disable lmdb download
since we have this committed now

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
c2383060aa nostrdb/blocks: add ndb_blocks_flags function
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
432cdb96d9 nostrdb/fix: don't write the owned flag to the DB
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
f580c7dd93 nostrdb/fix clang compile issue
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
c677233dcb nostrdb/blocks: expose block iterator internals
so we don't need heap allocation. we will be calling this a lot in tight
render loops, we don't want to be allocating on each frame.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
d063362bd7 nostrdb/blocks: write note blocks on ingest
When ingesting notes, parse text/longform contents and store them in nostrdb.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
088683696a nostrdb/blocks: actually set the note block version
Version 1 to start

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
f2795aa71c nostrdb/blocks: add ndb_blocks_free
In some situations we will need to have owned note blocks. For
example, when we try to fetch note blocks from the database and it's not
there yet. We will need to parse the content on the spot and return an
owned copy, since it will not be immediately available in the database.

Add a new flag field to note blocks that lets us know if it's owned by
malloc or nostrdb.

We the add a free function that checks this flag and frees the object if
its set. If it is not set then it doesn nothing because it likely came
from the database.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
c831976078 nostrdb/blocks: add total_size
Fix this mistake that we have with ndb_notes where we don't know the
total size of the object

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
c2c73c3af6 nostrdb/header: move bech32 around
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
971fa3e4ef nostrdb/invoice: fix crash in any-amount invoice parsing
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
dfa145dd4a nostrdb/parser: fix bech32 block decoding
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
4cfe28d802 nostrdb/bech32: fix big in bech32 size parsing
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
034f2cc02f nostrdb/blocks: add note block iterator
This adds an api that walks along and pulls compact note block data out of
nostrdb.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
kernelkind
6f9bd6c4f4 nostrdb/parser: handle period at end of url
Fix parsing URL when encountering a period at the end of the url by
setting it as disallowed from being present at the end of a
URL.

Some characters are disallowed to be present at the end of URLs.
Presently, the period character is the only disallowed character.
A character is the last character in the URL if it is followed by
is_whitespace() or if it's the last character in the string.

Signed-off-by: kernelkind <kernelkind@gmail.com>
Tested-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb5.com>
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
d73422db38 nostrdb/content_parser: add initial db decoders
We need to pull the data out as well! Let's add some initial decoders.
We still need tests to make sure it's working.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
c3b06d281e nostrdb/bech32: add some initial tests
since we modified this recently, let's add some tests to make sure
we didn't break anything

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
1b09e9458c nostrdb/nostr_bech32: parse in one pass
since we will be decoding these in realtime, let's make sure we can
decode them in O(1)

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
e0a2dcf3db nostrdb/Inital embedded content parser
This adds some initial code for nostrdb content parsing.

We still need to write tests for encoding and decoding, so this is
likely not working yet.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
9ff1f69a82 nostrdb/search: switch to cursor_align function
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
623b8603c2 nostrdb/cursor: add align function
handy function for padding buffers to some byte alignment

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
d8b083010d nostrdb/cursor: fix some warnings
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
887eb4e1e2 nostrdb/cursor: fix empty string pushing in push_c_str
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
b5ad3ed1a5 nostrdb/cursor: add pull_varint_u32
This is a varint helper that doesn't pull larger than uint32

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
371e9fb406 nostrdb/cursor: add malloc_slice
This is the same as cursor_slice except we don't memset afterwards

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
aa5809d792 nostrdb/nostr_bech32: only parse up to raw bech32 buffers
We will be storing raw nostr bech32 buffers directly into nostrdb, so
adapt our bech32 code to reflect this.

When doing our content parsing pass, we will only look for strings and we
won't allocate any intermediate buffers. Only when we write this string
block to nostrdb will we actually allocate in our nostrdb output buffer
(no mallocs!)

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
30ba0d72cc nostrdb/bech32: retab
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
373cd71f69 nostrdb/block: add bolt11 invoice encoding/decoding
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
acaf327a07 nostrdb/make: cleanup a bit, separate bench running
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
9f0bf7dff5 nostrdb/fix github action
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
88d7eb8a86 nostrdb/fix build
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
76862776b8 nostrdb/varint: switch to 64 bit varints
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
4c55459c1f nostrdb/test: disable migrate for now
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
f7cdc7bc31 nostrdb/cursor: re-apply infinite loop bug fix
since I keep overwriting it by accident

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
1bc4971111 nostrdb/add libnostrdb.a
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
6ce6c79160 nostrdb/add initial content parser
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
1ffbd80c67 nostrdb: move everything to src
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
1fb88a912a nostrdb: port everything over to be in as sync as possible
for now
2025-08-11 16:39:43 -07:00
William Casarin
954f48b23d c: move c files into nostrdb in prep for switchover 2025-08-11 16:39:43 -07:00
William Casarin
cc75a8450a nostrdb: add supporting files for the bolt11 parser
A lot of this was pulled from core-lightning. Not sure what is actually
needed or not.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:43 -07:00
William Casarin
389c2c9695 nostrdb: add supporting files before the move commit 2025-08-11 16:39:42 -07:00
William Casarin
4a6121ba13 c: move compiler to nostrdb dir
we will be applying a patch here as well
2025-08-11 16:39:42 -07:00
William Casarin
a469f2e127 nostrdb/re-apply ispunct crash fix
since it was overwritten when we synced with damus

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:42 -07:00
William Casarin
2f8f18b846 nostrdb/build: fix constness on config pointer in ingester thread
otherwise build fails

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:42 -07:00
William Casarin
3a7cf4d08d nostrdb/rust: initial api for Ndb and NdbConfig
This is the start of our rust library for nostrdb. Implement idiomatic
interfaces for Ndb and NdbConfig.

Changelog-Added: Add initial rust library
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:42 -07:00
William Casarin
e3001cc240 nostrdb/cursor: fix warning that build.rs is complaining about
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:42 -07:00
William Casarin
d1ef113a8b nostrdb/api: don't expose many internals, like note
rust doesn't like packed structures, so hide this from bindgen

This also buttons up the API so less things are exposed which is good.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:42 -07:00
William Casarin
f187f4f8f2 c: move cursor.h to nostrdb subdir
everything will be in here soon
2025-08-11 16:39:42 -07:00
William Casarin
4e9583ef54 nostrdb/stream: actually use file pointer in stream api
Right now it's accidently hardcoded.

Fixes: 8376e5bca05c ("add "import -"")
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:42 -07:00
Yasuhiro Matsumoto
cc95d5df6e nostrdb/add "import -"
Closes: https://github.com/damus-io/nostrdb/pull/21
Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:42 -07:00
William Casarin
4ca156fd83 nostrdb/build: fix additional compiler errors
When trying to build from rust

Signed-off-by: William Casarin <jb55@jb55.com>
2025-08-11 16:39:42 -07:00
askeew
9f6da8eb79 Fix display issues with pasted or uploaded images
- Fix aspect ratio, use fit
- Remove fixed height on image frame to align close button on image
- Use overlay instead of ZStack to reduce complexity
- Add background to close button to get better contrast in light mode
- Change close-image to be a button for better accessibility

Changelog-Fixed: Fix aspect ratio on pasted or uploaded images
Signed-off-by: Askeew <askeew@hotmail.com>
Closes: https://github.com/damus-io/damus/issues/2913
2025-08-08 16:31:30 -07:00
ericholguin
65a22813a3 refactor: Adding structure
Huge refactor to add better structure to the project.
Separating features with their associated view and model structure.
This should be better organization and will allow us to improve the
overall architecture in the future.

I forsee many more improvements that can follow this change. e.g. MVVM Arch
As well as cleaning up duplicate, unused, functionality.
Many files have global functions that can also be moved or be renamed.

damus/
├── Features/
│   ├── <Feature>/
│   │   ├── Views/
│   │   └── Models/
├── Shared/
│   ├── Components/
│   ├── Media/
│   ├── Buttons/
│   ├── Extensions/
│   ├── Empty Views/
│   ├── ErrorHandling/
│   ├── Modifiers/
│   └── Utilities/
├── Core/
│   ├── Nostr/
│   ├── NIPs/
│   ├── DIPs/
│   ├── Types/
│   ├── Networking/
│   └── Storage/

Signed-off-by: ericholguin <ericholguin@apache.org>
2025-08-06 10:24:00 -07:00
fdbf271432 Add relay count and relay view to events
Changelog-Added: Added relay count and relay view to events

Closes: https://github.com/damus-io/damus/issues/1029
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-07-21 16:45:49 -07:00
b26eedc633 Fix note content rendering to not remove whitespace before hashtag
Changelog-Fixed: Fixed note content rendering to not remove whitespace before hashtag

Closes: https://github.com/damus-io/damus/issues/3122
Fixes: f436291209 ("Fix note content rendering to not remove whitespace before hashtag")
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-07-21 16:37:36 -07:00
793970beaf Add relay hints to tags and identifiers
Changelog-Added: Add relay hints to tags and identifiers
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-07-18 15:51:25 -07:00
transifex-integration[bot]
049d9170be Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2025-07-18 14:52:31 -07:00
transifex-integration[bot]
fd10c5672a Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2025-07-18 14:52:31 -07:00
transifex-integration[bot]
37bd9447f0 Translate Localizable.stringsdict in de
100% translated source file: 'Localizable.stringsdict'
on 'de'.
2025-07-18 14:52:31 -07:00
transifex-integration[bot]
e8457d7486 Translate Localizable.stringsdict in de
100% translated source file: 'Localizable.stringsdict'
on 'de'.
2025-07-18 14:52:31 -07:00
transifex-integration[bot]
280297ad35 Translate Localizable.stringsdict in de
100% translated source file: 'Localizable.stringsdict'
on 'de'.
2025-07-18 14:52:31 -07:00
transifex-integration[bot]
7da3ead01e Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2025-07-18 14:52:31 -07:00
3ddb2625e9 Fix #nsfw tag filtering to be case insensitive
Closes: https://github.com/damus-io/damus/issues/3131

Changelog-Fixed: Fixed #nsfw tag filtering to be case insensitive
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-07-11 10:48:10 -07:00
Swift
f53ffae767 Fix stretchy banner header in Edit profile
Put the views into ScrollView
Fixed banner offset in Geometry reader

Changelog-Fixed: Fixed stretchy banner header in Edit profile
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2025-07-09 15:06:23 -07:00
Daniel D’Aquino
b9168f9914 Merge pull request #3121 from damus-io/translations
Translations
2025-07-09 10:43:04 -07:00
Daniel D’Aquino
63ff2b6f9e ui: Stabilize ImageCarousel height when swiping between images
This commit enhances the ImageCarousel component to maintain a consistent
height when navigating between images of different aspect ratios. The
changes prevent the UI from "jumping" during carousel navigation, which
improves the overall user experience.

Key improvements:
- Added `first_image_fill` property to store dimensions of the first image
- Modified height calculation to prioritize the first image's dimensions
- Refactored image fill calculation into a reusable `compute_item_fill` method
- Added proper view clipping to prevent content overflow
- Simplified filling behavior for more predictable layout

These changes provide a smoother, more stable carousel experience by
maintaining consistent dimensions throughout image navigation.

Changelog-Changed: Improved the image sizing behavior on the image carousel for a smoother experience
Closes: https://github.com/damus-io/damus/issues/2724
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-07-09 10:26:32 -07:00
transifex-integration[bot]
7d9468388b Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-07-08 05:48:12 +00:00
transifex-integration[bot]
66b555e0ff Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-07-07 22:04:41 -04:00
transifex-integration[bot]
8df332472c Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-07-07 22:04:41 -04:00
transifex-integration[bot]
6072668438 Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2025-07-07 22:04:41 -04:00
transifex-integration[bot]
6f26ddf7ac Translate Localizable.stringsdict in ja
100% translated source file: 'Localizable.stringsdict'
on 'ja'.
2025-07-07 22:04:41 -04:00
df156df6d9 Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-07-07 22:04:40 -04:00
transifex-integration[bot]
11c367b541 Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2025-07-07 22:04:40 -04:00
transifex-integration[bot]
4e1b23d1cb Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-07-07 22:04:40 -04:00
transifex-integration[bot]
2de3083dad Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-07-07 22:04:40 -04:00
93149642db Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-07-07 22:04:40 -04:00
transifex-integration[bot]
0b0d422b7a Translate Localizable.stringsdict in th
100% translated source file: 'Localizable.stringsdict'
on 'th'.
2025-07-07 22:04:40 -04:00
transifex-integration[bot]
036ea50a3a Translate Localizable.stringsdict in th
100% translated source file: 'Localizable.stringsdict'
on 'th'.
2025-07-07 22:04:39 -04:00
Daniel D’Aquino
073feccbbf CI: Fix UI tests to include new onboarding steps
Changelog-None
Closes: https://github.com/damus-io/damus/issues/3124
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-07-07 17:56:30 -07:00
Daniel D’Aquino
eeea9d3266 Integrate follow packs into onboarding suggestions
Closes: https://github.com/damus-io/damus/issues/3007
Changelog-Added: Added new onboarding suggestions based on user-selected interests
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-07-04 09:58:10 -07:00
Daniel D’Aquino
b8bf5df7bc Add .build to .gitignore
Some code editors will automatically run the SourceKit LSP on the
project, and create the `.build` folder. This folder should be ignored
by git
2025-07-04 09:58:10 -07:00
Daniel D’Aquino
e9e68422d4 Implement max budget setting for Coinos one-click wallets
Closes: https://github.com/damus-io/damus/issues/3059
Changelog-Added: Added adjustable max budget setting for Coinos one-click wallets
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-07-03 16:14:23 -07:00
Askia Linder
6f9a00d728 Handle npub correctly in draft notes
Damus stores npub as both Strings and URLs in NSAttributedString.Key.link when a note is saved as a draft. Make Damus correctly handle both when we retrieve and store drafts.

Changelog-Changed: Handle npub correctly in draft notes
Signed-off-by: Askeew <askeew@hotmail.com>
Closes: https://github.com/damus-io/damus/issues/2923
2025-07-02 09:45:13 -07:00
Askia Linder
51e07df1b5 User section will be the last section in MutedView.
Changelog-Changed: Move users-section to be last in muted view
Signed-off-by: Askeew <askeew@hotmail.com>
Closes: https://github.com/damus-io/damus/issues/2939
2025-07-02 09:24:37 -07:00
ericholguin
2a42723b81 Update README
Small improvement to the Readme

Signed-off-by: ericholguin <ericholguin@apache.org>
2025-06-23 14:53:48 -07:00
839ef6a80d Remove image, video, and icon from non-media link previews if media links are present to reduce screen clutter
Changelog-Changed: Removed media from regular link previews if media is already being shown
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-06-23 11:36:24 -07:00
c073dd8fea Fix note rendering to include non-media link previews with image, video, and icon removed when media previews are disabled
Closes: https://github.com/damus-io/damus/issues/3099

Changelog-Fixed: Fixed note rendering to include regular link previews with media removed when media previews are disabled
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-06-23 11:36:24 -07:00
Daniel D’Aquino
8d9f728cf0 Display wallet response error if available
This commit improves error handling in the wallet's "send" feature, by
displaying more specific wallet response error messages when available.

Closes: https://github.com/damus-io/damus/issues/3095
Changelog-Fixed: Improve error handling on wallet send feature
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-06-20 19:02:16 -07:00
2c62741e25 Remove incorrect Thai translation for notes_from_three_and_others
Closes: https://github.com/damus-io/damus/issues/3093
Fixes: cfb6f07c67a8 ("Remove Thai translation with incorrect arguments")

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-06-20 18:53:01 -07:00
transifex-integration[bot]
1f612f7fde Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-06-20 15:04:06 -07:00
transifex-integration[bot]
0e9e102d0f Translate Localizable.stringsdict in th
100% translated source file: 'Localizable.stringsdict'
on 'th'.
2025-06-20 15:04:06 -07:00
transifex-integration[bot]
b94e8765a1 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-06-20 15:04:06 -07:00
transifex-integration[bot]
53964f5c1a Translate Localizable.stringsdict in nl
100% translated source file: 'Localizable.stringsdict'
on 'nl'.
2025-06-20 15:04:06 -07:00
bd574d93c3 Fix localizable strings in FollowPackView
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-06-20 15:04:06 -07:00
47514ace79 Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-06-20 15:04:06 -07:00
transifex-integration[bot]
298b43733f Translate Localizable.stringsdict in nl
100% translated source file: 'Localizable.stringsdict'
on 'nl'.
2025-06-20 15:04:06 -07:00
transifex-integration[bot]
02116c0af5 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-06-20 15:04:06 -07:00
92121e3b2d Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-06-20 15:04:06 -07:00
Daniel D’Aquino
c92094823e Add send feature
Closes: https://github.com/damus-io/damus/issues/2988
Changelog-Added: Added send feature to the wallet view
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-06-20 14:12:50 -07:00
Daniel D’Aquino
f4b1a504a5 Fix issue with balance loading appearance
During the implementation of the "hide balance" feature, the balance
view was refactored in a way that caused it to not be redacted anymore,
making it show the "??" instead of the intended skeleton loader.

This commit fixes that issue without reverting the hide balance feature.

Changelog-Fixed: Fixed issue where the text "??" would appear on the balance while loading
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-06-20 14:12:50 -07:00
99ae7de5eb Rename Friends of Friends to Trusted Network and add popover tips to DMs and Notifications toolbars on Trusted Network button
Changelog-Changed: Renamed Friends of Friends to Trusted Network

Changelog-Added: Added popover tips to DMs and Notifications toolbars on Trusted Network button
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-06-16 13:32:02 -07:00
b3d9ee3fc0 Add tip in threads to inform users what trusted network means
Changelog-Added: Added tip in threads to inform users what trusted network means
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-06-16 13:32:02 -07:00
e65219ee3e Add web of trust reply sorting in threads to mitigate spam
Changelog-Added: Added web of trust reply sorting in threads to mitigate spam
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-06-16 13:32:02 -07:00
ericholguin
414c67a919 Follow Packs
This PR adds and enables follow packs in the universe view.

Closes: #3012

Changelog-Added: Added follow list kind 39089
Changelog-Added: Added follow pack preview
Changelog-Added: Added follow pack timeline to Universe View
Changelog-Removed: Removed hashtags in Universe View

Signed-off-by: ericholguin <ericholguin@apache.org>
2025-06-16 10:34:18 -07:00
f436291209 Hide end previewables when hashtags are present
Changelog-Fixed: Hide end previewables when hashtags are present
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-06-02 11:44:59 -07:00
a9196a39df Fix wallet transactions to always show profile display name unless there is no pubkey
Changelog-Fixed: Fixed wallet transactions to always show profile display name unless there is no pubkey
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-06-02 11:41:51 -07:00
William Casarin
6a8ee9c360 Merge remote-tracking branches 'github/pr/3066' and 'github/pr/3065' 2025-06-02 07:01:35 -07:00
947e24864e Add privacy-based redaction to nsec in key settings view
Changelog-Changed: Added privacy-based redaction to nsec in key settings view
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-06-01 19:54:46 -04:00
b9198d6bd7 Add privacy-based redaction to wallet view
Changelog-Changed: Added privacy-based redaction to wallet view
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-06-01 18:36:33 -04:00
William Casarin
14bf187a6e Merge remote-tracking branches 'github/pr/30{62,57,55,51,50}'
Merge a bunch of changes from terry, translations, and me

Terry Yiu (4):
      Add NIP-05 favicon to profile names and NIP-05 web of trust feed
      Fix quotes view header alignment
      Export strings for translation
      Rename Bitcoin Beach wallet to Blink

Transifex (11):
      Translate Localizable.strings in th
      Translate Localizable.strings in th
      Translate Localizable.strings in nl
      Translate Localizable.strings in de
      Translate Localizable.stringsdict in de
      Translate Localizable.stringsdict in de
      Translate Localizable.strings in th
      Translate Localizable.strings in th
      Translate Localizable.strings in th
      Translate Localizable.strings in th
      Translate Localizable.strings in th

William Casarin (2):
      perf: don't use regex in trim_{prefix,suffix}
2025-06-01 00:36:19 +02:00
William Casarin
c996e5f8b3 perf: don't use regex in trim_{prefix,suffix}
regex is overkill for this, and performance is quite bad

Fixes: b131c74ee3 ("Add prefix and suffix string trimming functions")
Signed-off-by: William Casarin <jb55@jb55.com>
2025-05-31 20:17:14 +02:00
b6dad349c9 Rename Bitcoin Beach wallet to Blink
Changelog-Changed: Renamed Bitcoin Beach wallet to Blink

Closes: https://github.com/damus-io/damus/issues/3056
2025-05-30 12:37:13 -04:00
transifex-integration[bot]
56dde30cf6 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-05-29 09:24:57 -07:00
transifex-integration[bot]
95bfbae131 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-05-29 09:24:57 -07:00
transifex-integration[bot]
3da0ff7ecc Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-05-29 09:24:57 -07:00
transifex-integration[bot]
b8f846ded8 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-05-29 09:24:57 -07:00
transifex-integration[bot]
e74c45ad39 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-05-29 09:24:57 -07:00
transifex-integration[bot]
e6a03522c6 Translate Localizable.stringsdict in de
100% translated source file: 'Localizable.stringsdict'
on 'de'.
2025-05-29 09:24:57 -07:00
transifex-integration[bot]
dbc7d79ecd Translate Localizable.stringsdict in de
100% translated source file: 'Localizable.stringsdict'
on 'de'.
2025-05-29 09:24:57 -07:00
transifex-integration[bot]
d2b5a65eca Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2025-05-29 09:24:57 -07:00
transifex-integration[bot]
16b19d3a96 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-05-29 09:24:56 -07:00
transifex-integration[bot]
70edb8d7c5 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-05-29 09:24:56 -07:00
ea04ebe95c Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-05-29 09:24:56 -07:00
transifex-integration[bot]
44cf47faa4 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-05-29 09:24:55 -07:00
612abfd862 Fix quotes view header alignment
Changelog-Fixed: Fixed quotes view header alignment
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-05-27 17:40:07 -04:00
20af086273 Add NIP-05 favicon to profile names and NIP-05 web of trust feed
Changelog-Added: Added NIP-05 favicon to profile names and NIP-05 web of trust feed
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-05-27 00:54:03 -04:00
Swift Coder
e9c1671d06 Display Circular Indicator on top of media undergoing upload process
Removed existing progress view bar at the top of post view
Added separate stack in PVImageCarouselView for media undergoing the upload process
Changelog-Added: Display uploading indicator in post view
Signed-off-by: Swift Coder <scoder1747@gmail.com>
2025-05-26 17:21:06 -07:00
Daniel D’Aquino
d02847d466 Version bump to 1.15
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-05-26 11:56:42 -07:00
Daniel D’Aquino
580fa954b2 Add changelog for v1.14
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-05-26 11:45:09 -07:00
Daniel D’Aquino
aef516ae9f Add relay connectivity information to NWC settings
Changelog-Changed: Added relay connectivity information to NWC settings
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-05-15 15:12:52 -07:00
Daniel D’Aquino
eb4e3b692b Do not process NWC responses not meant for the user
Soon after tightening error handling around NWC, it was noticed that
Damus was trying to process NWC responses meant for other people,
which caused a failure around the decryption process and a spam of
errors.

This commit modifies the relay filter to include only responses destined
to the user, and also guards the NWC response processing logic to ignore
responses meant for other users.

Changelog-Changed: Improved handling around NWC responses
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-05-15 15:12:52 -07:00
Daniel D’Aquino
fe52381d63 Improve error handling on NWC wallet
Changelog-Changed: Added more human visible errors on NWC wallets to aid with troubleshooting
Changelog-Added: Added copy technical info button to user visible errors, so that users can more easily share errors with developers
Closes: https://github.com/damus-io/damus/issues/3010
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-05-15 15:12:52 -07:00
Daniel D’Aquino
ab8d52e685 Add option to dismiss wallet high balance warning
Changelog-Added: Add dismiss button to wallet high balance reminders
Closes: https://github.com/damus-io/damus/issues/2994
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-05-15 15:12:52 -07:00
Daniel D’Aquino
1d32200ae3 Improve Coinos button disclaimer
Closes: https://github.com/damus-io/damus/issues/3000
Fixes: 67f0e3d296
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-05-15 15:12:52 -07:00
Daniel D’Aquino
309b00380d Add description and metadata to pay_invoice command
Changelog-Added: Zap receiver information now included for outgoing zaps
Closes: https://github.com/damus-io/damus/issues/2927
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-05-15 15:12:52 -07:00
Daniel D’Aquino
7fa2118480 Implement Codable for NdbNote
Makes it easier to work with other Swift types

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-05-15 15:12:52 -07:00
Daniel D’Aquino
1a6c17e308 Move Kingfisher data to the Caches directory
This commit moves Kingfisher data to Apple's designated caches folder
to avoid it from being backed up to iCloud.

Closes: https://github.com/damus-io/damus/issues/2993
Changelog-Fixed: Fixed issue where cached images would be backed up to iCloud
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-05-15 15:12:12 -07:00
Daniel D’Aquino
82a6046620 Re-enable note zaps
Let's go!

Changelog-Changed: Re-enabled note zaps as permitted by the new App Store guidelines
Closes: https://github.com/damus-io/damus/issues/3016
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-05-15 15:11:35 -07:00
Daniel D’Aquino
241755c8c4 Refactor wallet invoice URL handling
This is a minor refactor on the way wallet invoice URLs are handled, in
order to better fit the interface, enforce the design pattern, and avoid
side-effects in a particular function that handles opening URLs.

This design pattern was introduced to prevent issues on the previous
pattern, where URL handling was done with side-effects inside multiple
levels of nested logic and separate function calls, which would make
debugging very difficult, and cause the app to fail silently.

Closes: https://github.com/damus-io/damus/issues/3023
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-05-09 15:35:49 -07:00
transifex-integration[bot]
b26f66f15c Translate Localizable.stringsdict in ja
100% translated source file: 'Localizable.stringsdict'
on 'ja'.
2025-05-07 16:00:37 -07:00
transifex-integration[bot]
28bd0c81e8 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-05-07 16:00:37 -07:00
0bd1814877 Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-05-07 16:00:37 -07:00
ee94f67b94 Remove arbitrary newline from localizable string
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-05-07 16:00:37 -07:00
transifex-integration[bot]
3a25075473 Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2025-05-07 16:00:37 -07:00
transifex-integration[bot]
d16ff8f78f Translate Localizable.stringsdict in th
100% translated source file: 'Localizable.stringsdict'
on 'th'.
2025-05-07 16:00:37 -07:00
transifex-integration[bot]
38dc90cb33 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-05-07 16:00:37 -07:00
transifex-integration[bot]
52bbc698b2 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-05-07 16:00:37 -07:00
transifex-integration[bot]
496a11f597 Translate Localizable.stringsdict in nl
100% translated source file: 'Localizable.stringsdict'
on 'nl'.
2025-05-07 16:00:37 -07:00
4a8a0ea1bd Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-05-07 16:00:37 -07:00
transifex-integration[bot]
c424d4da99 Translate Localizable.strings in hu_HU
100% translated source file: 'Localizable.strings'
on 'hu_HU'.
2025-05-07 16:00:37 -07:00
transifex-integration[bot]
69d5fc1553 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2025-05-07 16:00:37 -07:00
bcb59896db Optimize classify_url function
Changelog-Fixed: Optimized classify_url function
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-05-07 15:13:31 -07:00
e1e6d9eb3d Add inline note rendering of invoices to pull up wallet selector sheet
Changelog-Added: Added inline note rendering of invoices to pull up wallet selector sheet
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-05-07 15:13:31 -07:00
f1fdae5957 Fix note rendering for those that contain previewable items or leading and trailing whitespaces
Changelog-Fixed: Fixed note rendering for those that contain previewable items or leading and trailing whitespaces
Closes: https://github.com/damus-io/damus/issues/2187
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-05-07 15:13:31 -07:00
ericholguin
f96647fa40 wallet: route to profile from wallet tx list
This PR allows users to tap on a profile picture from the wallet
transaction list to go to that user's profile page.

Closes: #2997

Changelog-Added: Added route to profile page from wallet tx list

Signed-off-by: ericholguin <ericholguin@apache.org>
2025-05-07 14:49:36 -07:00
Daniel D’Aquino
5ea522d306 Reinitialize videos if they enter an error state
This is a palliative fix for an issue where videos become unplayable
after a long user session.

The fix works by detecting the error state anytime the video gets
played, and reinitializes the video and corresponding player views in
order to clear the error.

Changelog-Fixed: Fixed issue where some videos would become unplayable after some time using the app
Closes: https://github.com/damus-io/damus/issues/2878
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-05-07 14:37:35 -07:00
SanjaySiddharth
54d6161acd Show additional information on top of blurred images
Changelog-Changed: Added additional information on top of blurred images
Closes: https://github.com/damus-io/damus/issues/2854
Signed-off-by: SanjaySiddharth <mjsanjaysiddharth1999@gmail.com>
2025-04-21 16:28:56 -07:00
Daniel D’Aquino
b1fd84fd75 Add safety reminder for higher balances
This commit adds a reminder to users who hold more than 100K sats in
their NWC wallet, reminding them to learn about self-custody.

Changelog-Added: Added safety reminder to wallets with higher balance
Closes: https://github.com/damus-io/damus/issues/2984
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-21 15:46:33 -07:00
Daniel D’Aquino
9dbdf7928a Add network connect call to extensions
This commit fixes a regression on the highlighter and share extensions,
which was caused by a change in the code's architecture, which required
the network manager to be initialized.

Fixes: 8d48f77d95138c93ed93989989fa930b61c2d6fb
Closes: https://github.com/damus-io/damus/issues/2955
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-21 15:29:46 -07:00
Daniel D’Aquino
67f0e3d296 Add disclaimer to Coinos button
Changelog-Changed: Added disclaimer to clarify that Coinos is a third-party service
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-21 12:23:31 -07:00
Daniel D’Aquino
e498418c2d Add one-click Coinos wallet setup
This commit implements a one-click Coinos wallet setup.

This was implemented using the Coinos API, and using account details
that are deterministically generated from the user's private key.

Closes: https://github.com/damus-io/damus/issues/2961
Changelog-Added: Added one-click Coinos wallet setup
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-21 12:23:31 -07:00
33150a42c5 Hide future notes from timeline
Changelog-Fixed: Hide future notes from timeline

Closes: https://github.com/damus-io/damus/issues/2949
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-18 16:29:12 -07:00
e7fe4ab9b4 Inverse hellthread_notifications_enabled to be hellthread_notifications_disabled and add hellthread_notifications_max_pubkeys setting
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-18 16:14:13 -07:00
c146bab08a Add notification setting to hide hellthreads
Changelog-Added: Add notification setting to hide hellthreads
Closes: https://github.com/damus-io/damus/issues/2943
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-18 16:14:13 -07:00
Daniel D’Aquino
d1cced8d54 Fetch NIP-65 relay lists from profile view
Changelog-Fixed: Fixed issue where profiles with a NIP-65 relay list would not display on Damus
Closes: https://github.com/damus-io/damus/issues/2120
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-16 11:48:52 -07:00
Daniel D’Aquino
8849b6105c Add First Aid tool to repair relay list
This adds a First aid tool to repair the NIP-65 relay list

Changelog-Added: Added separated first aid option for relay lists that does not need a contact list reset
Closes: https://github.com/damus-io/damus/issues/2120
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-16 11:48:52 -07:00
Daniel D’Aquino
3a0acfaba1 Implement NostrNetworkManager and UserRelayListManager
This commit implements a new layer called NostrNetworkManager,
responsible for managing interactions with the Nostr network, and
providing a higher level API that is easier and more secure to use for
the layer above it.

It also integrates it with the rest of the app, by moving RelayPool and PostBox
into NostrNetworkManager, along with all their usages.

Changelog-Added: Added NIP-65 relay list support
Changelog-Changed: Improved robustness of relay list handling
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-16 11:48:52 -07:00
Daniel D’Aquino
0ec2b05070 Implement safe interface for unowned NdbNotes
This commit introduces a new interface that makes it easier and safer to
handle unowned NostrDB notes, by leveraging new non-copyable and borrow
features from modern Swift.

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-16 11:48:52 -07:00
Daniel D’Aquino
130bbfafb4 New async streaming interface from RelayPool
This defines a higher level and easier to use streaming interface from
RelayPool.

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-16 11:48:52 -07:00
Daniel D’Aquino
ffc75772f9 NIP-65 relay list models and definitions
This commit adds the base models needed for the NIP-65 relay list support.

This introduces no user-facing changes.

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-16 11:48:52 -07:00
Daniel D’Aquino
5b3fac70ed Organize RelayPool namespace
This is a non-functional refactor that organizes some classes and
structs used by RelayPool under the same namespace.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-16 11:48:52 -07:00
Daniel D’Aquino
53e3f6d86b Define protocol NostrEventConvertible
This adds a new protocol for classes that can be converted to and from a
NostrEvent.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-16 11:48:52 -07:00
Daniel D’Aquino
c28ab7a57c Renamed RelayInfo to LegacyKind3RelayRWConfiguration
This is a non-functional refactor that makes a struct name more
detailed.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-16 11:48:52 -07:00
Daniel D’Aquino
09ce3af11e Add some miscellaneous documentation
This commit adds some documentation to miscellaneous functions and
classes.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-16 11:48:52 -07:00
e42c09883a Replace deprecated usage of UIMenuController with UITextViewDelegate
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-14 19:11:30 -07:00
77e3924809 Fix some compiler warnings
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-14 19:11:30 -07:00
3511b1ee91 Fix quote notes to include missing q tag
Changelog-Fixed: Fix quote notes to include missing q tag

Closes: https://github.com/damus-io/damus/issues/2615
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-14 19:10:46 -07:00
78a62c8ef0 Clean up code in ProfileName.name_choice
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-14 18:38:51 -07:00
Daniel D’Aquino
8b96b9f4e6 Merge pull request #2973 from damus-io/translations
Translations
2025-04-14 18:35:34 -07:00
Daniel D’Aquino
649a857c3a Update Kingfisher to 8.3.1
Changelog-Changed: Updated image cache for better stability
Closes: https://github.com/damus-io/damus/issues/2899
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-14 17:56:23 -07:00
transifex-integration[bot]
cdae2c7558 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-08 11:52:23 +00:00
transifex-integration[bot]
3639110c51 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-04-08 08:39:51 +00:00
186668512e Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-07 20:39:27 -04:00
f63666fae2 Add missing localized string comment
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-07 20:39:08 -04:00
transifex-integration[bot]
68d25059b1 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:30 -04:00
transifex-integration[bot]
9aef6b7f5b Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:30 -04:00
transifex-integration[bot]
d2e712575f Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:30 -04:00
transifex-integration[bot]
bf9674e6e4 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:30 -04:00
transifex-integration[bot]
4815390cbe Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:30 -04:00
transifex-integration[bot]
6ce903f1f6 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:30 -04:00
transifex-integration[bot]
b2c91ffce4 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:30 -04:00
transifex-integration[bot]
ae335b18bf Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:29 -04:00
transifex-integration[bot]
6391819fb2 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:29 -04:00
transifex-integration[bot]
5d0e56b7c7 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:29 -04:00
transifex-integration[bot]
50ccc7bd7f Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:29 -04:00
transifex-integration[bot]
b3a6bcf3b2 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:29 -04:00
transifex-integration[bot]
38b2988bbe Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:29 -04:00
transifex-integration[bot]
446c541dcb Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2025-04-07 20:25:29 -04:00
transifex-integration[bot]
31fd48ee52 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2025-04-07 20:25:28 -04:00
b35cc33c32 Add Unicode 16 emoji reactions for iOS 18.4+ by upgrading EmojiPicker
Changelog-Added: Added Unicode 16 emoji reactions for iOS 18.4+ by upgrading EmojiPicker
Closes: https://github.com/damus-io/damus/issues/2915
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-03-29 11:40:47 -03:00
SanjaySiddharth
9510290c29 Fix bug that closes the side menu when copying npub
Changelog-Fixed: Fixed issue where the side menu would close when copying the npub
Closes: https://github.com/damus-io/damus/issues/2748
Signed-off-by: SanjaySiddharth <mjsanjaysiddharth1999@gmail.com>
2025-03-28 22:00:00 -03:00
Daniel D’Aquino
3b1238b9c7 Merge pull request #2936 from damus-io/translations
Translations
2025-03-28 21:53:33 -03:00
SanjaySiddharth
3bec23ecac Add search feature to the settings screen
Closes: https://github.com/damus-io/damus/issues/2838
Changelog-Added: Added a search interface to the settings screen
Signed-off-by: SanjaySiddharth <mjsanjaysiddharth1999@gmail.com>
2025-03-28 18:48:08 -03:00
transifex-integration[bot]
7b678228b6 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:29:51 +00:00
transifex-integration[bot]
b1292d4562 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:29:41 +00:00
transifex-integration[bot]
a62d782fe5 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:29:30 +00:00
transifex-integration[bot]
81b07eb339 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:29:17 +00:00
transifex-integration[bot]
02f88398b9 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:28:56 +00:00
transifex-integration[bot]
e80961cc09 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:28:48 +00:00
transifex-integration[bot]
bd7721dc26 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:28:36 +00:00
transifex-integration[bot]
6d974bf71c Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:28:25 +00:00
transifex-integration[bot]
aeeb817735 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:27:59 +00:00
transifex-integration[bot]
b5e7033958 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:27:39 +00:00
transifex-integration[bot]
bdc843f30f Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:27:31 +00:00
transifex-integration[bot]
a823fa8e14 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:27:16 +00:00
transifex-integration[bot]
9232386c15 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:27:09 +00:00
transifex-integration[bot]
3c1547718c Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:26:58 +00:00
transifex-integration[bot]
b67a7f3e9e Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:23:06 +00:00
transifex-integration[bot]
92850d4f64 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-25 06:22:59 +00:00
Daniel D’Aquino
6323eafd7e v1.13.1 changelog
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-03-24 14:02:08 -03:00
Daniel D’Aquino
b8fe826b58 Improve syncing and performance of ThreadModel
This commit introduces two minor improvements:
1. It ensures better consistency between ThreadModel and EventCache
2. It avoids unnecessary recursion calls on `add_event`

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-03-24 13:52:35 -03:00
Daniel D’Aquino
841c49238f Fix thread parent event loading regression
This fixes a regression where ThreadModel would no longer look at
NostrDB saved notes for parent events, causing some instability on
thread loading — especially in poor networking conditions.

This was fixed by adding a call that searches for parent events in
EventCache/NostrDB each time an event is added to the ThreadModel.

That `add_event` function is the ideal spot to place the call because it
is the only interface used for all information updates incoming to the
ThreadModel, including:
- anytime an event is loaded from the network into the thread model.
- when the ThreadModel is first initialized, with an initial event.

Fixes: 74d5bee1f6
Changelog-Fixed: Fixed an issue where threads would not load properly
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-03-24 13:52:35 -03:00
transifex-integration[bot]
7ab612e3d9 Translate Localizable.strings in hu_HU
100% translated source file: 'Localizable.strings'
on 'hu_HU'.
2025-03-23 18:13:22 +00:00
transifex-integration[bot]
6d8a27688f Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2025-03-23 16:34:49 +00:00
transifex-integration[bot]
765385319a Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2025-03-23 16:34:31 +00:00
transifex-integration[bot]
342c49a3e5 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-03-23 10:48:13 +00:00
transifex-integration[bot]
25860e7bb2 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-03-23 05:06:23 +00:00
transifex-integration[bot]
6962f2b462 Translate InfoPlist.strings in fr
100% translated source file: 'InfoPlist.strings'
on 'fr'.
2025-03-22 21:44:17 +00:00
e48ce4c6c5 Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-03-22 15:53:21 -04:00
1cb311cc2c Fix localization issues in TransactionsView
Fixes: 22f2aba969 ("Fix localization issues in TransactionsView")
Changelog-Fixed: Fix localization issues in TransactionsView
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-03-22 15:51:31 -04:00
transifex-integration[bot]
401846abe4 Translate Localizable.stringsdict in hu_HU
100% translated source file: 'Localizable.stringsdict'
on 'hu_HU'.
2025-03-22 14:50:14 -04:00
transifex-integration[bot]
16ef393350 Translate Localizable.strings in hu_HU
100% translated source file: 'Localizable.strings'
on 'hu_HU'.
2025-03-22 14:50:14 -04:00
transifex-integration[bot]
d5742f8e4c Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-03-22 14:50:14 -04:00
transifex-integration[bot]
319063f823 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-03-22 14:50:14 -04:00
transifex-integration[bot]
5b13cf5634 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-03-22 14:50:14 -04:00
transifex-integration[bot]
da10b908b3 Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:14 -04:00
transifex-integration[bot]
4568935bc5 Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:14 -04:00
transifex-integration[bot]
467404a55e Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:13 -04:00
transifex-integration[bot]
fcfe1e4558 Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:13 -04:00
transifex-integration[bot]
c9d87a1b9a Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:13 -04:00
transifex-integration[bot]
35ebf4dfc2 Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:13 -04:00
transifex-integration[bot]
bc3c256d22 Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:13 -04:00
transifex-integration[bot]
0e10e74496 Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:13 -04:00
transifex-integration[bot]
ebe9097f73 Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:13 -04:00
transifex-integration[bot]
d61a11b647 Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:13 -04:00
transifex-integration[bot]
d980cc1f8e Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:12 -04:00
transifex-integration[bot]
bd6056ce2e Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:12 -04:00
transifex-integration[bot]
cf48fda8d0 Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:12 -04:00
transifex-integration[bot]
dc344cd28c Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:12 -04:00
transifex-integration[bot]
358610575f Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:12 -04:00
transifex-integration[bot]
a7869fccbb Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:12 -04:00
transifex-integration[bot]
a50903f90a Translate Localizable.stringsdict in pl_PL
100% translated source file: 'Localizable.stringsdict'
on 'pl_PL'.
2025-03-22 14:50:12 -04:00
transifex-integration[bot]
9243705995 Translate Localizable.stringsdict in de
100% translated source file: 'Localizable.stringsdict'
on 'de'.
2025-03-22 14:50:12 -04:00
transifex-integration[bot]
db4dd9eee9 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2025-03-22 14:50:10 -04:00
ericholguin
22f2aba969 nwc: Wallet Redesign
This PR redesigns the NWC wallet view. A new view is added to introduce zaps to users. The set up wallet view is simplified, with new and existing wallet setup separated.
This also adds new NWC features such as getBalance and listTransactions allowing users to see their balance and previous transactions made.

Changelog-Added: Added view introducing users to Zaps
Changelog-Added: Added new wallet view with balance and transactions list
Changelog-Changed: Improved integration with Nostr Wallet Connect wallets
Closes: https://github.com/damus-io/damus/issues/2900

Signed-off-by: ericholguin <ericholguin@apache.org>
Co-Authored-By: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-03-19 18:00:00 -03:00
Daniel D’Aquino
98f2777fda Version bump to 1.14
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-03-17 11:05:43 -03:00
Daniel D’Aquino
102ce43216 v1.13 changelog
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-03-17 10:58:12 -03:00
815 changed files with 86560 additions and 15224 deletions

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.]_

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ damus.xcodeproj/xcshareddata/xcbaselines
TODO.bak
tags
build-git-hash.txt
.build

47
AGENTS.md Normal file
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.

View File

@@ -1,3 +1,161 @@
## [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
- Added safety reminder to wallets with higher balance (Daniel DAquino)
- Added one-click Coinos wallet setup (Daniel DAquino)
- Add notification setting to hide hellthreads (Terry Yiu)
- Added separated first aid option for relay lists that does not need a contact list reset (Daniel DAquino)
- Added NIP-65 relay list support (Daniel DAquino)
- Added Unicode 16 emoji reactions for iOS 18.4+ by upgrading EmojiPicker (Terry Yiu)
- Added a search interface to the settings screen (SanjaySiddharth)
- Added view introducing users to Zaps (ericholguin)
- Added new wallet view with balance and transactions list (ericholguin)
- Added copy technical info button to user visible errors, so that users can more easily share errors with developers (Daniel DAquino)
- Add dismiss button to wallet high balance reminders (Daniel DAquino)
- Zap receiver information now included for outgoing zaps (Daniel DAquino)
- Added inline note rendering of invoices to pull up wallet selector sheet (Terry Yiu)
- Added route to profile page from wallet tx list (ericholguin)
### Changed
- Added additional information on top of blurred images (SanjaySiddharth)
- Improved robustness of relay list handling (Daniel DAquino)
- Updated image cache for better stability (Daniel DAquino)
- Improved integration with Nostr Wallet Connect wallets (ericholguin)
- Added relay connectivity information to NWC settings (Daniel DAquino)
- Improved handling around NWC responses (Daniel DAquino)
- Added more human visible errors on NWC wallets to aid with troubleshooting (Daniel DAquino)
- Re-enabled note zaps as permitted by the new App Store guidelines (Daniel DAquino)
### Fixed
- Hide future notes from timeline (Terry Yiu)
- Fixed issue where profiles with a NIP-65 relay list would not display on Damus (Daniel DAquino)
- Fix quote notes to include missing q tag (Terry Yiu)
- Fixed issue where the side menu would close when copying the npub (SanjaySiddharth)
- Fixed issue where cached images would be backed up to iCloud (Daniel DAquino)
- Optimized classify_url function (Terry Yiu)
- Fixed note rendering for those that contain previewable items or leading and trailing whitespaces (Terry Yiu)
- Fixed issue where some videos would become unplayable after some time using the app (Daniel DAquino)
[1.14]: https://github.com/damus-io/damus/releases/tag/v1.14
## [1.13.1] - 2025-03-21
### Fixed
- Fixed an issue where threads would not load properly (Daniel DAquino)
[1.13.1]: https://github.com/damus-io/damus/releases/tag/v1.13.1
## [1.13] - 2025-03-14
### Added
- Added local persistence of note drafts (Daniel DAquino)
- Added user-friendly error view for errors around the app that would not fit in other places (Daniel DAquino)
- Coinos connection button in Wallet view (ericholguin)
- Added Alby Go to mobile wallets selection menu (Tomek ⚡ K)
- Minor accessibility improvements around picture editing and onboarding (Daniel DAquino)
- Profile image cropping tools (Daniel DAquino)
- Added Conversations tab to profiles (Terry Yiu)
- Added profile pictures to push notifications (William Casarin)
### Changed
- Don't show reposts for the same note more than once in your home feed (William Casarin)
- Improved profile image bandwidth optimization (Daniel DAquino)
- Improved reliability of picture selector (Daniel DAquino)
- Changed spaces to newlines in new posts to provide cleaner separation between text, uploaded media, and quoted notes (Terry Yiu)
### Fixed
- Fixed issue where some push notifications would not open in the app and leave users confused (Daniel DAquino)
- Fixed issue where app would need a restart for new NWC wallets to work (Daniel DAquino)
- Fixed overly sensitive horizontal swipe on thread chat view (Daniel DAquino)
- Trim whitespaces from Lightning addresses (Terry Yiu)
- Fixed translation export script by upgrading nostr-sdk-swift dependency to support Mac Catalyst (Terry Yiu)
- Fixed issue where users continue to receive push notifications after logout (Daniel DAquino)
- Fixed an issue where events on a thread view would occasionally disappear (Daniel DAquino)
- Improved robustness of the URL handler (Daniel DAquino)
- Translate notes even if they are in a preferred language but not the current language as that is what users expect (Terry Yiu)
- Cancel ongoing uploading operations after the user cancels the post (Swift Coder)
- Fixed link and photo sharing support on macOS (Swift Coder)
- Fix bug where profile view was showing more than just the notes and replies on the notes / notes & replies tabs (Terry Yiu)
- Fixed reposts banner to be localizable (Terry Yiu)
### Removed
- Removed language filtering from Universe feed because language detection can be inaccurate (Terry Yiu)
- Removed mystery tabs meant to fix tab switching bug that no longer exists (Terry Yiu)
[1.13](https://github.com/damus-io/damus/releases/tag/v1.13): https://github.com/damus-io/damus/releases/tag/v1.13
## [1.12.3] - 2025-02-06
### Added

View File

@@ -7,6 +7,7 @@
import Foundation
@MainActor
struct NotificationExtensionState: HeadlessDamusState {
let ndb: Ndb
let settings: UserSettingsStore
@@ -18,7 +19,7 @@ struct NotificationExtensionState: HeadlessDamusState {
let lnurls: LNUrls
init?() {
guard let ndb = try? Ndb(owns_db_file: false) else { return nil }
guard let ndb = Ndb(owns_db_file: false) else { return nil }
self.ndb = ndb
guard let keypair = get_saved_keypair() else { return nil }

View File

@@ -103,7 +103,7 @@ struct NotificationFormatter {
content.title = Self.zap_notification_title(zap)
content.body = Self.zap_notification_body(profiles: state.profiles, zap: zap)
content.sound = UNNotificationSound.default
content.userInfo = LossyLocalNotification(type: .zap, mention: .note(notify.event.id)).to_user_info()
content.userInfo = LossyLocalNotification(type: .zap, mention: .init(nip19: .note(notify.event.id))).to_user_info()
return (content, "myZapNotification")
default:
// The sync method should have taken care of this.
@@ -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))

View File

@@ -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(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

View File

@@ -1,10 +1,26 @@
[![Run Test Suite](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml/badge.svg?branch=master)](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml)
<div align="center">
# damus
<img src="./damus/Assets.xcassets/damus-home.imageset/damus-home@2x.png" alt="Damus Logo" title="Damus logo" width=""/>
# Damus
The social network you control
A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS.
<img src="./ss.png" width="50%" height="50%" />
[![License: GPL-3.0](https://img.shields.io/github/license/damus-io/damus?labelColor=27303D&color=0877d2)](/LICENSE)
## Download and Install
[![Apple](https://img.shields.io/badge/Apple-%23000000.svg?style=for-the-badge&logo=apple&logoColor=white)](https://apps.apple.com/us/app/damus/id1628663131)
## Supported Platforms
iOS 16.0+ • macOS 13.0+
<img src="./demo1.png" width="70%" height="50%" />
</div>
[nostr]: https://github.com/fiatjaf/nostr
@@ -138,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.

1
TODO
View File

@@ -1 +1,2 @@
Fix q tags
1.5-24 profile loading was much better

View File

@@ -1,57 +0,0 @@
//
// block.h
// damus
//
// Created by William Casarin on 2023-04-09.
//
#ifndef block_h
#define block_h
#include "nostr_bech32.h"
#include "str_block.h"
#define MAX_BLOCKS 1024
enum block_type {
BLOCK_HASHTAG = 1,
BLOCK_TEXT = 2,
BLOCK_MENTION_INDEX = 3,
BLOCK_MENTION_BECH32 = 4,
BLOCK_URL = 5,
BLOCK_INVOICE = 6,
};
typedef struct invoice_block {
struct str_block invstr;
union {
struct bolt11 *bolt11;
};
} invoice_block_t;
typedef struct mention_bech32_block {
struct str_block str;
struct nostr_bech32 bech32;
} mention_bech32_block_t;
typedef struct note_block {
enum block_type type;
union {
struct str_block str;
struct invoice_block invoice;
struct mention_bech32_block mention_bech32;
int mention_index;
} block;
} block_t;
typedef struct note_blocks {
int words;
int num_blocks;
struct note_block *blocks;
} blocks_t;
void blocks_init(struct note_blocks *blocks);
void blocks_free(struct note_blocks *blocks);
#endif /* block_h */

View File

@@ -2,7 +2,6 @@
// Use this file to import your target's public headers that you would like to expose to Swift.
//
#include "damus.h"
#include "bolt11.h"
#include "amount.h"
#include "nostr_bech32.h"

View File

@@ -1,393 +0,0 @@
//
// damus.c
// damus
//
// Created by William Casarin on 2022-10-17.
//
#include "damus.h"
#include "cursor.h"
#include "bolt11.h"
#include "bech32.h"
#include <stdlib.h>
#include <string.h>
static int parse_digit(struct cursor *cur, int *digit) {
int c;
if ((c = peek_char(cur, 0)) == -1)
return 0;
c -= '0';
if (c >= 0 && c <= 9) {
*digit = c;
cur->p++;
return 1;
}
return 0;
}
static int parse_mention_index(struct cursor *cur, struct note_block *block) {
int d1, d2, d3, ind;
u8 *start = cur->p;
if (!parse_str(cur, "#["))
return 0;
if (!parse_digit(cur, &d1)) {
cur->p = start;
return 0;
}
ind = d1;
if (parse_digit(cur, &d2))
ind = (d1 * 10) + d2;
if (parse_digit(cur, &d3))
ind = (d1 * 100) + (d2 * 10) + d3;
if (!parse_char(cur, ']')) {
cur->p = start;
return 0;
}
block->type = BLOCK_MENTION_INDEX;
block->block.mention_index = ind;
return 1;
}
static int parse_hashtag(struct cursor *cur, struct note_block *block) {
int c;
u8 *start = cur->p;
if (!parse_char(cur, '#'))
return 0;
c = peek_char(cur, 0);
if (c == -1 || is_whitespace(c) || c == '#') {
cur->p = start;
return 0;
}
consume_until_boundary(cur);
block->type = BLOCK_HASHTAG;
block->block.str.start = (const char*)(start + 1);
block->block.str.end = (const char*)cur->p;
return 1;
}
static int add_block(struct note_blocks *blocks, struct note_block block)
{
if (blocks->num_blocks + 1 >= MAX_BLOCKS)
return 0;
blocks->blocks[blocks->num_blocks++] = block;
return 1;
}
static int add_text_block(struct note_blocks *blocks, const u8 *start, const u8 *end)
{
struct note_block b;
if (start == end)
return 1;
b.type = BLOCK_TEXT;
b.block.str.start = (const char*)start;
b.block.str.end = (const char*)end;
return add_block(blocks, b);
}
static int consume_url_fragment(struct cursor *cur)
{
int c;
if ((c = peek_char(cur, 0)) < 0)
return 1;
if (c != '#' && c != '?') {
return 1;
}
cur->p++;
return consume_until_end_url(cur, 1);
}
static int consume_url_path(struct cursor *cur)
{
int c;
if ((c = peek_char(cur, 0)) < 0)
return 1;
if (c != '/') {
return 1;
}
while (cur->p < cur->end) {
c = *cur->p;
if (c == '?' || c == '#' || is_final_url_char(cur->p, cur->end)) {
return 1;
}
cur->p++;
}
return 1;
}
static int consume_url_host(struct cursor *cur)
{
char c;
int count = 0;
while (cur->p < cur->end) {
c = *cur->p;
// TODO: handle IDNs
if ((is_alphanumeric(c) || c == '.' || c == '-') && !is_final_url_char(cur->p, cur->end))
{
count++;
cur->p++;
continue;
}
return count != 0;
}
// this means the end of the URL hostname is the end of the buffer and we finished
return count != 0;
}
static int parse_url(struct cursor *cur, struct note_block *block) {
u8 *start = cur->p;
u8 *host;
int host_len;
struct cursor path_cur;
if (!parse_str(cur, "http"))
return 0;
if (parse_char(cur, 's') || parse_char(cur, 'S')) {
if (!parse_str(cur, "://")) {
cur->p = start;
return 0;
}
} else {
if (!parse_str(cur, "://")) {
cur->p = start;
return 0;
}
}
// make sure to save the hostname. We will use this to detect damus.io links
host = cur->p;
if (!consume_url_host(cur)) {
cur->p = start;
return 0;
}
// get the length of the host string
host_len = (int)(cur->p - host);
// save the current parse state so that we can continue from here when
// parsing the bech32 in the damus.io link if we have it
copy_cursor(cur, &path_cur);
// skip leading /
cursor_skip(&path_cur, 1);
if (!consume_url_path(cur)) {
cur->p = start;
return 0;
}
if (!consume_url_fragment(cur)) {
cur->p = start;
return 0;
}
// smart parens
if (start - 1 >= 0 &&
start < cur->end &&
*(start - 1) == '(' &&
(cur->p - 1) < cur->end &&
*(cur->p - 1) == ')')
{
cur->p--;
}
// save the bech32 string pos in case we hit a damus.io link
block->block.str.start = (const char *)path_cur.p;
// if we have a damus link, make it a mention
if (host_len == 8
&& !strncmp((const char *)host, "damus.io", 8)
&& parse_nostr_bech32(&path_cur, &block->block.mention_bech32.bech32))
{
block->block.str.end = (const char *)path_cur.p;
block->type = BLOCK_MENTION_BECH32;
return 1;
}
block->type = BLOCK_URL;
block->block.str.start = (const char *)start;
block->block.str.end = (const char *)cur->p;
return 1;
}
static int parse_invoice(struct cursor *cur, struct note_block *block) {
u8 *start, *end;
char *fail;
struct bolt11 *bolt11;
// optional
parse_str(cur, "lightning:");
start = cur->p;
if (!parse_str(cur, "lnbc"))
return 0;
if (!consume_until_whitespace(cur, 1)) {
cur->p = start;
return 0;
}
end = cur->p;
char str[end - start + 1];
str[end - start] = 0;
memcpy(str, start, end - start);
if (!(bolt11 = bolt11_decode(NULL, str, &fail))) {
cur->p = start;
return 0;
}
block->type = BLOCK_INVOICE;
block->block.invoice.invstr.start = (const char*)start;
block->block.invoice.invstr.end = (const char*)end;
block->block.invoice.bolt11 = bolt11;
cur->p = end;
return 1;
}
static int parse_mention_bech32(struct cursor *cur, struct note_block *block) {
u8 *start = cur->p;
parse_char(cur, '@');
parse_str(cur, "nostr:");
block->block.str.start = (const char *)cur->p;
if (!parse_nostr_bech32(cur, &block->block.mention_bech32.bech32)) {
cur->p = start;
return 0;
}
block->block.str.end = (const char *)cur->p;
block->type = BLOCK_MENTION_BECH32;
return 1;
}
static int add_text_then_block(struct cursor *cur, struct note_blocks *blocks, struct note_block block, u8 **start, const u8 *pre_mention)
{
if (!add_text_block(blocks, *start, pre_mention))
return 0;
*start = (u8*)cur->p;
if (!add_block(blocks, block))
return 0;
return 1;
}
int damus_parse_content(struct note_blocks *blocks, const char *content) {
int cp, c;
struct cursor cur;
struct note_block block;
u8 *start, *pre_mention;
blocks->words = 0;
blocks->num_blocks = 0;
make_cursor((u8*)content, (u8*)content + strlen(content), &cur);
start = cur.p;
while (cur.p < cur.end && blocks->num_blocks < MAX_BLOCKS) {
cp = peek_char(&cur, -1);
c = peek_char(&cur, 0);
// new word
if (is_whitespace(cp) && !is_whitespace(c)) {
blocks->words++;
}
pre_mention = cur.p;
if (cp == -1 || is_left_boundary(cp) || c == '#') {
if (c == '#' && (parse_mention_index(&cur, &block) || parse_hashtag(&cur, &block))) {
if (!add_text_then_block(&cur, blocks, block, &start, pre_mention))
return 0;
continue;
} else if ((c == 'h' || c == 'H') && parse_url(&cur, &block)) {
if (!add_text_then_block(&cur, blocks, block, &start, pre_mention))
return 0;
continue;
} else if ((c == 'l' || c == 'L') && parse_invoice(&cur, &block)) {
if (!add_text_then_block(&cur, blocks, block, &start, pre_mention))
return 0;
continue;
} else if ((c == 'n' || c == '@') && parse_mention_bech32(&cur, &block)) {
if (!add_text_then_block(&cur, blocks, block, &start, pre_mention))
return 0;
continue;
}
}
cur.p++;
}
if (cur.p - start > 0) {
if (!add_text_block(blocks, start, cur.p))
return 0;
}
return 1;
}
void blocks_init(struct note_blocks *blocks) {
blocks->blocks = malloc(sizeof(struct note_block) * MAX_BLOCKS);
blocks->num_blocks = 0;
}
void blocks_free(struct note_blocks *blocks) {
if (!blocks->blocks) {
return;
}
for (int i = 0; i < blocks->num_blocks; ++i) {
if (blocks->blocks[i].type == BLOCK_MENTION_BECH32) {
free(blocks->blocks[i].block.mention_bech32.bech32.buffer);
blocks->blocks[i].block.mention_bech32.bech32.buffer = NULL;
}
}
free(blocks->blocks);
blocks->num_blocks = 0;
}

View File

@@ -1,18 +0,0 @@
//
// damus.h
// damus
//
// Created by William Casarin on 2022-10-17.
//
#ifndef damus_h
#define damus_h
#include <stdio.h>
#include "block.h"
typedef unsigned char u8;
int damus_parse_content(struct note_blocks *blocks, const char *content);
#endif /* damus_h */

View File

@@ -1,84 +0,0 @@
/* CC0 (Public domain) - see LICENSE file for details */
#ifndef CCAN_HEX_H
#define CCAN_HEX_H
#include "config.h"
#include <stdbool.h>
#include <stdlib.h>
/**
* hex_decode - Unpack a hex string.
* @str: the hexadecimal string
* @slen: the length of @str
* @buf: the buffer to write the data into
* @bufsize: the length of
*
* Returns false if there are any characters which aren't 0-9, a-f or A-F,
* of the string wasn't the right length for @bufsize.
*
* Example:
* unsigned char data[20];
*
* if (!hex_decode(argv[1], strlen(argv[1]), data, 20))
* printf("String is malformed!\n");
*/
bool hex_decode(const char *str, size_t slen, void *buf, size_t bufsize);
/**
* hex_encode - Create a nul-terminated hex string
* @buf: the buffer to read the data from
* @bufsize: the length of buf
* @dest: the string to fill
* @destsize: the max size of the string
*
* Returns true if the string, including terminator, fit in @destsize;
*
* Example:
* unsigned char buf[] = { 0x1F, 0x2F };
* char str[5];
*
* if (!hex_encode(buf, sizeof(buf), str, sizeof(str)))
* abort();
*/
bool hex_encode(const void *buf, size_t bufsize, char *dest, size_t destsize);
/**
* hex_str_size - Calculate how big a nul-terminated hex string is
* @bytes: bytes of data to represent
*
* Example:
* unsigned char buf[] = { 0x1F, 0x2F };
* char str[hex_str_size(sizeof(buf))];
*
* hex_encode(buf, sizeof(buf), str, sizeof(str));
*/
static inline size_t hex_str_size(size_t bytes)
{
return 2 * bytes + 1;
}
/**
* hex_data_size - Calculate how many bytes of data in a hex string
* @strlen: the length of the string (with or without NUL)
*
* Example:
* const char str[] = "1F2F";
* unsigned char buf[hex_data_size(sizeof(str))];
*
* hex_decode(str, strlen(str), buf, sizeof(buf));
*/
static inline size_t hex_data_size(size_t strlen)
{
return strlen / 2;
}
static inline char hexchar(unsigned int val)
{
if (val < 10)
return '0' + val;
if (val < 16)
return 'a' + val - 10;
abort();
}
#endif /* CCAN_HEX_H */

View File

@@ -1,325 +0,0 @@
//
// nostr_bech32.c
// damus
//
// Created by William Casarin on 2023-04-09.
//
#include "nostr_bech32.h"
#include <stdlib.h>
#include "endian.h"
#include "cursor.h"
#include "bech32.h"
#include <stdbool.h>
#define MAX_TLVS 16
#define TLV_SPECIAL 0
#define TLV_RELAY 1
#define TLV_AUTHOR 2
#define TLV_KIND 3
#define TLV_KNOWN_TLVS 4
struct nostr_tlv {
u8 type;
u8 len;
const u8 *value;
};
struct nostr_tlvs {
struct nostr_tlv tlvs[MAX_TLVS];
int num_tlvs;
};
static int parse_nostr_tlv(struct cursor *cur, struct nostr_tlv *tlv) {
// get the tlv tag
if (!pull_byte(cur, &tlv->type))
return 0;
// unknown, fail!
if (tlv->type >= TLV_KNOWN_TLVS)
return 0;
// get the length
if (!pull_byte(cur, &tlv->len))
return 0;
// is the reported length greater then our buffer? if so fail
if (cur->p + tlv->len > cur->end)
return 0;
tlv->value = cur->p;
cur->p += tlv->len;
return 1;
}
static int parse_nostr_tlvs(struct cursor *cur, struct nostr_tlvs *tlvs) {
int i;
tlvs->num_tlvs = 0;
for (i = 0; i < MAX_TLVS; i++) {
if (parse_nostr_tlv(cur, &tlvs->tlvs[i])) {
tlvs->num_tlvs++;
} else {
break;
}
}
if (tlvs->num_tlvs == 0)
return 0;
return 1;
}
static int find_tlv(struct nostr_tlvs *tlvs, u8 type, struct nostr_tlv **tlv) {
*tlv = NULL;
for (int i = 0; i < tlvs->num_tlvs; i++) {
if (tlvs->tlvs[i].type == type) {
*tlv = &tlvs->tlvs[i];
return 1;
}
}
return 0;
}
static int parse_nostr_bech32_type(const char *prefix, enum nostr_bech32_type *type) {
// Parse type
if (strcmp(prefix, "note") == 0) {
*type = NOSTR_BECH32_NOTE;
return 1;
} else if (strcmp(prefix, "npub") == 0) {
*type = NOSTR_BECH32_NPUB;
return 1;
} else if (strcmp(prefix, "nsec") == 0) {
*type = NOSTR_BECH32_NSEC;
return 1;
} else if (strcmp(prefix, "nprofile") == 0) {
*type = NOSTR_BECH32_NPROFILE;
return 1;
} else if (strcmp(prefix, "nevent") == 0) {
*type = NOSTR_BECH32_NEVENT;
return 1;
} else if (strcmp(prefix, "nrelay") == 0) {
*type = NOSTR_BECH32_NRELAY;
return 1;
} else if (strcmp(prefix, "naddr") == 0) {
*type = NOSTR_BECH32_NADDR;
return 1;
}
return 0;
}
static int parse_nostr_bech32_note(struct cursor *cur, struct bech32_note *note) {
return pull_bytes(cur, 32, &note->event_id);
}
static int parse_nostr_bech32_npub(struct cursor *cur, struct bech32_npub *npub) {
return pull_bytes(cur, 32, &npub->pubkey);
}
static int parse_nostr_bech32_nsec(struct cursor *cur, struct bech32_nsec *nsec) {
return pull_bytes(cur, 32, &nsec->nsec);
}
static int tlvs_to_relays(struct nostr_tlvs *tlvs, struct relays *relays) {
struct nostr_tlv *tlv;
struct str_block *str;
relays->num_relays = 0;
for (int i = 0; i < tlvs->num_tlvs; i++) {
tlv = &tlvs->tlvs[i];
if (tlv->type != TLV_RELAY)
continue;
if (relays->num_relays + 1 > MAX_RELAYS)
break;
str = &relays->relays[relays->num_relays++];
str->start = (const char*)tlv->value;
str->end = (const char*)(tlv->value + tlv->len);
}
return 1;
}
static uint32_t decode_tlv_u32(const uint8_t *bytes) {
beint32_t *be32_bytes = (beint32_t*)bytes;
return be32_to_cpu(*be32_bytes);
}
static int parse_nostr_bech32_nevent(struct cursor *cur, struct bech32_nevent *nevent) {
struct nostr_tlvs tlvs;
struct nostr_tlv *tlv;
if (!parse_nostr_tlvs(cur, &tlvs))
return 0;
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
return 0;
if (tlv->len != 32)
return 0;
nevent->event_id = tlv->value;
if (find_tlv(&tlvs, TLV_AUTHOR, &tlv)) {
nevent->pubkey = tlv->value;
} else {
nevent->pubkey = NULL;
}
if(find_tlv(&tlvs, TLV_KIND, &tlv)) {
nevent->kind = decode_tlv_u32(tlv->value);
nevent->has_kind = true;
} else {
nevent->has_kind = false;
}
return tlvs_to_relays(&tlvs, &nevent->relays);
}
static int parse_nostr_bech32_naddr(struct cursor *cur, struct bech32_naddr *naddr) {
struct nostr_tlvs tlvs;
struct nostr_tlv *tlv;
if (!parse_nostr_tlvs(cur, &tlvs))
return 0;
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
return 0;
naddr->identifier.start = (const char*)tlv->value;
naddr->identifier.end = (const char*)tlv->value + tlv->len;
if (!find_tlv(&tlvs, TLV_AUTHOR, &tlv))
return 0;
naddr->pubkey = tlv->value;
if(!find_tlv(&tlvs, TLV_KIND, &tlv)) {
return 0;
}
naddr->kind = decode_tlv_u32(tlv->value);
return tlvs_to_relays(&tlvs, &naddr->relays);
}
static int parse_nostr_bech32_nprofile(struct cursor *cur, struct bech32_nprofile *nprofile) {
struct nostr_tlvs tlvs;
struct nostr_tlv *tlv;
if (!parse_nostr_tlvs(cur, &tlvs))
return 0;
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
return 0;
if (tlv->len != 32)
return 0;
nprofile->pubkey = tlv->value;
return tlvs_to_relays(&tlvs, &nprofile->relays);
}
static int parse_nostr_bech32_nrelay(struct cursor *cur, struct bech32_nrelay *nrelay) {
struct nostr_tlvs tlvs;
struct nostr_tlv *tlv;
if (!parse_nostr_tlvs(cur, &tlvs))
return 0;
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
return 0;
nrelay->relay.start = (const char*)tlv->value;
nrelay->relay.end = (const char*)tlv->value + tlv->len;
return 1;
}
int parse_nostr_bech32(struct cursor *cur, struct nostr_bech32 *obj) {
u8 *start, *end;
start = cur->p;
if (!consume_until_non_alphanumeric(cur, 1)) {
cur->p = start;
return 0;
}
end = cur->p;
size_t data_len;
size_t input_len = end - start;
if (input_len < 10 || input_len > 10000) {
return 0;
}
obj->buffer = malloc(input_len * 2);
if (!obj->buffer)
return 0;
u8 data[input_len];
char prefix[input_len];
if (bech32_decode_len(prefix, data, &data_len, (const char*)start, input_len) == BECH32_ENCODING_NONE) {
cur->p = start;
return 0;
}
obj->buflen = 0;
if (!bech32_convert_bits(obj->buffer, &obj->buflen, 8, data, data_len, 5, 0)) {
goto fail;
}
if (!parse_nostr_bech32_type(prefix, &obj->type)) {
goto fail;
}
struct cursor bcur;
make_cursor(obj->buffer, obj->buffer + obj->buflen, &bcur);
switch (obj->type) {
case NOSTR_BECH32_NOTE:
if (!parse_nostr_bech32_note(&bcur, &obj->data.note))
goto fail;
break;
case NOSTR_BECH32_NPUB:
if (!parse_nostr_bech32_npub(&bcur, &obj->data.npub))
goto fail;
break;
case NOSTR_BECH32_NSEC:
if (!parse_nostr_bech32_nsec(&bcur, &obj->data.nsec))
goto fail;
break;
case NOSTR_BECH32_NEVENT:
if (!parse_nostr_bech32_nevent(&bcur, &obj->data.nevent))
goto fail;
break;
case NOSTR_BECH32_NADDR:
if (!parse_nostr_bech32_naddr(&bcur, &obj->data.naddr))
goto fail;
break;
case NOSTR_BECH32_NPROFILE:
if (!parse_nostr_bech32_nprofile(&bcur, &obj->data.nprofile))
goto fail;
break;
case NOSTR_BECH32_NRELAY:
if (!parse_nostr_bech32_nrelay(&bcur, &obj->data.nrelay))
goto fail;
break;
}
return 1;
fail:
free(obj->buffer);
cur->p = start;
return 0;
}

View File

@@ -1,89 +0,0 @@
//
// nostr_bech32.h
// damus
//
// Created by William Casarin on 2023-04-09.
//
#ifndef nostr_bech32_h
#define nostr_bech32_h
#include <stdio.h>
#include "str_block.h"
#include "cursor.h"
#include <stdbool.h>
typedef unsigned char u8;
#define MAX_RELAYS 10
struct relays {
struct str_block relays[MAX_RELAYS];
int num_relays;
};
enum nostr_bech32_type {
NOSTR_BECH32_NOTE = 1,
NOSTR_BECH32_NPUB = 2,
NOSTR_BECH32_NPROFILE = 3,
NOSTR_BECH32_NEVENT = 4,
NOSTR_BECH32_NRELAY = 5,
NOSTR_BECH32_NADDR = 6,
NOSTR_BECH32_NSEC = 7,
};
struct bech32_note {
const u8 *event_id;
};
struct bech32_npub {
const u8 *pubkey;
};
struct bech32_nsec {
const u8 *nsec;
};
struct bech32_nevent {
struct relays relays;
const u8 *event_id;
const u8 *pubkey; // optional
uint32_t kind;
bool has_kind;
};
struct bech32_nprofile {
struct relays relays;
const u8 *pubkey;
};
struct bech32_naddr {
struct relays relays;
struct str_block identifier;
const u8 *pubkey;
uint32_t kind;
};
struct bech32_nrelay {
struct str_block relay;
};
typedef struct nostr_bech32 {
enum nostr_bech32_type type;
u8 *buffer; // holds strings and tlv stuff
size_t buflen;
union {
struct bech32_note note;
struct bech32_npub npub;
struct bech32_nsec nsec;
struct bech32_nevent nevent;
struct bech32_nprofile nprofile;
struct bech32_naddr naddr;
struct bech32_nrelay nrelay;
} data;
} nostr_bech32_t;
int parse_nostr_bech32(struct cursor *cur, struct nostr_bech32 *obj);
#endif /* nostr_bech32_h */

View File

@@ -1,308 +0,0 @@
/* MIT (BSD) license - see LICENSE file for details */
/* SHA256 core code translated from the Bitcoin project's C++:
*
* src/crypto/sha256.cpp commit 417532c8acb93c36c2b6fd052b7c11b6a2906aa2
* Copyright (c) 2014 The Bitcoin Core developers
* Distributed under the MIT software license, see the accompanying
* file COPYING or http://www.opensource.org/licenses/mit-license.php.
*/
#include "sha256.h"
#include "compiler.h"
#include "endian.h"
#include <stdbool.h>
#include <assert.h>
#include <string.h>
static void invalidate_sha256(struct sha256_ctx *ctx)
{
#ifdef CCAN_CRYPTO_SHA256_USE_OPENSSL
ctx->c.md_len = 0;
#else
ctx->bytes = (size_t)-1;
#endif
}
static void check_sha256(struct sha256_ctx *ctx UNUSED)
{
#ifdef CCAN_CRYPTO_SHA256_USE_OPENSSL
assert(ctx->c.md_len != 0);
#else
assert(ctx->bytes != (size_t)-1);
#endif
}
#ifdef CCAN_CRYPTO_SHA256_USE_OPENSSL
void sha256_init(struct sha256_ctx *ctx)
{
SHA256_Init(&ctx->c);
}
void sha256_update(struct sha256_ctx *ctx, const void *p, size_t size)
{
check_sha256(ctx);
SHA256_Update(&ctx->c, p, size);
}
void sha256_done(struct sha256_ctx *ctx, struct sha256 *res)
{
SHA256_Final(res->u.u8, &ctx->c);
invalidate_sha256(ctx);
}
#else
static uint32_t Ch(uint32_t x, uint32_t y, uint32_t z)
{
return z ^ (x & (y ^ z));
}
static uint32_t Maj(uint32_t x, uint32_t y, uint32_t z)
{
return (x & y) | (z & (x | y));
}
static uint32_t Sigma0(uint32_t x)
{
return (x >> 2 | x << 30) ^ (x >> 13 | x << 19) ^ (x >> 22 | x << 10);
}
static uint32_t Sigma1(uint32_t x)
{
return (x >> 6 | x << 26) ^ (x >> 11 | x << 21) ^ (x >> 25 | x << 7);
}
static uint32_t sigma0(uint32_t x)
{
return (x >> 7 | x << 25) ^ (x >> 18 | x << 14) ^ (x >> 3);
}
static uint32_t sigma1(uint32_t x)
{
return (x >> 17 | x << 15) ^ (x >> 19 | x << 13) ^ (x >> 10);
}
/** One round of SHA-256. */
static void Round(uint32_t a, uint32_t b, uint32_t c, uint32_t *d, uint32_t e, uint32_t f, uint32_t g, uint32_t *h, uint32_t k, uint32_t w)
{
uint32_t t1 = *h + Sigma1(e) + Ch(e, f, g) + k + w;
uint32_t t2 = Sigma0(a) + Maj(a, b, c);
*d += t1;
*h = t1 + t2;
}
/** Perform one SHA-256 transformation, processing a 64-byte chunk. */
static void Transform(uint32_t *s, const uint32_t *chunk)
{
uint32_t a = s[0], b = s[1], c = s[2], d = s[3], e = s[4], f = s[5], g = s[6], h = s[7];
uint32_t w0, w1, w2, w3, w4, w5, w6, w7, w8, w9, w10, w11, w12, w13, w14, w15;
Round(a, b, c, &d, e, f, g, &h, 0x428a2f98, w0 = be32_to_cpu(chunk[0]));
Round(h, a, b, &c, d, e, f, &g, 0x71374491, w1 = be32_to_cpu(chunk[1]));
Round(g, h, a, &b, c, d, e, &f, 0xb5c0fbcf, w2 = be32_to_cpu(chunk[2]));
Round(f, g, h, &a, b, c, d, &e, 0xe9b5dba5, w3 = be32_to_cpu(chunk[3]));
Round(e, f, g, &h, a, b, c, &d, 0x3956c25b, w4 = be32_to_cpu(chunk[4]));
Round(d, e, f, &g, h, a, b, &c, 0x59f111f1, w5 = be32_to_cpu(chunk[5]));
Round(c, d, e, &f, g, h, a, &b, 0x923f82a4, w6 = be32_to_cpu(chunk[6]));
Round(b, c, d, &e, f, g, h, &a, 0xab1c5ed5, w7 = be32_to_cpu(chunk[7]));
Round(a, b, c, &d, e, f, g, &h, 0xd807aa98, w8 = be32_to_cpu(chunk[8]));
Round(h, a, b, &c, d, e, f, &g, 0x12835b01, w9 = be32_to_cpu(chunk[9]));
Round(g, h, a, &b, c, d, e, &f, 0x243185be, w10 = be32_to_cpu(chunk[10]));
Round(f, g, h, &a, b, c, d, &e, 0x550c7dc3, w11 = be32_to_cpu(chunk[11]));
Round(e, f, g, &h, a, b, c, &d, 0x72be5d74, w12 = be32_to_cpu(chunk[12]));
Round(d, e, f, &g, h, a, b, &c, 0x80deb1fe, w13 = be32_to_cpu(chunk[13]));
Round(c, d, e, &f, g, h, a, &b, 0x9bdc06a7, w14 = be32_to_cpu(chunk[14]));
Round(b, c, d, &e, f, g, h, &a, 0xc19bf174, w15 = be32_to_cpu(chunk[15]));
Round(a, b, c, &d, e, f, g, &h, 0xe49b69c1, w0 += sigma1(w14) + w9 + sigma0(w1));
Round(h, a, b, &c, d, e, f, &g, 0xefbe4786, w1 += sigma1(w15) + w10 + sigma0(w2));
Round(g, h, a, &b, c, d, e, &f, 0x0fc19dc6, w2 += sigma1(w0) + w11 + sigma0(w3));
Round(f, g, h, &a, b, c, d, &e, 0x240ca1cc, w3 += sigma1(w1) + w12 + sigma0(w4));
Round(e, f, g, &h, a, b, c, &d, 0x2de92c6f, w4 += sigma1(w2) + w13 + sigma0(w5));
Round(d, e, f, &g, h, a, b, &c, 0x4a7484aa, w5 += sigma1(w3) + w14 + sigma0(w6));
Round(c, d, e, &f, g, h, a, &b, 0x5cb0a9dc, w6 += sigma1(w4) + w15 + sigma0(w7));
Round(b, c, d, &e, f, g, h, &a, 0x76f988da, w7 += sigma1(w5) + w0 + sigma0(w8));
Round(a, b, c, &d, e, f, g, &h, 0x983e5152, w8 += sigma1(w6) + w1 + sigma0(w9));
Round(h, a, b, &c, d, e, f, &g, 0xa831c66d, w9 += sigma1(w7) + w2 + sigma0(w10));
Round(g, h, a, &b, c, d, e, &f, 0xb00327c8, w10 += sigma1(w8) + w3 + sigma0(w11));
Round(f, g, h, &a, b, c, d, &e, 0xbf597fc7, w11 += sigma1(w9) + w4 + sigma0(w12));
Round(e, f, g, &h, a, b, c, &d, 0xc6e00bf3, w12 += sigma1(w10) + w5 + sigma0(w13));
Round(d, e, f, &g, h, a, b, &c, 0xd5a79147, w13 += sigma1(w11) + w6 + sigma0(w14));
Round(c, d, e, &f, g, h, a, &b, 0x06ca6351, w14 += sigma1(w12) + w7 + sigma0(w15));
Round(b, c, d, &e, f, g, h, &a, 0x14292967, w15 += sigma1(w13) + w8 + sigma0(w0));
Round(a, b, c, &d, e, f, g, &h, 0x27b70a85, w0 += sigma1(w14) + w9 + sigma0(w1));
Round(h, a, b, &c, d, e, f, &g, 0x2e1b2138, w1 += sigma1(w15) + w10 + sigma0(w2));
Round(g, h, a, &b, c, d, e, &f, 0x4d2c6dfc, w2 += sigma1(w0) + w11 + sigma0(w3));
Round(f, g, h, &a, b, c, d, &e, 0x53380d13, w3 += sigma1(w1) + w12 + sigma0(w4));
Round(e, f, g, &h, a, b, c, &d, 0x650a7354, w4 += sigma1(w2) + w13 + sigma0(w5));
Round(d, e, f, &g, h, a, b, &c, 0x766a0abb, w5 += sigma1(w3) + w14 + sigma0(w6));
Round(c, d, e, &f, g, h, a, &b, 0x81c2c92e, w6 += sigma1(w4) + w15 + sigma0(w7));
Round(b, c, d, &e, f, g, h, &a, 0x92722c85, w7 += sigma1(w5) + w0 + sigma0(w8));
Round(a, b, c, &d, e, f, g, &h, 0xa2bfe8a1, w8 += sigma1(w6) + w1 + sigma0(w9));
Round(h, a, b, &c, d, e, f, &g, 0xa81a664b, w9 += sigma1(w7) + w2 + sigma0(w10));
Round(g, h, a, &b, c, d, e, &f, 0xc24b8b70, w10 += sigma1(w8) + w3 + sigma0(w11));
Round(f, g, h, &a, b, c, d, &e, 0xc76c51a3, w11 += sigma1(w9) + w4 + sigma0(w12));
Round(e, f, g, &h, a, b, c, &d, 0xd192e819, w12 += sigma1(w10) + w5 + sigma0(w13));
Round(d, e, f, &g, h, a, b, &c, 0xd6990624, w13 += sigma1(w11) + w6 + sigma0(w14));
Round(c, d, e, &f, g, h, a, &b, 0xf40e3585, w14 += sigma1(w12) + w7 + sigma0(w15));
Round(b, c, d, &e, f, g, h, &a, 0x106aa070, w15 += sigma1(w13) + w8 + sigma0(w0));
Round(a, b, c, &d, e, f, g, &h, 0x19a4c116, w0 += sigma1(w14) + w9 + sigma0(w1));
Round(h, a, b, &c, d, e, f, &g, 0x1e376c08, w1 += sigma1(w15) + w10 + sigma0(w2));
Round(g, h, a, &b, c, d, e, &f, 0x2748774c, w2 += sigma1(w0) + w11 + sigma0(w3));
Round(f, g, h, &a, b, c, d, &e, 0x34b0bcb5, w3 += sigma1(w1) + w12 + sigma0(w4));
Round(e, f, g, &h, a, b, c, &d, 0x391c0cb3, w4 += sigma1(w2) + w13 + sigma0(w5));
Round(d, e, f, &g, h, a, b, &c, 0x4ed8aa4a, w5 += sigma1(w3) + w14 + sigma0(w6));
Round(c, d, e, &f, g, h, a, &b, 0x5b9cca4f, w6 += sigma1(w4) + w15 + sigma0(w7));
Round(b, c, d, &e, f, g, h, &a, 0x682e6ff3, w7 += sigma1(w5) + w0 + sigma0(w8));
Round(a, b, c, &d, e, f, g, &h, 0x748f82ee, w8 += sigma1(w6) + w1 + sigma0(w9));
Round(h, a, b, &c, d, e, f, &g, 0x78a5636f, w9 += sigma1(w7) + w2 + sigma0(w10));
Round(g, h, a, &b, c, d, e, &f, 0x84c87814, w10 += sigma1(w8) + w3 + sigma0(w11));
Round(f, g, h, &a, b, c, d, &e, 0x8cc70208, w11 += sigma1(w9) + w4 + sigma0(w12));
Round(e, f, g, &h, a, b, c, &d, 0x90befffa, w12 += sigma1(w10) + w5 + sigma0(w13));
Round(d, e, f, &g, h, a, b, &c, 0xa4506ceb, w13 += sigma1(w11) + w6 + sigma0(w14));
Round(c, d, e, &f, g, h, a, &b, 0xbef9a3f7, w14 + sigma1(w12) + w7 + sigma0(w15));
Round(b, c, d, &e, f, g, h, &a, 0xc67178f2, w15 + sigma1(w13) + w8 + sigma0(w0));
s[0] += a;
s[1] += b;
s[2] += c;
s[3] += d;
s[4] += e;
s[5] += f;
s[6] += g;
s[7] += h;
}
static bool alignment_ok(const void *p UNUSED, size_t n UNUSED)
{
#if HAVE_UNALIGNED_ACCESS
return true;
#else
return ((size_t)p % n == 0);
#endif
}
static void add(struct sha256_ctx *ctx, const void *p, size_t len)
{
const unsigned char *data = p;
size_t bufsize = ctx->bytes % 64;
if (bufsize + len >= 64) {
/* Fill the buffer, and process it. */
memcpy(ctx->buf.u8 + bufsize, data, 64 - bufsize);
ctx->bytes += 64 - bufsize;
data += 64 - bufsize;
len -= 64 - bufsize;
Transform(ctx->s, ctx->buf.u32);
bufsize = 0;
}
while (len >= 64) {
/* Process full chunks directly from the source. */
if (alignment_ok(data, sizeof(uint32_t)))
Transform(ctx->s, (const uint32_t *)data);
else {
memcpy(ctx->buf.u8, data, sizeof(ctx->buf));
Transform(ctx->s, ctx->buf.u32);
}
ctx->bytes += 64;
data += 64;
len -= 64;
}
if (len) {
/* Fill the buffer with what remains. */
memcpy(ctx->buf.u8 + bufsize, data, len);
ctx->bytes += len;
}
}
void sha256_init(struct sha256_ctx *ctx)
{
struct sha256_ctx init = SHA256_INIT;
*ctx = init;
}
void sha256_update(struct sha256_ctx *ctx, const void *p, size_t size)
{
check_sha256(ctx);
add(ctx, p, size);
}
void sha256_done(struct sha256_ctx *ctx, struct sha256 *res)
{
static const unsigned char pad[64] = {0x80};
uint64_t sizedesc;
size_t i;
sizedesc = cpu_to_be64((uint64_t)ctx->bytes << 3);
/* Add '1' bit to terminate, then all 0 bits, up to next block - 8. */
add(ctx, pad, 1 + ((128 - 8 - (ctx->bytes % 64) - 1) % 64));
/* Add number of bits of data (big endian) */
add(ctx, &sizedesc, 8);
for (i = 0; i < sizeof(ctx->s) / sizeof(ctx->s[0]); i++)
res->u.u32[i] = cpu_to_be32(ctx->s[i]);
invalidate_sha256(ctx);
}
#endif
void sha256(struct sha256 *sha, const void *p, size_t size)
{
struct sha256_ctx ctx;
sha256_init(&ctx);
sha256_update(&ctx, p, size);
sha256_done(&ctx, sha);
}
void sha256_u8(struct sha256_ctx *ctx, uint8_t v)
{
sha256_update(ctx, &v, sizeof(v));
}
void sha256_u16(struct sha256_ctx *ctx, uint16_t v)
{
sha256_update(ctx, &v, sizeof(v));
}
void sha256_u32(struct sha256_ctx *ctx, uint32_t v)
{
sha256_update(ctx, &v, sizeof(v));
}
void sha256_u64(struct sha256_ctx *ctx, uint64_t v)
{
sha256_update(ctx, &v, sizeof(v));
}
/* Add as little-endian */
void sha256_le16(struct sha256_ctx *ctx, uint16_t v)
{
leint16_t lev = cpu_to_le16(v);
sha256_update(ctx, &lev, sizeof(lev));
}
void sha256_le32(struct sha256_ctx *ctx, uint32_t v)
{
leint32_t lev = cpu_to_le32(v);
sha256_update(ctx, &lev, sizeof(lev));
}
void sha256_le64(struct sha256_ctx *ctx, uint64_t v)
{
leint64_t lev = cpu_to_le64(v);
sha256_update(ctx, &lev, sizeof(lev));
}
/* Add as big-endian */
void sha256_be16(struct sha256_ctx *ctx, uint16_t v)
{
beint16_t bev = cpu_to_be16(v);
sha256_update(ctx, &bev, sizeof(bev));
}
void sha256_be32(struct sha256_ctx *ctx, uint32_t v)
{
beint32_t bev = cpu_to_be32(v);
sha256_update(ctx, &bev, sizeof(bev));
}
void sha256_be64(struct sha256_ctx *ctx, uint64_t v)
{
beint64_t bev = cpu_to_be64(v);
sha256_update(ctx, &bev, sizeof(bev));
}

View File

@@ -1,14 +0,0 @@
#ifndef PROTOVERSE_TYPEDEFS_H
#define PROTOVERSE_TYPEDEFS_H
#include <stdint.h>
typedef unsigned char u8;
typedef unsigned int u32;
typedef unsigned short u16;
typedef uint64_t u64;
typedef int64_t s64;
#endif /* PROTOVERSE_TYPEDEFS_H */

View File

@@ -1179,7 +1179,7 @@ static INLINE int parse_i64(struct cursor *read, uint64_t *val)
shift = 0;
do {
if (!pull_byte(read, &byte))
if (!cursor_pull_byte(read, &byte))
return 0;
*val |= (byte & 0x7FULL) << shift;
shift += 7;
@@ -1199,7 +1199,7 @@ static INLINE int uleb128_read(struct cursor *read, unsigned int *val)
*val = 0;
for (;;) {
if (!pull_byte(read, &byte))
if (!cursor_pull_byte(read, &byte))
return 0;
*val |= (0x7F & byte) << shift;
@@ -1222,7 +1222,7 @@ static INLINE int sleb128_read(struct cursor *read, signed int *val)
shift = 0;
do {
if (!pull_byte(read, &byte))
if (!cursor_pull_byte(read, &byte))
return 0;
*val |= ((byte & 0x7F) << shift);
shift += 7;
@@ -1241,21 +1241,21 @@ static INLINE int uleb128_read(struct cursor *read, unsigned int *val)
unsigned char p[6] = {0};
*val = 0;
if (pull_byte(read, &p[0]) && (p[0] & 0x80) == 0) {
if (cursor_pull_byte(read, &p[0]) && (p[0] & 0x80) == 0) {
*val = LEB128_1(unsigned int);
if (p[0] == 0x7F)
assert((int)*val == -1);
return 1;
} else if (pull_byte(read, &p[1]) && (p[1] & 0x80) == 0) {
} else if (cursor_pull_byte(read, &p[1]) && (p[1] & 0x80) == 0) {
*val = LEB128_2(unsigned int);
return 2;
} else if (pull_byte(read, &p[2]) && (p[2] & 0x80) == 0) {
} else if (cursor_pull_byte(read, &p[2]) && (p[2] & 0x80) == 0) {
*val = LEB128_3(unsigned int);
return 3;
} else if (pull_byte(read, &p[3]) && (p[3] & 0x80) == 0) {
} else if (cursor_pull_byte(read, &p[3]) && (p[3] & 0x80) == 0) {
*val = LEB128_4(unsigned int);
return 4;
} else if (pull_byte(read, &p[4]) && (p[4] & 0x80) == 0) {
} else if (cursor_pull_byte(read, &p[4]) && (p[4] & 0x80) == 0) {
if (!(p[4] & 0xF0)) {
*val = LEB128_5(unsigned int);
return 5;
@@ -1296,7 +1296,7 @@ static int parse_section_tag(struct cursor *cur, enum section_tag *section)
start = cur->p;
if (!pull_byte(cur, &byte)) {
if (!cursor_pull_byte(cur, &byte)) {
return 0;
}
@@ -1315,7 +1315,7 @@ static int parse_valtype(struct wasm_parser *p, enum valtype *valtype)
start = p->cur.p;
if (unlikely(!pull_byte(&p->cur, (unsigned char*)valtype))) {
if (unlikely(!cursor_pull_byte(&p->cur, (unsigned char*)valtype))) {
return parse_err(p, "valtype tag oob");
}
@@ -1416,7 +1416,7 @@ static int parse_export_desc(struct wasm_parser *p, enum exportdesc *desc)
{
unsigned char byte;
if (!pull_byte(&p->cur, &byte)) {
if (!cursor_pull_byte(&p->cur, &byte)) {
parse_err(p, "export desc byte eof");
return 0;
}
@@ -1523,7 +1523,7 @@ static int parse_name_subsection(struct wasm_parser *p, struct namesec *sec, u32
u8 tag;
u8 *start = p->cur.p;
if (!pull_byte(&p->cur, &tag))
if (!cursor_pull_byte(&p->cur, &tag))
return parse_err(p, "name subsection tag oob?");
if (!is_valid_name_subsection(tag))
@@ -1676,7 +1676,7 @@ static int parse_reftype(struct wasm_parser *p, enum reftype *reftype)
{
u8 tag;
if (!pull_byte(&p->cur, &tag)) {
if (!cursor_pull_byte(&p->cur, &tag)) {
parse_err(p, "reftype");
return 0;
}
@@ -1720,7 +1720,7 @@ static int parse_export_section(struct wasm_parser *p,
static int parse_limits(struct wasm_parser *p, struct limits *limits)
{
unsigned char tag;
if (!pull_byte(&p->cur, &tag)) {
if (!cursor_pull_byte(&p->cur, &tag)) {
return parse_err(p, "oob");
}
@@ -1803,7 +1803,7 @@ static void print_code(u8 *code, int code_len)
make_cursor(code, code + code_len, &c);
for (;;) {
if (!pull_byte(&c, &tag)) {
if (!cursor_pull_byte(&c, &tag)) {
break;
}
@@ -2169,7 +2169,7 @@ static int parse_const_expr(struct expr_parser *p, struct expr *expr)
expr->code = p->code->p;
while (1) {
if (unlikely(!pull_byte(p->code, &tag))) {
if (unlikely(!cursor_pull_byte(p->code, &tag))) {
return note_error(p->errs, p->code, "oob");
}
@@ -2332,7 +2332,7 @@ static int parse_instrs_until_at(struct expr_parser *p, u8 stop_instr,
p->code->p - p->code->start,
dbg_inst, instr_name(stop_instr));
for (;;) {
if (!pull_byte(p->code, &tag))
if (!cursor_pull_byte(p->code, &tag))
return note_error(p->errs, p->code, "oob");
if ((tag != i_if && tag == stop_instr) ||
@@ -2413,7 +2413,7 @@ static int parse_element(struct wasm_parser *p, struct elem *elem)
make_expr_parser(&p->errs, &p->cur, &expr_parser);
if (!pull_byte(&p->cur, &tag))
if (!cursor_pull_byte(&p->cur, &tag))
return parse_err(p, "tag");
if (tag > 7)
@@ -2545,7 +2545,7 @@ static int parse_wdata(struct wasm_parser *p, struct wdata *data)
struct expr_parser parser;
u8 tag;
if (!pull_byte(&p->cur, &tag)) {
if (!cursor_pull_byte(&p->cur, &tag)) {
return parse_err(p, "tag");
}
@@ -2700,7 +2700,7 @@ static int parse_importdesc(struct wasm_parser *p, struct importdesc *desc)
{
u8 tag;
if (!pull_byte(&p->cur, &tag)) {
if (!cursor_pull_byte(&p->cur, &tag)) {
parse_err(p, "oom");
return 0;
}
@@ -4134,7 +4134,7 @@ static int parse_blocktype(struct cursor *cur, struct errors *errs, struct block
{
unsigned char byte;
if (unlikely(!pull_byte(cur, &byte))) {
if (unlikely(!cursor_pull_byte(cur, &byte))) {
return note_error(errs, cur, "parse_blocktype: oob\n");
}
@@ -4656,7 +4656,7 @@ static int parse_bulk_op(struct cursor *code, struct errors *errs,
{
u8 tag;
if (unlikely(!pull_byte(code, &tag)))
if (unlikely(!cursor_pull_byte(code, &tag)))
return note_error(errs, code, "oob");
if (unlikely(tag < 10 || tag > 17))
@@ -6552,7 +6552,7 @@ static INLINE int interp_parse_instr(struct wasm_interp *interp,
{
u8 tag;
if (unlikely(!pull_byte(code, &tag))) {
if (unlikely(!cursor_pull_byte(code, &tag))) {
return interp_error(interp, "no more instrs to pull");
}

View File

@@ -27,6 +27,8 @@ static const unsigned char WASM_MAGIC[] = {0,'a','s','m'};
#define interp_error(p, fmt, ...) note_error(&((p)->errors), interp_codeptr(p), fmt, ##__VA_ARGS__)
#define parse_err(p, fmt, ...) note_error(&((p)->errs), &(p)->cur, fmt, ##__VA_ARGS__)
#include "short_types.h"
enum valtype {
val_i32 = 0x7F,
val_i64 = 0x7E,

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"originHash" : "06318d35ee2e6bd681b95591e67da33a9461b48a3c652e58bd9d1a6f0d82bdac",
"originHash" : "1fc7e0b44329ba72cd285eeb022b5b92582cd01586b920d243cb0485c2e69dcc",
"pins" : [
{
"identity" : "codescanner",
@@ -35,6 +35,15 @@
"version" : "0.2.0"
}
},
{
"identity" : "faviconfinder",
"kind" : "remoteSourceControl",
"location" : "https://github.com/will-lumley/FaviconFinder.git",
"state" : {
"revision" : "9279f4371f4877ca302ba3bf1015f3f58ae4a56c",
"version" : "5.1.4"
}
},
{
"identity" : "gsplayer",
"kind" : "remoteSourceControl",
@@ -49,8 +58,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher",
"state" : {
"revision" : "415b1d97fb38bda1e5a6b2dde63354720832110b",
"version" : "7.6.1"
"revision" : "4c6b067f96953ee19526e49e4189403a2be21fb3",
"version" : "8.3.1"
}
},
{
@@ -105,6 +114,15 @@
"version" : "0.1.2"
}
},
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup.git",
"state" : {
"revision" : "bba848db50462894e7fc0891d018dfecad4ef11e",
"version" : "2.8.7"
}
},
{
"identity" : "swiftycrop",
"kind" : "remoteSourceControl",

View File

@@ -25,18 +25,57 @@ 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`
/// Any interest option button on the "select your interests" page during onboarding
case onboarding_interest_option_button
/// The "next" button on the onboarding interest page
case onboarding_interest_page_next_page
/// The "next" button on the onboarding content settings page
case onboarding_content_settings_page_next_page
/// The skip button on the onboarding sheet
case onboarding_sheet_skip_button
// 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`
@@ -51,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`

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "bbw.jpg",
"filename" : "blink.png",
"idiom" : "universal"
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

Before

Width:  |  Height:  |  Size: 216 B

After

Width:  |  Height:  |  Size: 216 B

View File

@@ -1,15 +0,0 @@
//
// AlbyGradient.swift
// damus
//
// Created by William Casarin on 2023-05-09.
//
import SwiftUI
fileprivate let alby_grad_c1 = hex_col(r: 226, g: 168, b: 122)
fileprivate let alby_grad_c2 = hex_col(r: 249, g: 223, b: 127)
fileprivate let alby_grad = [alby_grad_c2, alby_grad_c1]
let AlbyGradient: LinearGradient =
LinearGradient(colors: alby_grad, startPoint: .bottomLeading, endPoint: .topTrailing)

View File

@@ -9,6 +9,7 @@ import SwiftUI
import AVKit
import MediaPlayer
import EmojiPicker
import TipKit
struct ZapSheet {
let target: ZapTarget
@@ -134,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()
@@ -172,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)
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle)
}
}
.background(DamusColors.adaptableWhite)
@@ -194,12 +196,15 @@ struct ContentView: View {
}
}
}
.onAppear {
notify(.display_tabbar(true))
}
}
func MaybeReportView(target: ReportTarget) -> some View {
Group {
if let keypair = damus_state.keypair.to_full() {
ReportView(postbox: damus_state.postbox, target: target, keypair: keypair)
ReportView(postbox: damus_state.nostrNetwork.postbox, target: target, keypair: keypair)
} else {
EmptyView()
}
@@ -298,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
@@ -317,7 +326,7 @@ struct ContentView: View {
case .post(let action):
PostView(action: action, damus_state: damus_state!)
case .user_status:
UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.nostrNetwork.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
.presentationDragIndicator(.visible)
case .event:
EventDetailView()
@@ -333,7 +342,20 @@ struct ContentView: View {
.presentationDetents([.height(550)])
.presentationDragIndicator(.visible)
case .onboardingSuggestions:
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!))
if let model = try? SuggestedUsersViewModel(damus_state: damus_state!) {
OnboardingSuggestionsView(model: model)
.interactiveDismissDisabled(true)
}
else {
ErrorView(
damus_state: damus_state,
error: .init(
user_visible_description: NSLocalizedString("Unexpected error loading user suggestions", comment: "Human readable error label"),
tip: NSLocalizedString("Please contact support", comment: "Human readable error tip"),
technical_info: "Error inializing SuggestedUsersViewModel"
)
)
}
case .purple(let purple_url):
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
case .purple_onboarding:
@@ -356,7 +378,7 @@ struct ContentView: View {
self.hide_bar = !show
}
.onReceive(timer) { n in
self.damus_state?.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
@@ -367,45 +389,46 @@ struct ContentView: View {
self.confirm_mute = true
}
.onReceive(handle_notify(.attached_wallet)) { nwc in
try? damus_state.pool.add_relay(.nwc(url: nwc.relay))
// 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.postbox.send(ev)
}
.onReceive(handle_notify(.broadcast)) { ev in
guard let ds = self.damus_state else { return }
ds.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)
@@ -416,8 +439,10 @@ struct ContentView: View {
return
}
if !handle_post_notification(keypair: keypair, postbox: state.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) {
self.active_sheet = nil
}
}
}
.onReceive(handle_notify(.new_mutes)) { _ in
@@ -435,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.resubscribe(.following)
}
.onReceive(handle_notify(.zapping)) { zap_ev in
guard !zap_ev.is_custom else {
return
@@ -460,35 +488,35 @@ struct ContentView: View {
}
}
.onReceive(handle_notify(.disconnect_relays)) { () in
damus_state.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)
}
}
@@ -497,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:
@@ -506,26 +547,34 @@ struct ContentView: View {
break
case .active:
print("txn: 📙 DAMUS ACTIVE")
damus_state.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.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.")) {
@@ -533,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 {
@@ -548,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.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.")
@@ -575,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 {
@@ -589,13 +643,12 @@ struct ContentView: View {
}
ds.mutelist_manager.set_mutelist(ev)
ds.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 {
@@ -630,7 +683,7 @@ struct ContentView: View {
func handleNotification(notification: LossyLocalNotification) {
Log.info("ContentView is handling a notification", for: .push_notifications)
guard let damus_state else {
guard damus_state != nil else {
// This should never happen because `listenAndHandleLocalNotifications` is called after damus state is initialized in `onAppear`
assertionFailure("DamusState not loaded when ContentView (new handler) was handling a notification")
Log.error("DamusState not loaded when ContentView (new handler) was handling a notification", for: .push_notifications)
@@ -641,7 +694,7 @@ struct ContentView: View {
self.execute_open_action(openAction)
}
func connect() {
func connect() async {
// nostrdb
var mndb = Ndb()
if mndb == nil {
@@ -658,31 +711,18 @@ struct ContentView: View {
guard let ndb = mndb else { return }
let pool = RelayPool(ndb: ndb, keypair: keypair)
let model_cache = RelayModelCache()
let relay_filters = RelayFilters(our_pubkey: pubkey)
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
let new_relay_filters = load_relay_filters(pubkey) == nil
for relay in bootstrap_relays {
let descriptor = RelayDescriptor(url: relay, info: .rw)
add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode)
}
let new_relay_filters = await load_relay_filters(pubkey) == nil
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
if let nwc_str = settings.nostr_wallet_connect,
let nwc = WalletConnectURL(str: nwc_str) {
try? pool.add_relay(.nwc(url: nwc.relay))
}
self.damus_state = DamusState(pool: pool,
keypair: keypair,
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,
@@ -695,8 +735,6 @@ struct ContentView: View {
drafts: Drafts(),
events: EventCache(ndb: ndb),
bookmarks: BookmarksManager(pubkey: pubkey),
postbox: PostBox(pool: pool),
bootstrap_relays: bootstrap_relays,
replies: ReplyCounter(our_pubkey: pubkey),
wallet: WalletModel(settings: settings),
nav: self.navigationCoordinator,
@@ -704,11 +742,14 @@ struct ContentView: View {
video: DamusVideoCoordinator(),
ndb: ndb,
quote_reposts: .init(our_pubkey: pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: FaviconCache()
)
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
@@ -720,30 +761,52 @@ struct ContentView: View {
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
}
pool.connect()
if #available(iOS 17, *) {
if damus_state.settings.developer_mode && damus_state.settings.reset_tips_on_launch {
do {
try Tips.resetDatastore()
} catch {
Log.error("Failed to reset tips datastore: %s", for: .tips, error.localizedDescription)
}
}
do {
try Tips.configure()
} catch {
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.postbox.send(ev)
}
}
@@ -759,6 +822,8 @@ struct ContentView: View {
case route(Route)
/// Open a sheet
case sheet(Sheets)
/// Open an external URL
case external_url(URL)
/// Do nothing.
///
/// ## Implementation notes
@@ -775,6 +840,8 @@ struct ContentView: View {
navigationCoordinator.push(route: route)
case .sheet(let sheet):
self.active_sheet = sheet
case .external_url(let url):
this_app.open(url)
case .no_action:
return
}
@@ -789,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
@@ -891,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()
@@ -926,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.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.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.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.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?) -> ()) {
var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
let subid = UUID().description
damus_state.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in
guard case .nostr_event(let ev) = res else {
damus_state.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.pool.unsubscribe(sub_id: subid, to: [relay_id])
callback(ev)
return
}
}
}
}
damus_state.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 ""
@@ -1106,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.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
}
@@ -1134,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.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
}
@@ -1159,18 +1070,18 @@ 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 {
func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, post: NostrPostResult) async -> Bool {
switch post {
case .post(let post):
//let post = tup.0
@@ -1179,17 +1090,17 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
guard let new_ev = post.to_event(keypair: keypair) 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
@@ -1203,8 +1114,8 @@ extension LossyLocalNotification {
/// Computes a view open action from a mention reference.
/// Use this when opening a user-presentable interface to a specific mention reference.
func toViewOpenAction() -> ContentView.ViewOpenAction {
switch self.mention {
case .pubkey(let pubkey):
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)))
@@ -1214,7 +1125,7 @@ extension LossyLocalNotification {
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))
case .nrelay(let string):
case .nrelay:
// We do not need to implement `nrelay` support, it has been deprecated.
// See https://github.com/nostr-protocol/nips/blob/6e7a618e7f873bb91e743caacc3b09edab7796a0/BREAKING.md?plain=1#L21
return .sheet(.error(ErrorView.UserPresentableError(
@@ -1224,14 +1135,21 @@ extension LossyLocalNotification {
)))
case .naddr(let nAddr):
return .route(.LoadableNostrEvent(note_reference: .naddr(nAddr)))
case .nsec(_):
// `nsec` urls are a terrible idea security-wise, so we should intentionally not support those in order to discourage their use.
return .sheet(.error(ErrorView.UserPresentableError(
user_visible_description: NSLocalizedString("You opened an invalid link. The link you tried to open refers to \"nsec\", which is not supported.", comment: "User-visible error description for a user who tries to open an unsupported \"nsec\" link."),
tip: NSLocalizedString("Please contact the person who provided the link, and ask for another link. Also, this link may have sensitive information, please use caution before sharing it.", comment: "User-visible tip on what to do if a link contains an unsupported \"nsec\" reference."),
technical_info: "`MentionRef.toViewOpenAction` detected unsupported `nsec` contents"
)))
case .nscript(let script):
return .route(.Script(script: ScriptModel(data: script, state: .not_loaded)))
}
}
}
func logout(_ state: DamusState?)
{
state?.close()
notify(.logout)
}

View File

@@ -0,0 +1,77 @@
//
// Interests.swift
// damus
//
// Created by Daniel DAquino on 2025-06-25.
//
import Foundation
struct DIP06 {
/// Standard general interest topics.
/// See https://github.com/damus-io/dips/pull/3
enum Interest: String, CaseIterable {
/// Bitcoin-related topics (e.g. Bitcoin, Lightning, e-cash etc)
case bitcoin = "bitcoin"
/// Any non-Bitcoin technology-related topic (e.g. Linux, new releases, software development, supersonic flight, etc)
case technology = "technology"
/// Any science-related topic (e.g. astronomy, biology, physics, etc)
case science = "science"
/// Lifestyle topics (e.g. Worldschooling, Digital nomading, vagabonding, homesteading, digital minimalism, life hacks, etc)
case lifestyle = "lifestyle"
/// Travel-related topics (e.g. Information about locations to visit, travel logs, etc)
case travel = "travel"
/// Any art-related topic (e.g. poetry, painting, sculpting, photography, etc)
case art = "art"
/// Topics focused on improving human health (e.g. advances in medicine, exercising, nutrition, meditation, sleep, etc)
case health = "health"
/// Any music-related topic (e.g. Bands, fan pages, instruments, classical music theory, etc)
case music = "music"
/// Any topic related to food (e.g. Cooking, recipes, meal planning, nutrition)
case food = "food"
/// Any topic related to sports (e.g. Athlete fan pages, general sports information, sports news, sports equipment, etc)
case sports = "sports"
/// Any topic related to religion, spirituality, or faith (e.g. Christianity, Judaism, Buddhism, Islamism, Hinduism, Taoism, general meditation practice, etc)
case religionSpirituality = "religion-spirituality"
/// General humanities topics (e.g. philosophy, sociology, culture, etc)
case humanities = "humanities"
/// General topics about politics
case politics = "politics"
/// Other miscellaneous topics that do not fit in any of the previous items of the list
case other = "other"
var label: String {
switch self {
case .bitcoin:
return NSLocalizedString("₿ Bitcoin", comment: "Interest topic label")
case .technology:
return NSLocalizedString("💻 Tech", comment: "Interest topic label")
case .science:
return NSLocalizedString("🔭 Science", comment: "Interest topic label")
case .lifestyle:
return NSLocalizedString("🏝️ Lifestyle", comment: "Interest topic label")
case .travel:
return NSLocalizedString("✈️ Travel", comment: "Interest topic label")
case .art:
return NSLocalizedString("🎨 Art", comment: "Interest topic label")
case .health:
return NSLocalizedString("🏃 Health", comment: "Interest topic label")
case .music:
return NSLocalizedString("🎶 Music", comment: "Interest topic label")
case .food:
return NSLocalizedString("🍱 Food", comment: "Interest topic label")
case .sports:
return NSLocalizedString("⚾️ Sports", comment: "Interest topic label")
case .religionSpirituality:
return NSLocalizedString("🛐 Religion", comment: "Interest topic label")
case .humanities:
return NSLocalizedString("📚 Humanities", comment: "Interest topic label")
case .politics:
return NSLocalizedString("🏛️ Politics", comment: "Interest topic label")
case .other:
return NSLocalizedString("♾️ Other", comment: "Interest topic label")
}
}
}
}

View File

@@ -0,0 +1,79 @@
//
// NIP04.swift
// damus
//
// Created by Daniel DAquino on 2025-03-10.
//
import Foundation
/// Functions and utilities for the NIP-04 spec
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).byteArray
guard let shared_sec = get_shared_secret(privkey: privkey, pubkey: to_pk) else {
return nil
}
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
}
switch encoding {
case .base64:
return encode_dm_base64(content: enc_message.bytes, iv: iv)
case .bech32:
return encode_dm_bech32(content: enc_message.bytes, iv: iv)
}
}
/// Creates an event with encrypted `contents` field, using NIP-04
static func create_encrypted_event(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: FullKeypair, created_at: UInt32, kind: UInt32) -> NostrEvent? {
let privkey = keypair.privkey
guard let enc_content = encrypt_message(message: message, privkey: privkey, to_pk: to_pk) else {
return nil
}
return NostrEvent(content: enc_content, keypair: keypair.to_keypair(), kind: kind, tags: tags, createdAt: created_at)
}
/// Creates a NIP-04 style direct message event
static func create_dm(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: Keypair, created_at: UInt32? = nil) -> NostrEvent?
{
let created = created_at ?? UInt32(Date().timeIntervalSince1970)
guard let keypair = keypair.to_full() else {
return nil
}
return create_encrypted_event(message, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created, kind: 4)
}
/// Decrypts string content
static func decryptContent(recipientPrivateKey: Privkey, senderPubkey: Pubkey, content: String, encoding: EncEncoding) throws(NIP04DecryptionError) -> String {
guard let shared_sec = get_shared_secret(privkey: recipientPrivateKey, pubkey: senderPubkey) else {
throw .failedToComputeSharedSecret
}
guard let dat = (encoding == .base64 ? decode_dm_base64(content) : decode_dm_bech32(content)) else {
throw .failedToDecodeEncryptedContent
}
guard let dat = aes_decrypt(data: dat.content, iv: dat.iv, shared_sec: shared_sec) else {
throw .failedToDecryptAES
}
guard let decryptedString = String(data: dat, encoding: .utf8) else {
throw .utf8DecodingFailedOnDecryptedPayload
}
return decryptedString
}
enum NIP04DecryptionError: Error {
case failedToComputeSharedSecret
case failedToDecodeEncryptedContent
case failedToDecryptAES
case utf8DecodingFailedOnDecryptedPayload
}
}

View File

@@ -0,0 +1,111 @@
//
// InterestList.swift
// damus
//
// Created by Daniel D'Aquino on 2025-06-23.
//
// Some text excerpts taken from the Nostr Protocol itself (which are public domain)
import Foundation
/// Includes models and functions for working with NIP-51
struct NIP51: Sendable {}
extension NIP51 {
/// An error thrown when decoding an item into a NIP-51 list
enum NIP51DecodingError: Error {
/// The Nostr event being converted is not a NIP-51 interest list
case notInterestList
}
}
extension NIP51 {
/// Models a NIP-51 Interest List (kind:10015)
struct InterestList: NostrEventConvertible, Sendable {
typealias E = NIP51DecodingError
enum InterestItem: Sendable, Hashable {
case hashtag(String)
case interestSet(String, String, String) // a-tag: kind, pubkey, identifier
var tag: [String] {
switch self {
case .hashtag(let tag):
return ["t", tag]
case .interestSet(let kind, let pubkey, let identifier):
var tag = ["a", "\(kind):\(pubkey):\(identifier)"]
return tag
}
}
static func fromTag(tag: TagSequence) -> InterestItem? {
var i = tag.makeIterator()
guard let t0 = i.next(),
let t1 = i.next() else { return nil }
let tagName = t0.string()
if tagName == "t" {
return .hashtag(t1.string())
} else if tagName == "a" {
let components = t1.string().split(separator: ":")
guard components.count > 2 else { return nil }
let kind = String(components[0])
let pubkey = String(components[1])
let identifier = String(components[2])
return .interestSet(kind, pubkey, identifier)
}
return nil
}
}
let interests: [InterestItem]
// MARK: - Initialization
init(event: NdbNote) throws(E) {
try self.init(event: UnownedNdbNote(event))
}
init(event: borrowing UnownedNdbNote) throws(E) {
guard event.known_kind == .interest_list else {
throw E.notInterestList
}
var interests: [InterestItem] = []
for tag in event.tags {
if let interest = InterestItem.fromTag(tag: tag) {
interests.append(interest)
}
}
self.interests = interests
}
init?(event: NdbNote?) throws(E) {
guard let event else { return nil }
try self.init(event: event)
}
init(interests: [InterestItem]) {
self.interests = interests
}
// MARK: - Conversion to a Nostr Event
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32? = nil) -> NostrEvent? {
return NdbNote(
content: "",
keypair: keypair.to_keypair(),
kind: NostrKind.interest_list.rawValue,
tags: self.interests.map { $0.tag },
createdAt: timestamp ?? UInt32(Date.now.timeIntervalSince1970)
)
}
}
}

View File

@@ -0,0 +1,175 @@
//
// NIP65.swift
// damus
//
// Created by Daniel DAquino on 2025-02-21.
//
// Some text excerpts taken from the Nostr Protocol itself (which are public domain)
import OrderedCollections
import Foundation
/// Includes models and functions for working with NIP-65
struct NIP65: Sendable {}
extension NIP65 {
/// Models a NIP-65 relay list
struct RelayList: NostrEventConvertible, Sendable {
let relays: OrderedDictionary<RelayURL, RelayItem>
// MARK: - Initialization
init(event: NdbNote) throws(NIP65DecodingError) {
try self.init(event: UnownedNdbNote(event))
}
init(event: borrowing UnownedNdbNote) throws(NIP65DecodingError) {
guard event.known_kind == .relay_list else { throw .notRelayList }
var relays: [RelayItem] = []
for tag in event.tags {
guard let relay = try RelayItem.fromTag(tag: tag) else { continue }
relays.append(relay)
}
self.relays = Self.relayOrderedDictionary(from: relays)
}
init?(event: NdbNote?) throws(NIP65DecodingError) {
guard let event else { return nil }
try self.init(event: event)
}
init(relays: [RelayItem]) {
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)
}
private static func relayOrderedDictionary(from relayList: [RelayItem]) -> OrderedDictionary<RelayURL, RelayItem> {
var seenUrls: Set<RelayURL> = []
return OrderedDictionary(uniqueKeysWithValues: relayList.compactMap({
// We need to ensure the keys are unique to avoid assertion errors from OrderedDictionary
guard !seenUrls.contains($0.url) else { return nil }
seenUrls.insert($0.url)
return ($0.url, $0)
}))
}
// MARK: - Conversion to a Nostr Event
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32? = nil) -> NostrEvent? {
return NdbNote(
content: "",
keypair: keypair.to_keypair(),
kind: NostrKind.relay_list.rawValue,
tags: self.relays.values.map({ $0.tag }),
createdAt: timestamp ?? UInt32(Date.now.timeIntervalSince1970)
)
}
}
}
extension NIP65 {
/// An error thrown when decoding an item into a NIP-65 relay list
enum NIP65DecodingError: Error {
/// The Nostr event being converted is not a NIP-65 relay list
case notRelayList
/// The relay URL is invalid
case invalidRelayURL
///The relay RW marker is invalid
case invalidRelayMarker
}
}
extension NIP65.RelayList {
/// An item referencing a relay and its configuration inside a relay list
struct RelayItem: ThrowingTagConvertible, Sendable {
typealias E = NIP65.NIP65DecodingError
let url: RelayURL
let rwConfiguration: RWConfiguration
/// The raw tag sequence in a Nostr event
var tag: [String] {
var tag = ["r", url.absoluteString]
if let rwMarker = rwConfiguration.tagItem { tag.append(rwMarker) }
return tag
}
/// Initialize a new relay item from a Nostr event's tag sequence
static func fromTag(tag: TagSequence) throws(E) -> NIP65.RelayList.RelayItem? {
var i = tag.makeIterator()
guard tag.count >= 2,
let t0 = i.next(),
let key = t0.single_char,
let rkey = RefId.RefKey(rawValue: key),
let t1 = i.next()
else { return nil }
let t2 = i.next()
switch rkey {
case .r: return try self.fromRawInfo(urlString: t1.string(), rwMarker: t2?.string())
// Keep options explicit to make compiler prompt developer on whether to ignore or handle new future options
case .e, .p, .q, .t, .d, .a: return nil
}
}
/// Initializes a Relay Item based on raw information
static func fromRawInfo(urlString: String, rwMarker: String?) throws(NIP65.NIP65DecodingError) -> NIP65.RelayList.RelayItem? {
guard let relayUrl = RelayURL(urlString) else { throw .invalidRelayURL }
guard let rwConfiguration = RWConfiguration.fromTagItem(rwMarker) else { throw .invalidRelayMarker }
return NIP65.RelayList.RelayItem(url: relayUrl, rwConfiguration: rwConfiguration)
}
}
}
extension NIP65.RelayList.RelayItem {
/// The read/write configuration for a relay item
enum RWConfiguration: TagItemConvertible {
case read
case write
case readWrite
static let READ_MARKER: String = "read"
static let WRITE_MARKER: String = "write"
var canRead: Bool {
switch self {
case .read, .readWrite: return true
case .write: return false
}
}
var canWrite: Bool {
switch self {
case .write, .readWrite: return true
case .read: return false
}
}
/// A raw Nostr Event tag item
var tagItem: String? {
switch self {
case .read: Self.READ_MARKER
case .write: Self.WRITE_MARKER
case .readWrite: nil
}
}
/// Initialize this from a raw Nostr Event tag item
static func fromTagItem(_ item: String?) -> Self? {
if item == READ_MARKER { return .read }
if item == WRITE_MARKER { return .write }
return .readWrite
}
}
}

View File

@@ -7,7 +7,7 @@
import Foundation
func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: Relay) -> NostrEvent? {
func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: RelayPool.Relay) -> NostrEvent? {
let tags: [[String]] = [["relay", relay.descriptor.url.absoluteString],["challenge", challenge_string]]
let event = NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 22242, tags: tags)
return event

View File

@@ -0,0 +1,215 @@
//
// NostrNetworkManager.swift
// damus
//
// Created by Daniel DAquino on 2025-02-26.
//
import Foundation
/// Manages interactions with the Nostr Network.
///
/// This delineates a layer that is responsible for doing mid-level management of interactions with the Nostr network, controlling lower-level classes that perform more network/DB specific code, and providing an easier to use and more semantic interfaces for the rest of the app.
///
/// This is responsible for:
/// - Managing the user's relay list
/// - Establishing a `RelayPool` and maintaining it in sync with the user's relay list as it changes
/// - Abstracting away complexities of interacting with the nostr network, providing an easier-to-use interface to fetch and send content related to the Nostr network
///
/// This is **NOT** responsible for:
/// - Doing actual storage of relay list (delegated via the delegate
/// - Handling low-level relay logic (this will be delegated to lower level classes used in RelayPool/RelayConnection)
class NostrNetworkManager {
/// The relay pool that we manage
///
/// ## 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
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
let userRelayList: UserRelayListManager
/// Handles sending out notes to the network
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, addNdbToRelayPool: Bool = true) {
self.delegate = delegate
let pool = RelayPool(ndb: addNdbToRelayPool ? delegate.ndb : nil, keypair: delegate.keypair)
self.pool = pool
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 and lifecycle functions
/// Connects the app to the Nostr network
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()
}
func disconnectRelays() async {
await self.pool.disconnect()
}
func handleAppBackgroundRequest() async {
await self.reader.cancelAllTasks()
await self.pool.cleanQueuedRequestForSessionEnd()
}
func handleAppForegroundRequest() async {
// Pinging the network will automatically reconnect any dead websocket connections
await self.ping()
}
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()
}
// But await on each one to prevent race conditions
for await value in group { continue }
await pool.close()
}
}
func ping() async {
await self.pool.ping()
}
@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 = 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)
}
}
// MARK: - Helper types
extension NostrNetworkManager {
/// The delegate that provides information and structure for the `NostrNetworkManager` to function.
///
/// ## Implementation notes
///
/// This is needed to prevent a circular reference between `DamusState` and `NostrNetworkManager`, and reduce coupling.
protocol Delegate: Sendable {
/// NostrDB instance, used with `RelayPool` to send events for ingestion.
var ndb: Ndb { get }
/// The keypair to use for relay authentication and updating relay lists
var keypair: Keypair { get }
/// The latest relay list event id hex
var latestRelayListEventIdHex: String? { get set } // TODO: Update this once we have full NostrDB query support
/// 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
var bootstrapRelays: [RelayURL] { get }
/// 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 }
/// Relay filters
var relayFilters: RelayFilters { get }
/// The user's connected NWC wallet
var nwcWallet: WalletConnectURL? { get }
}
}

View File

@@ -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(optimizeNetworkFilter: false)) {
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
}
}
}

View File

@@ -0,0 +1,564 @@
//
// SubscriptionManager.swift
// damus
//
// Created by Daniel DAquino on 2025-03-25.
//
import Foundation
import os
extension NostrNetworkManager {
/// Reads or fetches information from RelayPool and NostrDB, and provides an easier and unified higher-level interface.
///
/// ## Implementation notes
///
/// - This class will be a key part of the local relay model migration. Most higher-level code should fetch content from this class, which will properly setup the correct relay pool subscriptions, and provide a stream from NostrDB for higher performance and reliability.
class SubscriptionManager {
private let pool: RelayPool
private var ndb: Ndb
private var taskManager: TaskManager
private let experimentalLocalRelayModelSupport: Bool
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
}
// MARK: - Subscribing and Streaming data from Nostr
/// Streams notes until the EOSE signal
func streamExistingEvents(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, timeout: Duration? = nil, streamMode: StreamMode? = 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, id: id) {
try Task.checkCancellation()
switch item {
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, 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, 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, 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, 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
streamingTask.cancel()
}
}
}
func advancedStream(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, timeout: Duration? = 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")
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
let startNetworkStreamTask = {
guard streamMode.shouldStreamFromNetwork else { return }
networkStreamTask = Task {
while !Task.isCancelled {
let optimizedFilters = filters.map {
var optimizedFilter = $0
// Shift the since filter 2 minutes (120 seconds) before the last note timestamp
if let latestTimestamp = latestNoteTimestampSeen {
optimizedFilter.since = latestTimestamp > 120 ? latestTimestamp - 120 : 0
}
return optimizedFilter
}
for await item in self.multiSessionNetworkStream(filters: optimizedFilters, to: desiredRelays, streamMode: streamMode, id: id) {
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)")
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
}
})
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
networkStreamTask?.cancel()
ndbStreamTask.cancel()
}
}
}
private func multiSessionNetworkStream(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("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.pool.subscribe(filters: filters, to: desiredRelays, id: id) {
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()
}
}
}
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 {
self.experimentalLocalRelayModelSupport ? .ndbFirst(optimizeNetworkFilter: false) : .ndbAndNetworkParallel(optimizeNetworkFilter: false)
}
// MARK: - Finding specific data from Nostr
/// Finds a non-replaceable event based on a note ID
func lookup(noteId: NoteId, to targetRelays: [RelayURL]? = nil, timeout: Duration? = nil) async throws -> NdbNoteLender? {
let filter = NostrFilter(ids: [noteId], limit: 1)
// Since note ids point to immutable objects, we can do a simple ndb lookup first
if let noteKey = try? self.ndb.lookup_note_key(noteId) {
return NdbNoteLender(ndb: self.ndb, noteKey: noteKey)
}
// Not available in local ndb, stream from network
outerLoop: for await item in await self.pool.subscribe(filters: [NostrFilter(ids: [noteId], limit: 1)], to: targetRelays, eoseTimeout: timeout) {
switch item {
case .event(let event):
return NdbNoteLender(ownedNdbNote: event)
case .eose:
break outerLoop
}
}
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 replaceable event based on an `naddr` address.
///
/// - Parameters:
/// - naddr: the `naddr` address
func lookup(naddr: NAddr, to targetRelays: [RelayURL]? = nil, timeout: Duration? = nil) async -> NostrEvent? {
var 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: targetRelays, timeout: timeout) {
// TODO: This can be refactored to borrow the note instead of copying it. But we need to implement `referenced_params` on `UnownedNdbNote` to do so
guard let event = noteLender.justGetACopy() else { continue }
if event.referenced_params.first?.param.string() == naddr.identifier {
return event
}
}
return nil
}
// TODO: Improve this. This is mostly intact to keep compatibility with its predecessor, but we can do better
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)
}
var attempts: Int = 0
var has_event = false
guard let filter else { return nil }
for await noteLender in self.streamExistingEvents(filters: [filter], to: find_from) {
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 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")
}
}
}
}
enum StreamItem {
/// An event which can be borrowed from NostrDB
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
/// `optimizeNetworkFilter`: Returns notes from ndb, then streams from the network with an added "since" filter set to the latest note stored on ndb.
case ndbFirst(optimizeNetworkFilter: Bool)
/// 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
/// `optimizeNetworkFilter`: Returns notes from ndb, then streams from the network with an added "since" filter set to the latest note stored on ndb.
case ndbAndNetworkParallel(optimizeNetworkFilter: Bool)
/// Ignores the network.
case ndbOnly
var optimizeNetworkFilter: Bool {
switch self {
case .ndbFirst(optimizeNetworkFilter: let optimizeNetworkFilter):
return optimizeNetworkFilter
case .ndbAndNetworkParallel(optimizeNetworkFilter: let optimizeNetworkFilter):
return optimizeNetworkFilter
case .ndbOnly:
return false
}
}
var shouldStreamFromNetwork: Bool {
switch self {
case .ndbFirst:
return true
case .ndbAndNetworkParallel:
return true
case .ndbOnly:
return false
}
}
}
}

View File

@@ -0,0 +1,85 @@
//
// UserRelayListErrors.swift
// damus
//
// Created by Daniel DAquino on 2025-02-27.
//
import Foundation
extension NostrNetworkManager.UserRelayListManager {
/// Models an error that may occur when performing operations that change the user's relay list.
///
/// Callers to functions that throw this error SHOULD handle them in order to provide a better user experience.
enum UpdateError: Error {
/// The user is not authorized to change relay list, usually because the private key is missing.
case notAuthorizedToChangeRelayList
/// An error occurred when forming the relay list Nostr event.
case cannotFormRelayListEvent
/// Cannot add item to the relay list because the relay is already present in the list.
case relayAlreadyExists
/// Cannot update the relay list because we do not have the user's previous relay list.
///
/// Implementers must be careful not to overwrite the user's existing relay list if it exists somewhere else.
case noInitialRelayList
/// Cannot remove or update a specific relay because it is not on the relay list
case noSuchRelay
/// Convert `RelayPool.RelayError` into `UserRelayListUpdateError`
static func from(_ relayPoolError: RelayPool.RelayError) -> Self {
switch relayPoolError {
case .RelayAlreadyExists: return .relayAlreadyExists
}
}
var humanReadableError: ErrorView.UserPresentableError {
switch self {
case .notAuthorizedToChangeRelayList:
ErrorView.UserPresentableError(
user_visible_description: NSLocalizedString("You do not have permission to alter this relay list.", comment: "Human readable error description"),
tip: NSLocalizedString("Please make sure you have logged-in with your private key.", comment: "Human readable tip for error"),
technical_info: nil
)
case .cannotFormRelayListEvent:
ErrorView.UserPresentableError(
user_visible_description: NSLocalizedString("There was a problem creating the relay list event.", comment: "Human readable error description"),
tip: NSLocalizedString("Please try again later or contact support if the issue persists.", comment: "Human readable tip for error"),
technical_info: "Failed forming Nostr event for the relay list update."
)
case .relayAlreadyExists:
ErrorView.UserPresentableError(
user_visible_description: NSLocalizedString("This relay is already in your list.", comment: "Human readable tip for error"),
tip: NSLocalizedString("Check the address and/or the relay list.", comment: "Human readable tip for error"),
technical_info: nil
)
case .noInitialRelayList:
ErrorView.UserPresentableError(
user_visible_description: NSLocalizedString("No initial relay list available to update.", comment: "Human readable error description"),
tip: NSLocalizedString("Please go to Settings > First Aid > Repair relay list, or contact support.", comment: "Human readable tip for error"),
technical_info: "Missing initial relay list data for reference during update."
)
case .noSuchRelay:
ErrorView.UserPresentableError(
user_visible_description: NSLocalizedString("The specified relay that you are trying to udpate was not found in your relay list.", comment: "Human readable error description"),
tip: NSLocalizedString("This is an unexpected error, please contact support.", comment: "Human readable tip for error"),
technical_info: nil
)
}
}
}
enum LoadingError: Error {
case relayListParseError
var humanReadableError: ErrorView.UserPresentableError {
switch self {
case .relayListParseError:
return ErrorView.UserPresentableError(
user_visible_description: NSLocalizedString("Your relay list appears to be broken, so we cannot connect you to your Nostr network.", comment: "Human readable error description for a failure to parse the relay list due to a bad relay list"),
tip: NSLocalizedString("Please contact support for further help.", comment: "Human readable tips for what to do for a failure to find the relay list"),
technical_info: "Relay list could not be parsed."
)
}
}
}
}

View File

@@ -0,0 +1,323 @@
//
// UserRelayListManager.swift
// damus
//
// Created by Daniel DAquino on 2025-02-27.
//
import Foundation
import Combine
extension NostrNetworkManager {
/// Manages the user's relay list
///
/// - It can compute the user's current relay list
/// - It can compute the best relay list to connect to
/// - It can edit the user's relay list
class UserRelayListManager {
private var delegate: Delegate
private let pool: RelayPool
private let reader: SubscriptionManager
private var relayListObserverTask: Task<Void, Never>? = nil
private var walletUpdatesObserverTask: AnyCancellable? = nil
init(delegate: Delegate, pool: RelayPool, reader: SubscriptionManager) {
self.delegate = delegate
self.pool = pool
self.reader = reader
}
// MARK: - Computing the relays to connect to
@MainActor
private func relaysToConnectTo() -> [RelayPool.RelayDescriptor] {
return self.computeRelaysToConnectTo(with: self.getBestEffortRelayList())
}
private func computeRelaysToConnectTo(with relayList: NIP65.RelayList) -> [RelayPool.RelayDescriptor] {
let regularRelayDescriptorList = relayList.toRelayDescriptors()
if let nwcWallet = delegate.nwcWallet {
return regularRelayDescriptorList + [.nwc(url: nwcWallet.relay)]
}
return regularRelayDescriptorList
}
// MARK: - Getting the user's relay list
/// Gets the "best effort" relay list.
///
/// 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)
}
return userCurrentRelayList
}
/// 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 }
if let latestRelayListEvent = try? self.getLatestUserDefaultsRelayList() { return latestRelayListEvent }
return nil
}
/// Gets the latest NIP-65 relay list from NostrDB.
///
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
///
/// - Returns: The latest NIP-65 relay list object
private func getLatestNIP65RelayList() throws(LoadingError) -> NIP65.RelayList? {
guard let latestRelayListEvent = self.getLatestNIP65RelayListEvent() else { return nil }
guard let list = try? NIP65.RelayList(event: latestRelayListEvent) else { throw .relayListParseError }
return list
}
/// Gets the latest NIP-65 relay list event from NostrDB.
///
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
///
/// It is recommended to use this function only if the NostrEvent metadata is needed. For cases where only the relay list info is needed, use `getLatestNIP65RelayList` instead.
///
/// - Returns: The latest NIP-65 relay list NdbNote
private func getLatestNIP65RelayListEvent() -> NdbNote? {
guard let latestRelayListEventId = delegate.latestRelayListEventIdHex else { return nil }
guard let latestRelayListEventId = NoteId(hex: latestRelayListEventId) else { return nil }
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 }
return legacyContactList
}
/// Gets the latest relay list from `UserDefaults`
///
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
private func getLatestUserDefaultsRelayList() throws(LoadingError) -> NIP65.RelayList? {
let key = bootstrap_relays_setting_key(pubkey: delegate.keypair.pubkey)
guard let relays = UserDefaults.standard.stringArray(forKey: key) else { return nil }
let relayUrls = relays.compactMap({ RelayURL($0) })
if relayUrls.count == 0 { return nil }
return NIP65.RelayList(relays: relayUrls)
}
// MARK: - Getting metadata from the user's relay list
/// 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 }
return nil
}
// MARK: - Listening to and handling relay updates from the network
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 Task { await self.load() } }
}
func listenAndHandleRelayUpdates() async {
let filter = NostrFilter(kinds: [.relay_list], authors: [delegate.keypair.pubkey])
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) 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 await self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values)))
}
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 await self.upsert(relay: relay, force: force)
}
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 await self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values)))
}
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 }
await self.apply(newRelayList: self.computeRelaysToConnectTo(with: userRelayList))
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() 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.
///
/// - Parameters:
/// - state: The state of the app
/// - newRelayList: The new relay list to be applied
///
///
/// ## Implementation notes
///
/// - 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.
@MainActor
private func apply(newRelayList: [RelayPool.RelayDescriptor]) async {
let currentRelayList = self.pool.relays.map({ $0.descriptor })
var changed = false
let new_relay_filters = load_relay_filters(delegate.keypair.pubkey) == nil
for index in self.pool.relays.indices {
guard let newDescriptor = newRelayList.first(where: { $0.url == self.pool.relays[index].descriptor.url }) else { continue }
self.pool.relays[index].descriptor.info = newDescriptor.info
// Relay read-write configuration change does not need reconnection to the relay, so we do not set the `changed` flag.
}
// Working with URL Sets for difference analysis
let currentRelayURLs = Set(currentRelayList.map { $0.url })
let newRelayURLs = Set(newRelayList.map { $0.url })
// Analyzing which relays to add or remove
let relaysToRemove = currentRelayURLs.subtracting(newRelayURLs)
let relaysToAdd = newRelayURLs.subtracting(currentRelayURLs)
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 }
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 {
notify(.relays_changed)
}
}
}
}
// MARK: - Helper extensions
fileprivate extension NIP65.RelayList.RelayItem {
func toRelayDescriptor() -> RelayPool.RelayDescriptor {
return RelayPool.RelayDescriptor(url: self.url, info: self.rwConfiguration, variant: .regular) // NIP-65 relays are regular by definition.
}
}
fileprivate extension NIP65.RelayList {
func toRelayDescriptors() -> [RelayPool.RelayDescriptor] {
return self.relays.values.map({ $0.toRelayDescriptor() })
}
}
// MARK: - Helper functions
/// Adds a new relay, taking care of other tangential concerns, such as updating the relay model cache, configuring logging, etc
///
/// ## Implementation notes
///
/// 1. This function used to be in `HomeModel.swift` and moved here when `UserRelayListManager` was first implemented
/// 2. This is `fileprivate` because only `UserRelayListManager` should be able to manage the user's relay list and apply them to the `RelayPool`
///
/// - Parameters:
/// - model_cache: The relay model cache, that keeps metadata cached
/// - relay_filters: Relay filters
/// - pool: The relay pool to add this in
/// - 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) async {
try? await pool.add_relay(descriptor)
let url = descriptor.url
let relay_id = url
guard model_cache.model(withURL: url) == nil else {
return
}
Task.detached(priority: .background) {
guard let meta = try? await fetch_relay_metadata(relay_id: relay_id) else {
return
}
await MainActor.run {
let model = RelayModel(url, metadata: meta)
model_cache.insert(model: model)
if logging_enabled {
Task { await pool.setLog(model.log, for: relay_id) }
}
// if this is the first time adding filters, we should filter non-paid relays
if new_relay_filters && !meta.is_paid {
relay_filters.insert(timeline: .search, relay_id: relay_id)
}
}
}
}

View File

@@ -20,45 +20,6 @@ enum NoteContent {
}
}
func parsed_blocks_finish(bs: inout note_blocks, tags: TagsSequence?) -> Blocks {
var out: [Block] = []
var i = 0
while (i < bs.num_blocks) {
let block = bs.blocks[i]
if let converted = Block(block, tags: tags) {
out.append(converted)
}
i += 1
}
let words = Int(bs.words)
blocks_free(&bs)
return Blocks(words: words, blocks: out)
}
func parse_note_content(content: NoteContent) -> Blocks {
var bs = note_blocks()
bs.num_blocks = 0;
blocks_init(&bs)
switch content {
case .content(let s, let tags):
return s.withCString { cptr in
damus_parse_content(&bs, cptr)
return parsed_blocks_finish(bs: &bs, tags: tags)
}
case .note(let note):
damus_parse_content(&bs, note.content_raw)
return parsed_blocks_finish(bs: &bs, tags: note.tags)
}
}
func interpret_event_refs(tags: TagsSequence) -> ThreadReply? {
// migration is long over, lets just do this to fix tests
return interpret_event_refs_ndb(tags: tags)

View File

@@ -34,6 +34,19 @@ protocol TagConvertible {
static func from_tag(tag: TagSequence) -> Self?
}
/// Protocol for types that can be converted from/to a tag sequence with the possibilty of an error
protocol ThrowingTagConvertible {
associatedtype E: Error
var tag: [String] { get }
static func fromTag(tag: TagSequence) throws(E) -> Self?
}
/// Protocol for types that can be converted from/to a tag item
protocol TagItemConvertible {
var tagItem: String? { get }
static func fromTagItem(_ item: String?) -> Self?
}
struct QuoteId: IdType, TagKey, TagConvertible {
let id: Data
@@ -130,8 +143,16 @@ struct ReplaceableParam: TagConvertible {
var keychar: AsciiCharacter { "d" }
}
struct Signature: Hashable, Equatable {
struct Signature: Codable, Hashable, Equatable {
let data: Data
init(from decoder: Decoder) throws {
self.init(try hex_decoder(decoder, expected_len: 64))
}
func encode(to encoder: Encoder) throws {
try hex_encoder(to: encoder, data: self.data)
}
init(_ p: Data) {
self.data = p

View File

@@ -18,22 +18,42 @@ enum MentionType: AsciiCharacter, TagKey {
}
}
enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
case pubkey(Pubkey)
case note(NoteId)
case nevent(NEvent)
case nprofile(NProfile)
case nrelay(String)
case naddr(NAddr)
extension UnsafePointer<UInt8> {
func as_data(size: Int) -> Data {
return Data(bytes: self, count: size)
}
}
struct MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
let nip19: Bech32Object
static func pubkey(_ pubkey: Pubkey) -> MentionRef {
self.init(nip19: .npub(pubkey))
}
static func note(_ note_id: NoteId) -> MentionRef {
return self.init(nip19: .note(note_id))
}
init?(block: ndb_mention_bech32_block) {
guard let bech32_obj = Bech32Object.init(block: block) else {
return nil
}
self.nip19 = bech32_obj
}
init(nip19: Bech32Object) {
self.nip19 = nip19
}
var key: MentionType {
switch self {
case .pubkey: return .p
case .note: return .e
case .nevent: return .e
case .nprofile: return .p
switch self.nip19 {
case .note, .nevent: return .e
case .nprofile, .npub: return .p
case .nrelay: return .r
case .naddr: return .a
case .nscript: return .a
case .nsec: return .p
}
}
@@ -41,33 +61,64 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
return Bech32Object.encode(toBech32Object())
}
static func from_bech32(str: String) -> MentionRef? {
switch Bech32Object.parse(str) {
case .note(let noteid): return .note(noteid)
case .npub(let pubkey): return .pubkey(pubkey)
default: return nil
init?(bech32_str: String) {
guard let obj = Bech32Object.parse(bech32_str) else {
return nil
}
self.nip19 = obj
}
var pubkey: Pubkey? {
switch self {
case .pubkey(let pubkey): return pubkey
switch self.nip19 {
case .npub(let pubkey): return pubkey
case .note: return nil
case .nevent(let nevent): return nevent.author
case .nprofile(let nprofile): return nprofile.author
case .nrelay: return nil
case .naddr: return nil
case .nsec(let prv): return privkey_to_pubkey(privkey: prv)
case .nscript(_): return nil
}
}
var tag: [String] {
switch self {
case .pubkey(let pubkey): return ["p", pubkey.hex()]
switch self.nip19 {
case .npub(let pubkey): return ["p", pubkey.hex()]
case .note(let noteId): return ["e", noteId.hex()]
case .nevent(let nevent): return ["e", nevent.noteid.hex()]
case .nprofile(let nprofile): return ["p", nprofile.author.hex()]
case .nevent(let nevent):
var tagBuilder = ["e", nevent.noteid.hex()]
let relay = nevent.relays.first
if let author = nevent.author?.hex() {
tagBuilder.append(relay?.absoluteString ?? "")
tagBuilder.append(author)
} else if let relay {
tagBuilder.append(relay.absoluteString)
}
return tagBuilder
case .nprofile(let nprofile):
var tagBuilder = ["p", nprofile.author.hex()]
if let relay = nprofile.relays.first {
tagBuilder.append(relay.absoluteString)
}
return tagBuilder
case .nrelay(let url): return ["r", url]
case .naddr(let naddr): return ["a", naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier.string()]
case .naddr(let naddr):
var tagBuilder = ["a", "\(naddr.kind.description):\(naddr.author.hex()):\(naddr.identifier.string())"]
if let relay = naddr.relays.first {
tagBuilder.append(relay.absoluteString)
}
return tagBuilder
case .nsec(_):
return []
case .nscript(_):
return []
}
}
@@ -87,10 +138,10 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
switch mention_type {
case .p:
guard let data = element.id() else { return nil }
return .pubkey(Pubkey(data))
return .init(nip19: .npub(Pubkey(data)))
case .e:
guard let data = element.id() else { return nil }
return .note(NoteId(data))
return .init(nip19: .note(NoteId(data)))
case .a:
let str = element.string()
let data = str.split(separator: ":")
@@ -99,26 +150,13 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
guard let pubkey = Pubkey(hex: String(data[1])) else { return nil }
guard let kind = UInt32(data[0]) else { return nil }
return .naddr(NAddr(identifier: String(data[2]), author: pubkey, relays: [], kind: kind))
case .r: return .nrelay(element.string())
return .init(nip19: .naddr(NAddr(identifier: String(data[2]), author: pubkey, relays: [], kind: kind)))
case .r: return .init(nip19: .nrelay(element.string()))
}
}
func toBech32Object() -> Bech32Object {
switch self {
case .pubkey(let pk):
return .npub(pk)
case .note(let noteid):
return .note(noteid)
case .naddr(let naddr):
return .naddr(naddr)
case .nevent(let nevent):
return .nevent(nevent)
case .nprofile(let nprofile):
return .nprofile(nprofile)
case .nrelay(let url):
return .nrelay(url)
}
self.nip19
}
}
@@ -160,9 +198,12 @@ struct LightningInvoice<T> {
let amount: T
let string: String
let expiry: UInt64
let payment_hash: Data
let created_at: UInt64
var abbreviated: String {
return self.string.prefix(8) + "" + self.string.suffix(8)
}
var description_string: String {
switch description {
case .description(let string):
@@ -171,10 +212,21 @@ struct LightningInvoice<T> {
return ""
}
}
static func from(string: String) -> Invoice? {
// This feels a bit hacky at first, but it is actually clean
// because it reuses the same well-tested parsing logic as the rest of the app,
// avoiding code duplication and utilizing the guarantees acquired from age and testing.
// We could also use the C function `parse_invoice`, but it requires extra C bridging logic.
// NDBTODO: This may need updating on the nostrdb upgrade.
guard let parsedBlocks = parse_note_content(content: .content(string,nil))?.blocks else { return nil }
guard parsedBlocks.count == 1 else { return nil }
return parsedBlocks[0].asInvoice
}
}
func maybe_pointee<T>(_ p: UnsafeMutablePointer<T>!) -> T? {
guard p != nil else {
func maybe_pointee<T>(_ p: UnsafeMutablePointer<T>?) -> T? {
guard let p else {
return nil
}
return p.pointee
@@ -192,6 +244,13 @@ enum Amount: Equatable {
return format_msats(amt)
}
}
func amount_sats() -> Int64? {
switch self {
case .any: nil
case .specific(let amount): amount / 1000
}
}
}
func format_msats_abbrev(_ msats: Int64) -> String {
@@ -235,7 +294,7 @@ 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: bolt11) -> InvoiceDescription? {
func convert_invoice_description(b11: ndb_invoice) -> InvoiceDescription? {
if let desc = b11.description {
return .description(String(cString: desc))
}
@@ -260,3 +319,38 @@ func find_tag_ref(type: String, id: String, tags: [[String]]) -> Int? {
return nil
}
struct PostTags {
let blocks: [Block]
let tags: [[String]]
}
/// Convert
func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
var new_tags = tags
for post_block in post_blocks {
switch post_block {
case .mention(let mention):
switch(mention.ref.nip19) {
case .note, .nevent:
continue
default:
break
}
new_tags.append(mention.ref.tag)
case .hashtag(let hashtag):
new_tags.append(["t", hashtag.lowercased()])
case .text: break
case .invoice: break
case .relay: break
case .url(let url):
new_tags.append(["r", url.absoluteString])
break
}
}
return PostTags(blocks: post_blocks, tags: new_tags)
}

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))
}

View File

@@ -7,7 +7,7 @@
import Foundation
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayPool.RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
var tags = zap_target_to_tags(target)
var relay_tag = ["relays"]
relay_tag.append(contentsOf: relays.map { $0.url.absoluteString })
@@ -68,7 +68,7 @@ func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair,
guard let note = NostrEvent(content: message, keypair: identity.to_keypair(), kind: 9733, tags: tags),
let note_json = encode_json(note),
let enc = encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32)
let enc = NIP04.encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32)
else {
return nil
}
@@ -78,8 +78,8 @@ func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair,
func make_first_contact_event(keypair: Keypair) -> NostrEvent? {
let bootstrap_relays = load_bootstrap_relays(pubkey: keypair.pubkey)
let rw_relay_info = RelayInfo(read: true, write: true)
var relays: [RelayURL: RelayInfo] = [:]
let rw_relay_info = LegacyKind3RelayRWConfiguration(read: true, write: true)
var relays: [RelayURL: LegacyKind3RelayRWConfiguration] = [:]
for relay in bootstrap_relays {
relays[relay] = rw_relay_info

View File

@@ -13,6 +13,18 @@ import CryptoKit
import NaturalLanguage
/// A protocol for structs and classes that can convert themselves from/to a NostrEvent
protocol NostrEventConvertible {
associatedtype E: Error
/// Iniitialize this type from a NostrEvent
init(event: NostrEvent) throws(E)
/// Convert this type into a Nostr Event, using a keypair for signing and a specific timestamp
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32?) -> NostrEvent?
}
enum ValidationResult: Decodable {
case unknown
case ok
@@ -309,7 +321,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
@@ -322,6 +334,27 @@ func decode_nostr_event(txt: String) -> NostrResponse? {
return NostrResponse.owned_from_json(json: txt)
}
func decode_and_verify_nostr_response(txt: String) -> NostrResponse? {
guard let response = NostrResponse.owned_from_json(json: txt) else { return nil }
guard verify_nostr_response(response: response) == true else { return nil }
return response
}
func verify_nostr_response(response: borrowing NostrResponse) -> Bool {
switch response {
case .event(_, let event):
return event.verify()
case .notice(_):
return true
case .eose(_):
return true
case .ok(_):
return true
case .auth(_):
return true
}
}
func encode_json<T: Encodable>(_ val: T) -> String? {
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes
@@ -367,6 +400,10 @@ func decode_json<T: Decodable>(_ val: String) -> T? {
return try? JSONDecoder().decode(T.self, from: Data(val.utf8))
}
func decode_json_throwing<T: Decodable>(_ val: String) throws -> T {
return try JSONDecoder().decode(T.self, from: Data(val.utf8))
}
func decode_data<T: Decodable>(_ data: Data) -> T? {
let decoder = JSONDecoder()
do {
@@ -432,17 +469,26 @@ func random_bytes(count: Int) -> Data {
return Data(bytes: bytes, count: count)
}
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent) -> NostrEvent? {
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent, relayURL: RelayURL?) -> NostrEvent? {
var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag })
tags.append(["e", boosted.id.hex(), "", "root"])
tags.append(["p", boosted.pubkey.hex()])
var eTagBuilder = ["e", boosted.id.hex()]
var pTagBuilder = ["p", boosted.pubkey.hex()]
let relayURLString = relayURL?.absoluteString
if let relayURLString {
pTagBuilder.append(relayURLString)
}
eTagBuilder.append(contentsOf: [relayURLString ?? "", "root", boosted.pubkey.hex()])
tags.append(eTagBuilder)
tags.append(pTagBuilder)
let content = event_to_json(ev: boosted)
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 6, tags: tags)
}
func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙") -> NostrEvent? {
func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙", relayURL: RelayURL?) -> NostrEvent? {
var tags = liked.tags.reduce(into: [[String]]()) { ts, tag in
guard tag.count >= 2,
(tag[0].matches_char("e") || tag[0].matches_char("p")) else {
@@ -451,12 +497,30 @@ func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String =
ts.append(tag.strings())
}
tags.append(["e", liked.id.hex()])
tags.append(["p", liked.pubkey.hex()])
var eTagBuilder = ["e", liked.id.hex()]
var pTagBuilder = ["p", liked.pubkey.hex()]
let relayURLString = relayURL?.absoluteString
if let relayURLString {
pTagBuilder.append(relayURLString)
}
eTagBuilder.append(contentsOf: [relayURLString ?? "", liked.pubkey.hex()])
tags.append(eTagBuilder)
tags.append(pTagBuilder)
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 {
@@ -484,6 +548,15 @@ func uniq<T: Hashable>(_ xs: [T]) -> [T] {
return ys
}
func gather_quote_ids(our_pubkey: Pubkey, from: NostrEvent) -> [RefId] {
var ids: [RefId] = [.quote(from.id.quote_id)]
if from.pubkey != our_pubkey {
ids.append(.pubkey(from.pubkey))
}
return ids
}
func gather_reply_ids(our_pubkey: Pubkey, from: NostrEvent) -> [RefId] {
var ids: [RefId] = from.referenced_ids.first.map({ ref in [ .event(ref) ] }) ?? []
@@ -504,14 +577,6 @@ func gather_reply_ids(our_pubkey: Pubkey, from: NostrEvent) -> [RefId] {
return ids
}
func gather_quote_ids(our_pubkey: Pubkey, from: NostrEvent) -> [RefId] {
var ids: [RefId] = [.quote(from.id.quote_id)]
if from.pubkey != our_pubkey {
ids.append(.pubkey(from.pubkey))
}
return ids
}
func event_from_json(dat: String) -> NostrEvent? {
return NostrEvent.owned_from_json(json: dat)
}
@@ -527,6 +592,7 @@ func event_to_json(ev: NostrEvent) -> String {
return str
}
@available(*, deprecated, renamed: "NIP04.decryptContent", message: "Deprecated, please use NIP04.decryptContent instead")
func decrypt_dm(_ privkey: Privkey?, pubkey: Pubkey, content: String, encoding: EncEncoding) -> String? {
guard let privkey = privkey else {
return nil
@@ -729,57 +795,56 @@ 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(ev: NostrEvent, keypair: Keypair) -> Mention<NoteId>? {
let blocks = ev.blocks(keypair).blocks.filter { block in
guard case .mention(let mention) = block else {
return false
func first_eref_mention(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> Mention<NoteId>? {
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
}
switch mention.ref {
case .note, .nevent:
return true
default:
return false
}
}
/// MARK: - Preview
if let firstBlock = blocks.first,
case .mention(let mention) = firstBlock {
switch mention.ref {
case .note(let note_id):
return .note(note_id)
case .nevent(let nevent):
return .note(nevent.noteid)
default:
return nil
}
}
return nil
})
})
}
func separate_invoices(ev: NostrEvent, keypair: Keypair) -> [Invoice]? {
let invoiceBlocks: [Invoice] = ev.blocks(keypair).blocks.reduce(into: []) { invoices, block in
guard case .invoice(let invoice) = block else {
return
}
invoices.append(invoice)
}
return invoiceBlocks.isEmpty ? nil : invoiceBlocks
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):
if let invoice = invoice.as_invoice() {
return .loopReturn(invoices + [invoice])
}
default:
break
}
return .loopContinue
})) ?? []
return invoiceBlocks.isEmpty ? nil : invoiceBlocks
})
}
/**
@@ -815,4 +880,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
}

View File

@@ -8,6 +8,7 @@
import Foundation
/// A known Nostr event kind, addressable by name, with the actual number assigned by the protocol as the value
enum NostrKind: UInt32, Codable {
case metadata = 0
case text = 1
@@ -17,7 +18,10 @@ enum NostrKind: UInt32, Codable {
case boost = 6
case like = 7
case chat = 42
case live_chat = 1311
case mute_list = 10000
case relay_list = 10002
case interest_list = 10015
case list_deprecated = 30000
case draft = 31234
case longform = 30023
@@ -27,5 +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
}

View File

@@ -12,6 +12,7 @@ enum NostrLink: Equatable {
case ref(RefId)
case filter(NostrFilter)
case script([UInt8])
case invoice(String)
}
func encode_pubkey_uri(_ pubkey: Pubkey) -> String {
@@ -93,8 +94,15 @@ func decode_nostr_uri(_ s: String) -> NostrLink? {
return
}
if parts.count >= 2 && parts[0] == "t" {
return .filter(NostrFilter(hashtag: [parts[1].lowercased()]))
if parts.count >= 2 {
switch parts[0] {
case "t":
return .filter(NostrFilter(hashtag: [parts[1].lowercased()]))
case "lightning":
return .invoice(parts[1])
default:
break
}
}
guard parts.count == 1 else {

View File

@@ -12,11 +12,14 @@ struct NostrSubscribe {
let sub_id: String
}
/// Models a request/message that is sent to a Nostr relay
enum NostrRequestType {
/// A standard nostr request
case typical(NostrRequest)
/// A customized nostr request. Generally used in the context of a nostrscript.
case custom(String)
/// Whether this request is meant to write data to a relay
var is_write: Bool {
guard case .typical(let req) = self else {
return true
@@ -25,6 +28,7 @@ enum NostrRequestType {
return req.is_write
}
/// Whether this request is meant to read data from a relay
var is_read: Bool {
guard case .typical(let req) = self else {
return true
@@ -34,12 +38,18 @@ enum NostrRequestType {
}
}
/// Models a standard request/message that is sent to a Nostr relay.
enum NostrRequest {
/// Subscribes to receive information from the relay
case subscribe(NostrSubscribe)
/// Unsubscribes from an existing subscription, addressed by its id
case unsubscribe(String)
/// Posts an event
case event(NostrEvent)
/// Authenticate with the relay
case auth(NostrEvent)
/// Whether this request is meant to write data to a relay
var is_write: Bool {
switch self {
case .subscribe:
@@ -53,6 +63,7 @@ enum NostrRequest {
}
}
/// Whether this request is meant to read data from a relay
var is_read: Bool {
return !is_write
}

View File

@@ -45,7 +45,7 @@ enum NostrResponse {
static func owned_from_json(json: String) -> NostrResponse? {
return json.withCString{ cstr in
let bufsize: Int = max(Int(Double(json.utf8.count) * 4.0), Int(getpagesize()))
let bufsize: Int = max(Int(Double(json.utf8.count) * 8.0), Int(getpagesize()))
let data = malloc(bufsize)
if data == nil {
@@ -89,7 +89,7 @@ enum NostrResponse {
free(data)
return nil
}
let new_note = note_data.assumingMemoryBound(to: ndb_note.self)
let new_note = ndb_note_ptr(ptr: OpaquePointer(note_data))
let note = NdbNote(note: new_note, size: Int(len), owned: true, key: nil)
guard let subid = sized_cstr(cstr: tce.subid, len: tce.subid_len) else {

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()
}
}

View File

@@ -35,6 +35,7 @@ class Profiles {
@MainActor
private var profiles: [Pubkey: ProfileData] = [:]
// Map of validated NIP-05 address to pubkey.
@MainActor
var nip05_pubkey: [String: Pubkey] = [:]
@@ -73,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
}

View File

@@ -115,6 +115,19 @@ enum FollowRef: TagKeys, Hashable, TagConvertible, Equatable {
}
}
/// Models common tag references defined by the Nostr protocol, and their associated values.
///
/// For example, this raw JSON tag sequence:
/// ```json
/// ["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"]
/// ```
///
/// would be parsed into something equivalent to `.pubkey(Pubkey(hex: "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"))`
///
/// ## Notes
///
/// - Not all tag information from all NIPs can be modelled using this alone, as some NIPs may define extra associated values for specific event types. You may need to use a specialized type for some event kinds
///
enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
case event(NoteId)
case pubkey(Pubkey)
@@ -124,6 +137,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
case naddr(NAddr)
case reference(String)
/// The key that defines the type of reference being made
var key: RefKey {
switch self {
case .event: return .e
@@ -136,6 +150,14 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
}
}
/// Defines the type of reference being made on a Nostr event tag
///
/// Example:
/// ```json
/// ["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"]
/// ```
///
/// The `RefKey` is "p"
enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible {
case e, p, t, d, q, a, r
@@ -148,10 +170,12 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
}
}
/// A raw nostr-style tag sequence representation of this object
var tag: [String] {
[self.key.description, self.description]
}
/// Describes what is being referenced, as a `String`
var description: String {
switch self {
case .event(let noteId): return noteId.hex()
@@ -166,6 +190,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
}
}
/// Parses a raw tag sequence
static func from_tag(tag: TagSequence) -> RefId? {
var i = tag.makeIterator()

View File

@@ -0,0 +1,196 @@
//
// Relay.swift
// damus
//
// Created by William Casarin on 2022-04-11.
//
import Foundation
public struct LegacyKind3RelayRWConfiguration: Codable, Sendable {
public let read: Bool?
public let write: Bool?
init(read: Bool, write: Bool) {
self.read = read
self.write = write
}
static let rw = LegacyKind3RelayRWConfiguration(read: true, write: true)
func toNIP65RWConfiguration() -> NIP65.RelayList.RelayItem.RWConfiguration? {
switch (self.read, self.write) {
case (false, true): return .write
case (true, false): return .read
case (true, true): return .readWrite
default: return nil
}
}
}
enum RelayVariant {
case regular
case ephemeral
case nwc
}
extension RelayPool {
/// Describes a relay for use in `RelayPool`
public struct RelayDescriptor {
let url: RelayURL
var info: NIP65.RelayList.RelayItem.RWConfiguration
let variant: RelayVariant
init(url: RelayURL, info: NIP65.RelayList.RelayItem.RWConfiguration, variant: RelayVariant = .regular) {
self.url = url
self.info = info
self.variant = variant
}
var ephemeral: Bool {
switch variant {
case .regular:
return false
case .ephemeral:
return true
case .nwc:
return true
}
}
static func nwc(url: RelayURL) -> RelayDescriptor {
return RelayDescriptor(url: url, info: .readWrite, variant: .nwc)
}
}
}
enum RelayFlags: Int {
case none = 0
case broken = 1
}
enum RelayAuthenticationError {
/// Only a public key was provided in keypair to sign challenge.
///
/// A private key is required to sign `auth` challenge.
case no_private_key
/// No keypair was provided to sign challenge.
case no_key
}
enum RelayAuthenticationState: Equatable {
/// No `auth` request has been made from this relay
case none
/// We have received an `auth` challenge, but have not yet replied to the challenge
case pending
/// We have received an `auth` challenge and replied with an `auth` event
case verified
/// We received an `auth` challenge but failed to reply to the challenge
case error(RelayAuthenticationError)
}
struct Limitations: Codable {
let payment_required: Bool?
static var empty: Limitations {
Limitations(payment_required: nil)
}
}
struct Admission: Codable {
let amount: Int64
let unit: String
}
struct Subscription: Codable {
let amount: Int64
let unit: String
let period: Int
}
struct Publication: Codable {
let kinds: [Int]
let amount: Int64
let unit: String
}
struct Fees: Codable {
let admission: [Admission]?
let subscription: [Subscription]?
let publication: [Publication]?
static var empty: Fees {
Fees(admission: nil, subscription: nil, publication: nil)
}
}
struct RelayMetadata: Codable {
let name: String?
let description: String?
let pubkey: Pubkey?
let contact: String?
let supported_nips: [Int]?
let software: String?
let version: String?
let limitation: Limitations?
let payments_url: String?
let icon: String?
let fees: Fees?
var is_paid: Bool {
return limitation?.payment_required ?? false
}
}
extension RelayPool {
class Relay: Identifiable {
var descriptor: RelayDescriptor
let connection: RelayConnection
var authentication_state: RelayAuthenticationState
var flags: Int
init(descriptor: RelayDescriptor, connection: RelayConnection) {
self.flags = 0
self.descriptor = descriptor
self.connection = connection
self.authentication_state = RelayAuthenticationState.none
}
var is_broken: Bool {
return (flags & RelayFlags.broken.rawValue) == RelayFlags.broken.rawValue
}
var id: RelayURL {
return descriptor.url
}
}
}
extension RelayPool {
enum RelayError: Error {
case RelayAlreadyExists
}
}
// MARK: - Extension to bridge NIP-65 relay list structs with app-native objects
extension NIP65.RelayList {
static func fromLegacyContactList(_ contactList: NdbNote) throws(BridgeError) -> Self {
guard let relayListInfo = decode_json_relays(contactList.content) else { throw .couldNotDecodeRelayListInfo }
let relayItems = relayListInfo.map({ url, rwConfiguration in
return RelayItem(url: url, rwConfiguration: rwConfiguration.toNIP65RWConfiguration() ?? .readWrite)
})
return NIP65.RelayList(relays: relayItems)
}
static func fromLegacyContactList(_ contactList: NdbNote?) throws(BridgeError) -> Self? {
guard let contactList = contactList else { return nil }
return try fromLegacyContactList(contactList)
}
enum BridgeError: Error {
case couldNotDecodeRelayListInfo
}
}

View File

@@ -9,8 +9,41 @@ import Combine
import Foundation
enum NostrConnectionEvent {
case ws_event(WebSocketEvent)
/// Other non-message websocket events
case ws_connection_event(WSConnectionEvent)
/// A nostr response
case nostr_event(NostrResponse)
/// Models non-messaging websocket events
///
/// Implementation note: Messaging events should use `.nostr_event` in `NostrConnectionEvent`
enum WSConnectionEvent {
case connected
case disconnected(URLSessionWebSocketTask.CloseCode, String?)
case error(Error)
static func from(full_ws_event: WebSocketEvent) -> Self? {
switch full_ws_event {
case .connected:
return .connected
case .message(_):
return nil
case .disconnected(let closeCode, let string):
return .disconnected(closeCode, string)
case .error(let error):
return .error(error)
}
}
}
var subId: String? {
switch self {
case .ws_connection_event(_):
return nil
case .nostr_event(let event):
return event.subid
}
}
}
final class RelayConnection: ObservableObject {
@@ -24,18 +57,18 @@ 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?
init(url: RelayURL,
handleEvent: @escaping (NostrConnectionEvent) -> (),
processEvent: @escaping (WebSocketEvent) -> ())
handleEvent: @escaping (NostrConnectionEvent) async -> (),
processUnverifiedWSEvent: @escaping (WebSocketEvent) -> ())
{
self.relay_url = url
self.handleEvent = handleEvent
self.processEvent = processEvent
self.processEvent = processUnverifiedWSEvent
}
func ping() {
@@ -71,12 +104,12 @@ final class RelayConnection: ObservableObject {
.sink { [weak self] completion in
switch completion {
case .failure(let error):
self?.receive(event: .error(error))
Task { await self?.receive(event: .error(error)) }
case .finished:
self?.receive(event: .disconnected(.normalClosure, nil))
Task { await self?.receive(event: .disconnected(.normalClosure, nil)) }
}
} receiveValue: { [weak self] event in
self?.receive(event: event)
Task { await self?.receive(event: event) }
}
socket.connect()
@@ -114,7 +147,8 @@ 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 {
case .connected:
@@ -124,7 +158,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))
@@ -151,9 +185,8 @@ final class RelayConnection: ObservableObject {
self.reconnect_with_backoff()
}
}
DispatchQueue.main.async {
self.handleEvent(.ws_event(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)
@@ -187,19 +220,19 @@ final class RelayConnection: ObservableObject {
}
}
private func receive(message: URLSessionWebSocketTask.Message) {
private func receive(message: URLSessionWebSocketTask.Message) async {
switch message {
case .string(let messageString):
if let ev = decode_nostr_event(txt: messageString) {
DispatchQueue.main.async {
self.handleEvent(.nostr_event(ev))
}
// 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) {
await self.handleEvent(.nostr_event(ev))
return
}
print("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.")

View File

@@ -0,0 +1,560 @@
//
// RelayPool.swift
// damus
//
// Created by William Casarin on 2022-04-11.
//
import Foundation
import Network
struct RelayHandler {
let sub_id: String
/// 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 {
let req: NostrRequestType
let relay: RelayURL
let skip_ephemeral: Bool
}
struct SeenEvent: Hashable {
let relay_id: RelayURL
let evid: NoteId
}
/// Establishes and manages connections and subscriptions to a list of relays.
actor 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?
/// 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()
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() async {
await disconnect()
await clearRelays()
open = false
handlers = []
request_queue = []
await clearSeen()
counts = [:]
keypair = nil
}
@MainActor
private func clearRelays() {
relays = []
}
private func clearSeen() {
seen.removeAll()
}
init(ndb: Ndb?, keypair: Keypair? = nil) {
self.ndb = ndb
self.keypair = keypair
network_monitor.pathUpdateHandler = { [weak self] path in
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 {
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() 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, 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))
}
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)
}
@MainActor
func remove_relay(_ relay_id: RelayURL) async {
var i: Int = 0
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) async throws(RelayError) {
let relay_id = desc.url
if await get_relay(relay_id) != nil {
throw RelayError.RelayAlreadyExists
}
let conn = RelayConnection(url: desc.url, handleEvent: { event in
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?.processEvent(str, originRelayURL: relay_id)
self.message_received_function?((str, desc))
})
let relay = Relay(descriptor: desc, connection: conn)
await self.appendRelayToList(relay: relay)
}
@MainActor
private func appendRelayToList(relay: Relay) {
self.relays.append(relay)
}
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)")
await get_relay(relay_id)?.connection.log = log
}
/// This is used to retry dead connections
func connect_to_disconnected() async {
for relay in await relays {
let c = relay.connection
let is_connecting = c.isConnecting
if is_connecting && (Date.now.timeIntervalSince1970 - c.last_connection_attempt) > 5 {
print("stale connection detected (\(relay.descriptor.url.absoluteString)). retrying...")
relay.connection.reconnect()
} else if relay.is_broken || is_connecting || c.isConnected {
continue
} else {
relay.connection.reconnect()
}
}
}
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 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
open = true
}
func disconnect(to targetRelays: [RelayURL]? = nil) async {
// Mark as closed first, to prevent other classes from pulling data while the relays are being disconnected
open = false
let relays = await getRelays(targetRelays: targetRelays)
for relay in relays {
relay.connection.disconnect()
}
}
@MainActor
func getRelays(targetRelays: [RelayURL]? = nil) -> [Relay] {
targetRelays.map{ get_relays($0) } ?? self.relays
}
/// 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
}
}
}
func unsubscribe(sub_id: String, to: [RelayURL]? = nil) async {
if to == nil {
self.remove_handler(sub_id: sub_id)
}
await self.send(.unsubscribe(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.
///
/// - 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
/// - Returns: Returns an async stream that callers can easily consume via a for-loop
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)
let startTime = CFAbsoluteTimeGetCurrent()
return AsyncStream<StreamItem> { continuation in
let id = id ?? UUID()
let sub_id = id.uuidString
var seenEvents: Set<NoteId> = []
var relaysWhoFinishedInitialResults: Set<RelayURL> = []
var eoseSent = false
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
}
}
}
}
let timeoutTask = Task {
try? await Task.sleep(for: eoseTimeout)
if !eoseSent { continuation.yield(with: .success(.eose)) }
}
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()
}
}
}
enum StreamItem {
/// A Nostr event
case event(NostrEvent)
/// The "end of stored events" signal
case eose
}
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 {
var c = 0
for request in request_queue {
if request.relay == relay {
c += 1
}
}
return c
}
func queue_req(r: NostrRequestType, relay: RelayURL, skip_ephemeral: Bool) {
let count = count_queued(relay: relay)
guard count <= 10 else {
print("can't queue, too many queued events for \(relay)")
return
}
print("queueing request for \(relay)")
request_queue.append(QueuedRequest(req: r, relay: relay, skip_ephemeral: skip_ephemeral))
}
func send_raw_to_local_ndb(_ req: NostrRequestType) {
// send to local relay (nostrdb)
switch req {
case .typical(let r):
if case .event = r, let rstr = make_nostr_req(r) {
let _ = ndb?.process_client_event(rstr)
}
case .custom(let string):
let _ = ndb?.process_client_event(string)
}
}
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
for relay in relays {
if req.is_read && !(relay.descriptor.info.canRead) {
continue // Do not send read requests to relays that are not READ relays
}
if req.is_write && !(relay.descriptor.info.canWrite) {
continue // Do not send write requests to relays that are not WRITE relays
}
if relay.descriptor.ephemeral && skip_ephemeral {
continue // Do not send requests to ephemeral relays if we want to skip them
}
guard relay.connection.isConnected else {
Task { await queue_req(r: req, relay: relay.id, skip_ephemeral: skip_ephemeral) }
continue
}
relay.connection.send(req, callback: { str in
self.message_sent_function?((str, relay))
})
}
}
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 })
}
func run_queue(_ relay_id: RelayURL) {
self.request_queue = request_queue.reduce(into: Array<QueuedRequest>()) { (q, req) in
guard req.relay == relay_id else {
q.append(req)
return
}
print("running queueing request: \(req.req) for \(relay_id)")
Task { await self.send_raw(req.req, to: [relay_id], skip_ephemeral: false) }
}
}
func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) {
if case .nostr_event(let ev) = event {
if case .event(_, let nev) = ev {
if seen[nev.id]?.contains(relay_id) == true {
return
}
seen[nev.id, default: Set()].insert(relay_id)
counts[relay_id, default: 0] += 1
notify(.update_stats(note_id: nev.id))
}
}
}
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) async {
record_seen(relay_id: relay_id, event: event)
// 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 = 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) {
await send(.auth(authRequest), to: [relay_id], skip_ephemeral: false)
relay.authentication_state = .verified
} else {
print("failed to make auth request")
}
} else {
print("keypair provided did not contain private key, can not sign auth request")
relay.authentication_state = .error(.no_private_key)
}
} else {
print("no keypair to reply to auth request")
relay.authentication_state = .error(.no_key)
}
} else {
print("no relay found for \(relay_id)")
}
}
for handler in handlers {
// 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))
}
}
}
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)
}
}

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 {

View File

@@ -9,13 +9,13 @@ import Foundation
import LinkPresentation
import EmojiPicker
class DamusState: HeadlessDamusState {
let pool: RelayPool
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
@@ -28,8 +28,6 @@ class DamusState: HeadlessDamusState {
let drafts: Drafts
let events: EventCache
let bookmarks: BookmarksManager
let postbox: PostBox
let bootstrap_relays: [RelayURL]
let replies: ReplyCounter
let wallet: WalletModel
let nav: NavigationCoordinator
@@ -39,13 +37,16 @@ class DamusState: HeadlessDamusState {
var purple: DamusPurple
var push_notification_client: PushNotificationClient
let emoji_provider: EmojiProvider
let favicon_cache: FaviconCache
private(set) var nostrNetwork: NostrNetworkManager
var snapshotManager: DatabaseSnapshotManager
init(pool: RelayPool, 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, postbox: PostBox, bootstrap_relays: [RelayURL], replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
self.pool = pool
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
@@ -58,8 +59,6 @@ class DamusState: HeadlessDamusState {
self.drafts = drafts
self.events = events
self.bookmarks = bookmarks
self.postbox = postbox
self.bootstrap_relays = bootstrap_relays
self.replies = replies
self.wallet = wallet
self.nav = nav
@@ -73,12 +72,19 @@ class DamusState: HeadlessDamusState {
self.quote_reposts = quote_reposts
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
self.emoji_provider = emoji_provider
self.favicon_cache = FaviconCache()
let networkManagerDelegate = NostrNetworkManagerDelegate(settings: settings, contacts: contacts, ndb: ndb, keypair: keypair, relayModelCache: relay_model_cache, relayFilters: relay_filters)
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")
@@ -98,31 +104,18 @@ class DamusState: HeadlessDamusState {
guard let ndb = mndb else { return nil }
let pubkey = keypair.pubkey
let pool = RelayPool(ndb: ndb, keypair: keypair)
let model_cache = RelayModelCache()
let relay_filters = RelayFilters(our_pubkey: pubkey)
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
let new_relay_filters = load_relay_filters(pubkey) == nil
for relay in bootstrap_relays {
let descriptor = RelayDescriptor(url: relay, info: .rw)
add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode)
}
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
if let nwc_str = settings.nostr_wallet_connect,
let nwc = WalletConnectURL(str: nwc_str) {
try? pool.add_relay(.nwc(url: nwc.relay))
}
self.init(
pool: pool,
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,
@@ -135,16 +128,15 @@ class DamusState: HeadlessDamusState {
drafts: Drafts(),
events: EventCache(ndb: ndb),
bookmarks: BookmarksManager(pubkey: pubkey),
postbox: PostBox(pool: pool),
bootstrap_relays: bootstrap_relays,
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(),
ndb: ndb,
quote_reposts: .init(our_pubkey: pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: FaviconCache()
)
}
@@ -179,21 +171,24 @@ class DamusState: HeadlessDamusState {
try await self.push_notification_client.revoke_token()
}
wallet.disconnect()
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
let kp = Keypair(pubkey: empty_pub, privkey: nil)
return DamusState.init(
pool: RelayPool(ndb: .empty),
keypair: Keypair(pubkey: empty_pub, privkey: empty_sec),
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),
@@ -206,8 +201,6 @@ class DamusState: HeadlessDamusState {
drafts: Drafts(),
events: EventCache(ndb: .empty),
bookmarks: BookmarksManager(pubkey: empty_pub),
postbox: PostBox(pool: RelayPool(ndb: .empty)),
bootstrap_relays: [],
replies: ReplyCounter(our_pubkey: empty_pub),
wallet: WalletModel(settings: UserSettingsStore()),
nav: NavigationCoordinator(),
@@ -215,7 +208,36 @@ class DamusState: HeadlessDamusState {
video: DamusVideoCoordinator(),
ndb: .empty,
quote_reposts: .init(our_pubkey: empty_pub),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: FaviconCache()
)
}
}
fileprivate extension DamusState {
struct NostrNetworkManagerDelegate: NostrNetworkManager.Delegate {
let settings: UserSettingsStore
let contacts: Contacts
var ndb: Ndb
var keypair: Keypair
var latestRelayListEventIdHex: String? {
get { self.settings.latestRelayListEventIdHex }
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
var nwcWallet: WalletConnectURL? {
guard let nwcString = self.settings.nostr_wallet_connect else { return nil }
return WalletConnectURL(str: nwcString)
}
}
}

View File

@@ -0,0 +1,195 @@
//
// 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.
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 copyDatabase(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
}
/// Copy the database using LMDB's native copy function.
private func copyDatabase(to snapshotPath: String) async throws {
return try await withCheckedThrowingContinuation { continuation in
let fileManager = FileManager.default
// Delete existing database files at the destination if they exist
// LMDB creates multiple files (data.mdb, lock.mdb), so we remove the entire directory
if fileManager.fileExists(atPath: snapshotPath) {
do {
try fileManager.removeItem(atPath: snapshotPath)
Log.debug("Removed existing snapshot at %{public}@", for: .storage, snapshotPath)
} catch {
continuation.resume(throwing: SnapshotError.removeFailed(error))
return
}
}
Log.debug("Recreate the snapshot directory", for: .storage, snapshotPath)
// Recreate the snapshot directory
do {
try fileManager.createDirectory(atPath: snapshotPath, withIntermediateDirectories: true)
} catch {
continuation.resume(throwing: SnapshotError.directoryCreationFailed(error))
return
}
do {
try ndb.snapshot(path: snapshotPath)
continuation.resume(returning: ())
}
catch {
continuation.resume(throwing: SnapshotError.copyFailed(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)
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)"
}
}
}

View File

@@ -2,22 +2,11 @@
// Block.swift
// damus
//
// Created by Kyle Roucis on 2023-08-21.
//
import Foundation
fileprivate extension String {
/// Failable initializer to build a Swift.String from a C-backed `str_block_t`.
init?(_ s: str_block_t) {
let len = s.end - s.start
let bytes = Data(bytes: s.start, count: len)
self.init(bytes: bytes, encoding: .utf8)
}
}
/// Represents a block of data stored by the NOSTR protocol. This can be
/// Represents a block of data stored in nostrdb. This can be
/// simple text, a hashtag, a url, a relay reference, a mention ref and
/// potentially more in the future.
enum Block: Equatable {
@@ -37,7 +26,7 @@ enum Block: Equatable {
return false
}
}
case text(String)
case mention(Mention<MentionRef>)
case hashtag(String)
@@ -51,61 +40,56 @@ struct Blocks: Equatable {
let blocks: [Block]
}
extension ndb_str_block {
func as_str() -> String {
let buf = UnsafeBufferPointer(start: self.str, count: Int(self.len))
let uint8Buf = buf.map { UInt8(bitPattern: $0) }
return String(decoding: uint8Buf, as: UTF8.self)
}
}
extension ndb_block_ptr {
func as_str() -> String {
guard let str_block = ndb_block_str(self.ptr) else {
return ""
}
return str_block.pointee.as_str()
}
var block: ndb_block.__Unnamed_union_block {
self.ptr.pointee.block
}
}
extension Block {
/// Failable initializer for the C-backed type `block_t`. This initializer will inspect
/// the underlying block type and build the appropriate enum value as needed.
init?(_ block: block_t, tags: TagsSequence? = nil) {
switch block.type {
init?(block: ndb_block_ptr, tags: TagsSequence?) {
switch ndb_get_block_type(block.ptr) {
case BLOCK_HASHTAG:
guard let str = String(block.block.str) else {
return nil
}
self = .hashtag(str)
self = .hashtag(block.as_str())
case BLOCK_TEXT:
guard let str = String(block.block.str) else {
return nil
}
self = .text(str)
self = .text(block.as_str())
case BLOCK_MENTION_INDEX:
guard let b = Block(index: Int(block.block.mention_index), tags: tags) else {
return nil
}
self = b
case BLOCK_URL:
guard let b = Block(block.block.str) else {
return nil
}
self = b
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
}
guard let b = Block(invoice: block.block.invoice) else { return nil }
self = b
case BLOCK_MENTION_BECH32:
guard let b = Block(bech32: block.block.mention_bech32) else {
return nil
}
guard let b = Block(bech32: block.block.mention_bech32) else { return nil }
self = b
default:
return nil
}
}
}
fileprivate extension Block {
/// Failable initializer for the C-backed type `str_block_t`.
init?(_ b: str_block_t) {
guard let str = String(b) else {
return nil
}
if let url = URL(string: str) {
self = .url(url)
}
else {
self = .text(str)
}
}
}
fileprivate extension Block {
/// Failable initializer for a block index and a tag sequence.
init?(index: Int, tags: TagsSequence? = nil) {
@@ -127,34 +111,34 @@ fileprivate extension Block {
}
}
}
fileprivate extension Block {
/// Failable initializer for the C-backed type `invoice_block_t`.
init?(invoice: invoice_block_t) {
guard let invstr = String(invoice.invstr) else {
return nil
}
guard var b11 = maybe_pointee(invoice.bolt11) else {
return nil
}
guard let description = convert_invoice_description(b11: b11) else {
return nil
}
let amount: Amount = maybe_pointee(b11.msat).map { .specific(Int64($0.millisatoshis)) } ?? .any
let payment_hash = Data(bytes: &b11.payment_hash, count: 32)
let created_at = b11.timestamp
tal_free(invoice.bolt11)
self = .invoice(Invoice(description: description, amount: amount, string: invstr, expiry: b11.expiry, payment_hash: payment_hash, created_at: created_at))
init?(invoice: ndb_invoice_block) {
guard let invoice = invoice_block_as_invoice(invoice) else { return nil }
self = .invoice(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 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 {
/// Failable initializer for the C-backed type `mention_bech32_block_t`. This initializer will inspect the
/// bech32 type code and build the appropriate enum type.
init?(bech32 b: mention_bech32_block_t) {
init?(bech32 b: ndb_mention_bech32_block) {
guard let decoded = decodeCBech32(b.bech32) else {
return nil
}
@@ -164,6 +148,7 @@ fileprivate extension Block {
self = .mention(.any(ref))
}
}
extension Block {
var asString: String {
switch self {
@@ -186,3 +171,13 @@ extension Block {
}
}
}
extension Block {
var asInvoice: Invoice? {
switch self {
case .invoice(let invoice):
return invoice
default:
return nil
}
}
}

View File

@@ -51,4 +51,15 @@ struct NoteId: IdType, TagKey, TagConvertible {
return note_id
}
func withUnsafePointer<T>(_ body: (UnsafePointer<UInt8>) throws -> T) rethrows -> T {
return try self.id.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in
guard let baseAddress = bytes.baseAddress else {
fatalError("Cannot get base address")
}
return try baseAddress.withMemoryRebound(to: UInt8.self, capacity: bytes.count) { ptr in
return try body(ptr)
}
}
}
}

View File

@@ -44,5 +44,14 @@ struct Pubkey: IdType, TagKey, TagConvertible, Identifiable {
return pubkey
}
func withUnsafePointer<T>(_ body: (UnsafePointer<UInt8>) throws -> T) rethrows -> T {
return try self.id.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in
guard let baseAddress = bytes.baseAddress else {
fatalError("Cannot get base address")
}
return try baseAddress.withMemoryRebound(to: UInt8.self, capacity: bytes.count) { ptr in
return try body(ptr)
}
}
}
}

View File

@@ -25,12 +25,13 @@ class ActionBarModel: ObservableObject {
@Published private(set) var zaps: Int
@Published var zap_total: Int64
@Published var replies: Int
@Published var relays: Int
static func empty() -> ActionBarModel {
return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil)
}
init(likes: Int = 0, boosts: Int = 0, zaps: Int = 0, zap_total: Int64 = 0, replies: Int = 0, our_like: NostrEvent? = nil, our_boost: NostrEvent? = nil, our_zap: Zapping? = nil, our_reply: NostrEvent? = nil, our_quote_repost: NostrEvent? = nil, quote_reposts: Int = 0) {
init(likes: Int = 0, boosts: Int = 0, zaps: Int = 0, zap_total: Int64 = 0, replies: Int = 0, our_like: NostrEvent? = nil, our_boost: NostrEvent? = nil, our_zap: Zapping? = nil, our_reply: NostrEvent? = nil, our_quote_repost: NostrEvent? = nil, quote_reposts: Int = 0, relays: Int = 0) {
self.likes = likes
self.boosts = boosts
self.zaps = zaps
@@ -42,9 +43,11 @@ class ActionBarModel: ObservableObject {
self.our_reply = our_reply
self.our_quote_repost = our_quote_repost
self.quote_reposts = quote_reposts
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
@@ -56,11 +59,12 @@ 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 = (await damus.nostrNetwork.relayURLsThatSawNote(id: evid) ?? []).count
self.objectWillChange.send()
}
var is_empty: Bool {
return likes == 0 && boosts == 0 && zaps == 0
return likes == 0 && boosts == 0 && zaps == 0 && quote_reposts == 0 && relays == 0
}
var liked: Bool {

View File

@@ -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
}
}
}
}
}
@@ -217,11 +234,31 @@ struct EventActionBar: View {
AnyView(self.action_bar_content)
}
}
@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 }
}
return userProfile.getCappedRelays()
}
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, *) {
@@ -233,7 +270,9 @@ struct EventActionBar: View {
}
}
.sheet(isPresented: $show_share_sheet, onDismiss: { self.show_share_sheet = false }) {
ShareSheet(activityItems: [URL(string: "https://damus.io/" + event.id.bech32)!])
if let url = URL(string: "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))) {
ShareSheet(activityItems: [url])
}
}
.sheet(isPresented: $show_repost_action, onDismiss: { self.show_repost_action = false }) {
@@ -247,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 {
@@ -260,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) else {
let like_ev = await make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
return
}
@@ -270,7 +312,7 @@ struct EventActionBar: View {
generator.impactOccurred()
damus_state.postbox.send(like_ev)
await damus_state.nostrNetwork.postbox.send(like_ev)
}
// MARK: Helper structures

View File

@@ -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
@@ -59,7 +60,28 @@ struct EventDetailBar: View {
}
.buttonStyle(PlainButtonStyle())
}
if bar.relays > 0 {
NavigationLink(value: Route.UserRelays(relays: relays)) {
let nounString = pluralizedString(key: "relays_count", count: bar.relays)
let noun = Text(nounString).foregroundColor(.gray)
Text("\(Text(verbatim: bar.relays.formatted()).font(.body.bold())) \(noun)", comment: "Sentence composed of 2 variables to describe how many relays a note was found on. In source English, the first variable is the number of relays, and the second variable is 'Relay' or 'Relays'.")
}
.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
}
}

View File

@@ -26,7 +26,23 @@ struct ShareAction: View {
self.userProfile = userProfile
self._show_share = show_share
}
@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 }
}
return userProfile.getCappedRelays()
}
var body: some View {
VStack {
@@ -40,7 +56,7 @@ struct ShareAction: View {
ShareActionButton(img: "link", text: NSLocalizedString("Copy Link", comment: "Button to copy link to note")) {
dismiss()
UIPasteboard.general.string = "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(noteid: event.id, relays: userProfile.getCappedRelayStrings())))
UIPasteboard.general.string = "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(noteid: event.id, relays: userProfile.getCappedRelays())))
}
let bookmarkImg = isBookmarked ? "bookmark.fill" : "bookmark"
@@ -71,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()

View File

@@ -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: {
@@ -132,9 +132,9 @@ struct ReportView_Previews: PreviewProvider {
let ds = test_damus_state
VStack {
ReportView(postbox: ds.postbox, target: ReportTarget.user(test_pubkey), keypair: test_keypair.to_full()!)
ReportView(postbox: ds.nostrNetwork.postbox, target: ReportTarget.user(test_pubkey), keypair: test_keypair.to_full()!)
ReportView(postbox: ds.postbox, target: ReportTarget.user(test_pubkey), keypair: test_keypair.to_full()!, report_sent: true, report_id: "report_id")
ReportView(postbox: ds.nostrNetwork.postbox, target: ReportTarget.user(test_pubkey), keypair: test_keypair.to_full()!, report_sent: true, report_id: "report_id")
}
}

View File

@@ -0,0 +1,42 @@
//
// QuoteRepostsView.swift
// damus
//
// Created by William Casarin on 2024-03-16.
//
import SwiftUI
struct QuoteRepostsView: View {
let damus_state: DamusState
@ObservedObject var model: EventsModel
var body: some View {
TimelineView(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true, filter: ContentFilters.default_filters(damus_state: damus_state).filter(ev:)) {
ZStack(alignment: .leading) {
DamusBackground(maxHeight: 250)
.mask(LinearGradient(gradient: Gradient(colors: [.black, .black, .black, .clear]), startPoint: .top, endPoint: .bottom))
Text("Quotes", comment: "Navigation bar title for Quote Reposts view.")
.foregroundStyle(DamusLogoGradient.gradient)
.font(.title.bold())
.padding(.leading, 30)
.padding(.top, 30)
}
}
.ignoresSafeArea()
.padding(.bottom, tabHeight)
.onAppear {
model.subscribe()
}
.onDisappear {
model.unsubscribe()
}
}
}
struct QuoteRepostsView_Previews: PreviewProvider {
static var previews: some View {
let state = test_damus_state
QuoteRepostsView(damus_state: state, model: .reposts(state: state, target: test_note.id))
}
}

View File

@@ -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) 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.postbox.send(boost)
} label: {
Label(NSLocalizedString("Repost", comment: "Button to repost a note"), image: "repost")
.frame(maxWidth: .infinity, minHeight: 50, maxHeight: 50, alignment: .leading)

View File

@@ -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)
}

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