Compare commits
216 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
31e281ce73
|
|||
|
963da2d4eb
|
|||
| af3bb212c3 | |||
| f3361e6eae | |||
| 7f2c575f20 | |||
| ec28822451 | |||
| 795fce1b65 | |||
| 65e767b774 | |||
| af9956de8a | |||
| 7be75f37c6 | |||
| 84ef5ecf53 | |||
| f440f37cbf | |||
| b59816e180 | |||
| 609cdcc5f9 | |||
| 59498e3256 | |||
| 546e9eec32 | |||
| cfafcffde2 | |||
| 4099827169 | |||
| 32c0177049 | |||
| 2e1a98ff19 | |||
| 7fa044d205 | |||
| be6b0e2702 | |||
| 2d5460b654 | |||
| e271fa90d9 | |||
| 8c5027248b | |||
| 434c54f98e | |||
| 9a1ae6f9b5 | |||
| 6f8e2d3064 | |||
| 2c3fba5f90 | |||
| 1505a8f2e4 | |||
| 845089bed1 | |||
| c88d881801 | |||
| fa4b7a7518 | |||
| 438d537ff6 | |||
| 4eac3c576f | |||
| c22c819bc0 | |||
| b39996a6a7 | |||
| 96fb909d83 | |||
| ce461b58e6 | |||
| 89a56eebcd | |||
| d8f4dbb2aa | |||
| 95d38fa802 | |||
| ac05b83772 | |||
| ed9971f84f | |||
| 650d4af504 | |||
| 114dde7883 | |||
| b105dadd14 | |||
| 078042546b | |||
| 93834f8de2 | |||
| 760d0a8126 | |||
| c934bc7653 | |||
| 527b53a7c8 | |||
| ef262b3c22 | |||
| 28a2c23a76 | |||
| e8e2653316 | |||
| 0233f2ae48 | |||
| 767b318763 | |||
| 4f401c6ce9 | |||
| a4ad4960c4 | |||
| 4941b502d5 | |||
| f506f9cfe8 | |||
| 81251ee88a | |||
| cddee92f3a | |||
| 67e61417d9 | |||
| be7a23bea8 | |||
| f7fcb2cb91 | |||
| d27d4e65cb | |||
| 71c36052e2 | |||
| 368f94a209 | |||
| 0cbeaf8ea8 | |||
| 20dc672dbf | |||
| 6d9107f662 | |||
| a0cecdc8ad | |||
| 5058fb33d7 | |||
| 48143f859a | |||
| d3a54458f5 | |||
| 9eda7e5886 | |||
| 674d4683c3 | |||
| f5e5da25eb | |||
| 5066a39ffb | |||
| f1b81a3e5c | |||
| f844ed9931 | |||
| b562b930cc | |||
| 2f7a40bd50 | |||
| 498af9bc3a | |||
| 066b5ff379 | |||
| b8de67dcae | |||
| 44dfda8d33 | |||
| 7eafe973d9 | |||
| 44071e9d75 | |||
| 52115d07c2 | |||
| d651084465 | |||
| 8c4783c622 | |||
| 48d3049f3f | |||
| 529bb0dca0 | |||
| 0879fa39dc | |||
| b8c664d354 | |||
| a31f6bce0e | |||
| 58f4988237 | |||
| bd1eae5f26 | |||
| 5380918b15 | |||
| 1015b1cb08 | |||
| 9ca6b5e9ab | |||
| 56a7d1ed78 | |||
| 01150155ab | |||
| a8202d89f8 | |||
| d4402b0afc | |||
| 036afbf5b8 | |||
| 7ba2ec6713 | |||
| 36b40f53af | |||
| 58e6a49bcf | |||
| 7cf9a07099 | |||
| 7afcaa99fe | |||
| 10b4d804f8 | |||
| e3d27ae472 | |||
| 02296d7752 | |||
| 9dfd338077 | |||
| fe09f9da99 | |||
| 67d2b249b6 | |||
| 9555145359 | |||
| 8122a8a580 | |||
| 690f8b891e | |||
| 91426a79b9 | |||
| 61f695b7c6 | |||
| 6605c5e583 | |||
| ab2c16288b | |||
| 991a4a86e6 | |||
| 7c1594107f | |||
| 05c02f7dc4 | |||
| 70d0d9dacf | |||
| c80d4f146c | |||
| 9311a767c8 | |||
| 588ef46402 | |||
| 4f479d0280 | |||
| 7691b48fb6 | |||
| 01ec05ab32 | |||
| 61eb833239 | |||
| d9306d4153 | |||
| 3437cf5347 | |||
| 667a228e1a | |||
| 84c4594d30 | |||
| 32e8c1b6e1 | |||
| 1b5f107ac6 | |||
| fe62aea08a | |||
| 258d08723f | |||
| 9153a912b0 | |||
| fe491bf694 | |||
| e55675a336 | |||
| eda4212aa7 | |||
| 798f9ec7b4 | |||
| a09e22df24 | |||
| a3ef36120e | |||
| de528f3f70 | |||
| 8164eee479 | |||
| 0582892cae | |||
| 2185984ed7 | |||
| 1caad24364 | |||
| ecbfb3714b | |||
| d565eb20f7 | |||
| a040a0244b | |||
| 387af198d6 | |||
| 66e10db6b2 | |||
| 42a0f2c08d | |||
| aa8ce31941 | |||
| 8014d772ba | |||
| 4d8313c788 | |||
| 342067640f | |||
| 84839d1c43 | |||
| b5079c42d5 | |||
| 0847c53a39 | |||
| fa2d240ddf | |||
| 3a37a6c18e | |||
| 5c75e87ed5 | |||
| 64c16e7cc8 | |||
| 0b8090cb28 | |||
| 9cff8608f6 | |||
| c728210be8 | |||
| 0f66e87faf | |||
| af2298dcb7 | |||
| a0b85129d4 | |||
| e42b14cc6f | |||
| f0521ba406 | |||
| c29027ff5b | |||
| c6674199de | |||
| 5961bf7958 | |||
| a877a19c25 | |||
| 684701931d | |||
| fcd8131063 | |||
| 3290e1f9d2 | |||
| 2bea2faf3f | |||
| 9bcee298d4 | |||
| 7eb759a8a0 | |||
| 2550d613b2 | |||
| 9fb7ed741e | |||
| d766029f2b | |||
| 4478672c10 | |||
| c43a37d2d3 | |||
| ab22206093 | |||
| de70d19135 | |||
| 0f26d50e08 | |||
| 9709e69dda | |||
| 809c8c80ac | |||
| c4c3656f90 | |||
| 46c3667ec3 | |||
| 739a3a0b8c | |||
| ab6ea7a9c1 | |||
| 9620dcf6ef | |||
| a5aff15491 | |||
| 76b6d5c545 | |||
| 940b83f5c4 | |||
| e113dee95e | |||
| abd797b7b3 | |||
| 8083269709 | |||
| 5f3ce30826 | |||
| 578d47356d | |||
| f2870b9a38 |
@@ -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.]_
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agents
|
||||
|
||||
## Damus Overview
|
||||
|
||||
Damus is an iOS client built around a local relay model ([damus-io/damus#3204](https://github.com/damus-io/damus/pull/3204)) to keep interactions snappy and resilient. The app operates on `nostrdb` ([source](https://github.com/damus-io/damus/tree/master/nostrdb)), and agents working on Damus should maximize usage of `nostrdb` facilities whenever possible.
|
||||
|
||||
## Codebase Layout
|
||||
|
||||
- `damus/` contains the SwiftUI app. Key subdirectories: `Core` (protocol, storage, networking, nostr primitives), `Features` (feature-specific flows like Timeline, Wallet, Purple), `Shared` (reusable UI components and utilities), `Models`, and localized resources (`*.lproj`, `en-US.xcloc`).
|
||||
- `nostrdb/` hosts the embedded database. Swift bindings (`Ndb.swift`, iterators) wrap a C/LMDB core; prefer these abstractions when working with persistence or queries.
|
||||
- `damus-c/` bridges C helpers (e.g., WASM runner) into Swift; check `damus-Bridging-Header.h` before adding new bridges.
|
||||
- `nostrscript/` contains AssemblyScript sources compiled to WASM via the top-level `Makefile`.
|
||||
- Tests live in `damusTests/` (unit/snapshot coverage) and `damusUITests/` (UI smoke tests). Keep them running before submitting changes.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- Use `just build` / `just test` for simulator builds and the primary test suite (requires `xcbeautify`). Update or add `just` recipes if new repeatable workflows emerge.
|
||||
- Xcode project is `damus.xcodeproj`; the main scheme is `damus`. Ensure new targets or resources integrate cleanly with this scheme.
|
||||
- Rebuild WASM helpers with `make` when touching `nostrscript/` sources.
|
||||
- Follow `docs/DEV_TIPS.md` for debugging (enabling Info logging, staging push notification settings) and keep tips updated when discovering new workflows.
|
||||
|
||||
## Testing Expectations
|
||||
|
||||
- Provide a concrete test report in each PR (see `.github/pull_request_template.md`). Document devices, OS versions, and scenarios exercised.
|
||||
- Add or update unit tests in `damusTests/` alongside feature changes, especially when touching parsing, storage, or replay logic.
|
||||
- UI regressions should include `damusUITests/` coverage or rationale when automation is impractical.
|
||||
- Snapshot fixtures under `damusTests/__Snapshots__` must be regenerated deliberately; explain updates in commit messages.
|
||||
|
||||
## Contribution Standards
|
||||
|
||||
- Sign all commits (`git commit -s`) and include appropriate `Changelog-*`, `Closes:`, or `Fixes:` tags as described in `docs/CONTRIBUTING.md`.
|
||||
- Keep patches scoped: one logical change per commit, ensuring the app builds and runs after each step.
|
||||
- Favor Swift-first solutions that lean on `nostrdb` types (`Ndb`, `NdbNote`, iterators) before introducing new storage mechanisms.
|
||||
- Update documentation when workflows change, especially this file, `README.md`, or developer notes.
|
||||
|
||||
## Agent Requirements
|
||||
|
||||
1. Code should tend toward simplicity.
|
||||
2. Commits should be logically distinct.
|
||||
3. Commits should be standalone.
|
||||
4. Code should be human readable.
|
||||
5. Code should be human reviewable.
|
||||
6. Ensure docstring coverage for any code added, or modified.
|
||||
7. Review and follow `pull_request_template.md` when creating PRs for iOS Damus.
|
||||
8. Ensure nevernesting: favor early returns and guard clauses over deeply nested conditionals; simplify control flow by exiting early instead of wrapping logic in multiple layers of `if` statements.
|
||||
9. Before proposing changes, please **review and analyze if a change or upgrade to nostrdb** is beneficial to the change at hand.
|
||||
10. **Never block the main thread**: All network requests, database queries, and expensive computations must run on background threads/queues. Use `Task { }`, `DispatchQueue.global()`, or Swift concurrency (`async/await`) appropriately. UI updates must dispatch back to `@MainActor`. Test for hangs and freezes before submitting.
|
||||
+179
@@ -1,3 +1,182 @@
|
||||
## [1.16.1] - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- Added a view for quotes notes that could not be loaded, including actionable items (Daniel D’Aquino)
|
||||
- Added relay hint support for nevent, nprofile, naddr links and event tag references (reposts, quotes, replies) (alltheseas)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue where notes would keep loading indefinitely in some cases (Daniel D’Aquino)
|
||||
- Fixed Lightning invoice parsing and fetching for all amounts (alltheseas)
|
||||
|
||||
|
||||
|
||||
[1.16.1]: https://github.com/damus-io/damus/releases/tag/v1.16.1
|
||||
|
||||
|
||||
## [1.16] - 2026-01-28
|
||||
|
||||
### Added
|
||||
|
||||
- Added live stream timeline (ericholguin)
|
||||
- Added live chat timeline (ericholguin)
|
||||
- Added ability to create live chat event (ericholguin)
|
||||
- Damus Labs Toggle (ericholguin)
|
||||
- Added Damus Labs (ericholguin)
|
||||
- Add Timeline switcher button for NIP-81-favorites (Askia Linder)
|
||||
- Added the ability to load saved notes if device is offline (Daniel D’Aquino)
|
||||
- Notes now load offline (Daniel D’Aquino)
|
||||
- Added support for scanning nprofile QR codes (Terry Yiu)
|
||||
- Add nip50 search filters and queries (William Casarin)
|
||||
- Add ndb_filter_init_with (William Casarin)
|
||||
- Add ndb_filter_is_subset_of (William Casarin)
|
||||
- Add ndb_filter_eq for filter equality testing (William Casarin)
|
||||
- Add method for parsing filter json (William Casarin)
|
||||
- Add ndb_filter_json method for creating json filters (William Casarin)
|
||||
- Add ndb_unsubscribe to unsubscribe from subscriptions (William Casarin)
|
||||
- Add general created_at query plan for timelines (William Casarin)
|
||||
- Add ndb_poll_for_notes (William Casarin)
|
||||
- Added filter subscriptions (William Casarin)
|
||||
- Add initial rust library (William Casarin)
|
||||
- Added relay count and relay view to events (Terry Yiu)
|
||||
- Add relay hints to tags and identifiers (Terry Yiu)
|
||||
- Added focus mode with auto-hide navigation for longform reading (alltheseas)
|
||||
- Added sepia mode and line height settings for longform articles (alltheseas)
|
||||
- Added estimated read time to longform preview (alltheseas)
|
||||
- Added reading progress bar for longform articles (alltheseas)
|
||||
- Added automatic conversion of pasted npub/nprofile to human-readable mentions in post composer (alltheseas)
|
||||
- Added hashtag spam filter setting to hide posts with too many hashtags (alltheseas)
|
||||
- Profile metadata preloading for improved timeline performance (Daniel D’Aquino)
|
||||
- Added a pull to refresh feature on DMs that allows users to resync DMs with their relays (Daniel D’Aquino)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved performance around note content views to prevent hangs (Daniel D’Aquino)
|
||||
- Highlight note search results (alltheseas)
|
||||
- Improved draft saving feature to prevent data loss if app closes too quickly (Daniel D’Aquino)
|
||||
- Changed Damus Purple Side View logo and text (ericholguin)
|
||||
- Placed the Favorites feature behind a feature flag (Daniel D’Aquino)
|
||||
- Tweaked since optimization filter to capture notes that would otherwise be lost (Daniel D’Aquino)
|
||||
- Optimized network bandwidth usage and improved timeline performance (Daniel D’Aquino)
|
||||
- Increased transaction list limit to 50 transactions (Daniel D’Aquino)
|
||||
- Improved loading UX in the home timeline (Daniel D’Aquino)
|
||||
- Added UX hint to make it easier to load new notes (Daniel D’Aquino)
|
||||
- Switched to the local relay model (Daniel D’Aquino)
|
||||
- Reduced default zap amount and deduplicated from preset zap amount items (Terry Yiu)
|
||||
- Use NostrDB for rendering note contents (Daniel D’Aquino)
|
||||
- Changed abbreviated pubkey format to npub1...xyz for better readability (alltheseas)
|
||||
- Changed focus mode to only hide navigation on scroll down (alltheseas)
|
||||
- Removed card styling from longform preview in full article view (alltheseas)
|
||||
- Improved storage efficiency for NostrDB on extensions (Daniel D’Aquino)
|
||||
- Changed load media UI (ericholguin)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed broken automatic translations (alltheseas)
|
||||
- Fixed an issue where notifications view would occasionally appear blank when the app started. (alltheseas)
|
||||
- Fixed incorrect behaviour on the post editor that would cause the text cursor to occasionally jump beyond the correct location in some editing operations. (alltheseas)
|
||||
- Fixed several crashes throughout the app (Daniel D’Aquino)
|
||||
- Fixed an issue where an empty dot would appear on some thread chat views (alltheseas)
|
||||
- Ensure mention profile prefetch covers mention_index blocks (alltheseas)
|
||||
- Fixed an issue where the mute list view may occasionally freeze the app (Daniel D’Aquino)
|
||||
- Fix mention pills falling back to @npub text when profile metadata is missing (alltheseas)
|
||||
- Fixed an occasional random crash related to viewing profiles (Daniel D’Aquino)
|
||||
- Improved robustness in the part of the code that streams notes from nostrdb (Daniel D’Aquino)
|
||||
- Added performance improvements to timeline scrolling (Daniel D’Aquino)
|
||||
- Improved security around note validation (Daniel D’Aquino)
|
||||
- Fixed an issue where the app would crash when swapping between apps (Daniel D’Aquino)
|
||||
- Fixed memory error in nostrdb (Daniel D’Aquino)
|
||||
- Fixed bug where non-bech32 damus io urls would cause corruption (William Casarin)
|
||||
- Fix aspect ratio on pasted or uploaded images (askeew)
|
||||
- Fixed note content rendering to not remove whitespace before hashtag (Terry Yiu)
|
||||
- Fixed background crashes with error code 0xdead10cc (Daniel D’Aquino)
|
||||
- Fixed crashes that happened when the app went into background mode (Daniel D’Aquino)
|
||||
- Added more guards to prevent accidental overrides of the user's mutelist (alltheseas)
|
||||
- Fixed instances where a profile would not display profile name and picture for a few seconds (alltheseas)
|
||||
- Longform article links now open correctly when shared as nevent URLs (alltheseas)
|
||||
- Longform articles now open at the top instead of midway through (alltheseas)
|
||||
- Fixed tab bar staying hidden when switching from longform to non-longform event (alltheseas)
|
||||
- Fixed stretched/cut-off images in longform notes (alltheseas)
|
||||
- Fixed mentions unlinking when typing text before them (alltheseas)
|
||||
- Fixed cursor jumping behind first letter when typing a new note (alltheseas)
|
||||
- Fixed an issue that would occasionally cause the app to freeze (Daniel D’Aquino)
|
||||
- Fix issue where your own replies were sometimes not trusted (alltheseas)
|
||||
- Fix issue where search results were out of order (alltheseas)
|
||||
- Fixed repost notifications not appearing in notifications tab (alltheseas)
|
||||
- Fixed a crash that occurred when clicking "follow all" during onboarding. (Daniel D’Aquino)
|
||||
|
||||
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed "Load new content" button (Daniel D’Aquino)
|
||||
- Wallet view no longer hangs on loading placeholder (Daniel D’Aquino)
|
||||
- Fixed issue where the app would occasionally launch an empty universe view (Daniel D’Aquino)
|
||||
- Profile action sheet buttons now center properly when fewer than 5 buttons are displayed (Daniel D’Aquino)
|
||||
- Fixed an issue where DMs may not appear for users with a large contact list (Daniel D’Aquino)
|
||||
- Fixed an issue that could cause certain networking operations to hang indefinitely (Daniel D’Aquino)
|
||||
- Fixed a race condition in the networking logic that could cause notes to get missed in certain rare scenarios (Daniel D’Aquino)
|
||||
- Fixed a crash on iOS 17 that would happen on startup (Daniel D’Aquino)
|
||||
|
||||
|
||||
[1.16]: https://github.com/damus-io/damus/releases/tag/v1.16
|
||||
|
||||
|
||||
## [1.15] - 2025-07-11
|
||||
|
||||
**Note:** This version was only released on TestFlight, and never officially released on the App Store.
|
||||
|
||||
### Added
|
||||
|
||||
- Added new onboarding suggestions based on user-selected interests (Daniel D’Aquino)
|
||||
- Added adjustable max budget setting for Coinos one-click wallets (Daniel D’Aquino)
|
||||
- Added send feature to the wallet view (Daniel D’Aquino)
|
||||
- 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 D’Aquino)
|
||||
- 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 D’Aquino)
|
||||
- Fixed issue where the text "??" would appear on the balance while loading (Daniel D’Aquino)
|
||||
- Hide end previewables when hashtags are present (Terry Yiu)
|
||||
- Fixed wallet transactions to always show profile display name unless there is no pubkey (Terry Yiu)
|
||||
- Fixed quotes view header alignment (Terry Yiu)
|
||||
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed hashtags in Universe View (ericholguin)
|
||||
|
||||
|
||||
[1.15]: https://github.com/damus-io/damus/releases/tag/v1.15
|
||||
|
||||
|
||||
## [1.14] - 2025-05-25
|
||||
|
||||
### Added
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct NotificationExtensionState: HeadlessDamusState {
|
||||
let ndb: Ndb
|
||||
let settings: UserSettingsStore
|
||||
|
||||
@@ -125,8 +125,7 @@ struct NotificationFormatter {
|
||||
let src = zap.request.ev
|
||||
let pk = zap.is_anon ? ANON_PUBKEY : src.pubkey
|
||||
|
||||
let profile_txn = profiles.lookup(id: pk)
|
||||
let profile = profile_txn?.unsafeUnownedValue
|
||||
let profile = try? profiles.lookup(id: pk)
|
||||
let name = Profile.displayName(profile: profile, pubkey: pk).displayName.truncate(maxLength: 50)
|
||||
|
||||
let sats = NSNumber(value: (Double(zap.invoice.amount) / 1000.0))
|
||||
|
||||
@@ -44,63 +44,61 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
// Log that we got a push notification
|
||||
Log.debug("Got nostr event push notification from pubkey %s", for: .push_notifications, nostr_event.pubkey.hex())
|
||||
|
||||
guard let state = NotificationExtensionState() else {
|
||||
Log.debug("Failed to open nostrdb", for: .push_notifications)
|
||||
Task {
|
||||
guard let state = await NotificationExtensionState() else {
|
||||
Log.debug("Failed to open nostrdb", for: .push_notifications)
|
||||
|
||||
// Something failed to initialize so let's go for the next best thing
|
||||
guard let improved_content = NotificationFormatter.shared.format_message(event: nostr_event) else {
|
||||
// We cannot format this nostr event. Suppress notification.
|
||||
contentHandler(UNNotificationContent())
|
||||
// Something failed to initialize so let's go for the next best thing
|
||||
guard let improved_content = NotificationFormatter.shared.format_message(event: nostr_event) else {
|
||||
// We cannot format this nostr event. Suppress notification.
|
||||
contentHandler(UNNotificationContent())
|
||||
return
|
||||
}
|
||||
contentHandler(improved_content)
|
||||
return
|
||||
}
|
||||
contentHandler(improved_content)
|
||||
return
|
||||
}
|
||||
|
||||
let sender_profile = {
|
||||
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
|
||||
let profile = txn?.unsafeUnownedValue?.profile
|
||||
let picture = ((profile?.picture.map { URL(string: $0) }) ?? URL(string: robohash(nostr_event.pubkey)))!
|
||||
return ProfileBuf(picture: picture,
|
||||
name: profile?.name,
|
||||
display_name: profile?.display_name,
|
||||
nip05: profile?.nip05)
|
||||
}()
|
||||
let sender_pubkey = nostr_event.pubkey
|
||||
let sender_profile = {
|
||||
let profile = try? state.profiles.lookup(id: nostr_event.pubkey)
|
||||
let picture = ((profile?.picture.map { URL(string: $0) }) ?? URL(string: robohash(nostr_event.pubkey)))!
|
||||
return ProfileBuf(picture: picture,
|
||||
name: profile?.name,
|
||||
display_name: profile?.display_name,
|
||||
nip05: profile?.nip05)
|
||||
}()
|
||||
let sender_pubkey = nostr_event.pubkey
|
||||
|
||||
// Don't show notification details that match mute list.
|
||||
// TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
|
||||
if state.mutelist_manager.is_event_muted(nostr_event) {
|
||||
// We cannot really suppress muted notifications until we have the notification supression entitlement.
|
||||
// The best we can do if we ever get those muted notifications (which we generally won't due to server-side processing) is to obscure the details
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = NSLocalizedString("Muted event", comment: "Title for a push notification which has been muted")
|
||||
content.body = NSLocalizedString("This is an event that has been muted according to your mute list rules. We cannot suppress this notification, but we obscured the details to respect your preferences", comment: "Description for a push notification which has been muted, and explanation that we cannot suppress it")
|
||||
content.sound = UNNotificationSound.default
|
||||
contentHandler(content)
|
||||
return
|
||||
}
|
||||
// Don't show notification details that match mute list.
|
||||
// TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
|
||||
if await state.mutelist_manager.is_event_muted(nostr_event) {
|
||||
// We cannot really suppress muted notifications until we have the notification supression entitlement.
|
||||
// The best we can do if we ever get those muted notifications (which we generally won't due to server-side processing) is to obscure the details
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = NSLocalizedString("Muted event", comment: "Title for a push notification which has been muted")
|
||||
content.body = NSLocalizedString("This is an event that has been muted according to your mute list rules. We cannot suppress this notification, but we obscured the details to respect your preferences", comment: "Description for a push notification which has been muted, and explanation that we cannot suppress it")
|
||||
content.sound = UNNotificationSound.default
|
||||
contentHandler(content)
|
||||
return
|
||||
}
|
||||
|
||||
guard should_display_notification(state: state, event: nostr_event, mode: .push) else {
|
||||
Log.debug("should_display_notification failed", for: .push_notifications)
|
||||
// We should not display notification for this event. Suppress notification.
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
guard await should_display_notification(state: state, event: nostr_event, mode: .push) else {
|
||||
Log.debug("should_display_notification failed", for: .push_notifications)
|
||||
// We should not display notification for this event. Suppress notification.
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
guard let notification_object = generate_local_notification_object(ndb: state.ndb, from: nostr_event, state: state) else {
|
||||
Log.debug("generate_local_notification_object failed", for: .push_notifications)
|
||||
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Task {
|
||||
guard let notification_object = generate_local_notification_object(ndb: state.ndb, from: nostr_event, state: state) else {
|
||||
Log.debug("generate_local_notification_object failed", for: .push_notifications)
|
||||
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
let sender_dn = DisplayName(name: sender_profile.name, display_name: sender_profile.display_name, pubkey: sender_pubkey)
|
||||
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: sender_dn.displayName, notify: notification_object, state: state) else {
|
||||
|
||||
@@ -186,8 +184,13 @@ func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: Str
|
||||
|
||||
// gather recipients
|
||||
if let recipient_note_id = note.direct_replies() {
|
||||
let replying_to = ndb.lookup_note(recipient_note_id)
|
||||
if let replying_to_pk = replying_to?.unsafeUnownedValue?.pubkey {
|
||||
let replying_to_pk = try? ndb.lookup_note(recipient_note_id, borrow: { replying_to_note -> Pubkey? in
|
||||
switch replying_to_note {
|
||||
case .none: return nil
|
||||
case .some(let note): return note.pubkey
|
||||
}
|
||||
})
|
||||
if let replying_to_pk {
|
||||
meta.isReplyToCurrentUser = replying_to_pk == our_pubkey
|
||||
|
||||
if replying_to_pk != sender_pk {
|
||||
@@ -247,8 +250,12 @@ func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: Str
|
||||
}
|
||||
|
||||
func pubkey_to_inperson(ndb: Ndb, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson {
|
||||
let profile_txn = ndb.lookup_profile(pubkey)
|
||||
let profile = profile_txn?.unsafeUnownedValue?.profile
|
||||
let profile = try? ndb.lookup_profile(pubkey, borrow: { profileRecord in
|
||||
switch profileRecord {
|
||||
case .some(let pr): return pr.profile
|
||||
case .none: return nil
|
||||
}
|
||||
})
|
||||
let name = profile?.name
|
||||
let display_name = profile?.display_name
|
||||
let nip05 = profile?.nip05
|
||||
|
||||
@@ -154,7 +154,7 @@ We have a few mailing lists that anyone can join to get involved in damus develo
|
||||
|
||||
### Contributing
|
||||
|
||||
See [docs/CONTRIBUTING.md](./docs/CONTRIBUTING.md)
|
||||
Before starting to work on any contributions, please read [docs/CONTRIBUTING.md](./docs/CONTRIBUTING.md).
|
||||
|
||||
### Privacy
|
||||
Your internet protocol (IP) address is exposed to the relays you connect to, and third party media hosters (e.g. nostr.build, imgur.com, giphy.com, youtube.com etc.) that render on Damus. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
|
||||
|
||||
+656
-39
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "8d71e78d1d7bdc5a85a38932a14f84af755a9e34aeab19f9d540bd11a7b32fbc",
|
||||
"originHash" : "c718c1e7dcc1a07671694b2d7d7311e11804fbbaf22f4b81e49523a3df816ad6",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "codescanner",
|
||||
@@ -17,15 +17,6 @@
|
||||
"revision" : "e74bbbfbef939224b242ae7c342a90e60b88b5ce"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dswaveformimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/dmrschmidt/DSWaveformImage",
|
||||
"state" : {
|
||||
"revision" : "4c56578ee10128ee2b2c04c9c5aa73812de722db",
|
||||
"version" : "14.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "emojikit",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -71,6 +62,24 @@
|
||||
"version" : "8.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "negentropy-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/damus-io/negentropy-swift",
|
||||
"state" : {
|
||||
"revision" : "181789fb0842f5666020db87ffea0d120cc5aa5d",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "nostr-sdk-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/rust-nostr/nostr-sdk-swift",
|
||||
"state" : {
|
||||
"revision" : "42fe7d379b326583ae8282a5fd7232745f195906",
|
||||
"version" : "0.44.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "secp256k1.swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
enableAddressSanitizer = "YES"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
|
||||
@@ -25,6 +25,23 @@ enum AppAccessibilityIdentifiers: String {
|
||||
case sign_in_confirm_button
|
||||
|
||||
|
||||
// MARK: Sign Up / Create Account
|
||||
// Prefix: `sign_up`
|
||||
|
||||
/// Button to navigate to create account view
|
||||
case sign_up_option_button
|
||||
/// Text field for entering name during account creation
|
||||
case sign_up_name_field
|
||||
/// Text field for entering bio during account creation
|
||||
case sign_up_bio_field
|
||||
/// Button to proceed to the next step after entering profile info
|
||||
case sign_up_next_button
|
||||
/// Button to save keys after account creation
|
||||
case sign_up_save_keys_button
|
||||
/// Button to skip saving keys
|
||||
case sign_up_skip_save_keys_button
|
||||
|
||||
|
||||
// MARK: Onboarding
|
||||
// Prefix: `onboarding`
|
||||
|
||||
@@ -43,9 +60,22 @@ enum AppAccessibilityIdentifiers: String {
|
||||
|
||||
// MARK: Post composer
|
||||
// Prefix: `post_composer`
|
||||
|
||||
|
||||
/// The cancel post button
|
||||
case post_composer_cancel_button
|
||||
|
||||
/// The text view where the user types their note
|
||||
case post_composer_text_view
|
||||
|
||||
/// A user result in the mention autocomplete list
|
||||
case post_composer_mention_user_result
|
||||
|
||||
|
||||
// MARK: Post button (FAB)
|
||||
// Prefix: `post_button`
|
||||
|
||||
/// The floating action button to create a new post
|
||||
case post_button
|
||||
|
||||
// MARK: Main interface layout
|
||||
// Prefix: `main`
|
||||
@@ -60,6 +90,12 @@ enum AppAccessibilityIdentifiers: String {
|
||||
/// The profile option in the side menu
|
||||
case side_menu_profile_button
|
||||
|
||||
/// The logout button in the side menu
|
||||
case side_menu_logout_button
|
||||
|
||||
/// The logout confirmation button in the alert dialog
|
||||
case side_menu_logout_confirm_button
|
||||
|
||||
|
||||
// MARK: Items specific to the user's own profile
|
||||
// Prefix: `own_profile`
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "damooseLabs.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
+206
-298
@@ -135,6 +135,7 @@ struct ContentView: View {
|
||||
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
|
||||
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
|
||||
let sub_id = UUID().description
|
||||
@State var damusClosingTask: Task<Void, Never>? = nil
|
||||
|
||||
// connect retry timer
|
||||
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
@@ -173,13 +174,13 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
case .home:
|
||||
PostingTimelineView(damus_state: damus_state!, home: home, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset)
|
||||
PostingTimelineView(damus_state: damus_state!, home: home, homeEvents: home.events, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset)
|
||||
|
||||
case .notifications:
|
||||
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
||||
|
||||
case .dms:
|
||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle)
|
||||
DirectMessagesView(damus_state: damus_state!, home: home, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle)
|
||||
}
|
||||
}
|
||||
.background(DamusColors.adaptableWhite)
|
||||
@@ -195,6 +196,9 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
notify(.display_tabbar(true))
|
||||
}
|
||||
}
|
||||
|
||||
func MaybeReportView(target: ReportTarget) -> some View {
|
||||
@@ -299,16 +303,20 @@ struct ContentView: View {
|
||||
.ignoresSafeArea(.keyboard)
|
||||
.edgesIgnoringSafeArea(hide_bar ? [.bottom] : [])
|
||||
.onAppear() {
|
||||
self.connect()
|
||||
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)
|
||||
setup_notifications()
|
||||
if !hasSeenOnboardingSuggestions || damus_state!.settings.always_show_onboarding_suggestions {
|
||||
active_sheet = .onboardingSuggestions
|
||||
hasSeenOnboardingSuggestions = true
|
||||
}
|
||||
self.appDelegate?.state = damus_state
|
||||
Task { // We probably don't need this to be a detached task. According to https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Defining-and-Calling-Asynchronous-Functions, awaits are only suspension points that do not block the thread.
|
||||
await self.listenAndHandleLocalNotifications()
|
||||
Task {
|
||||
await self.connect()
|
||||
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)
|
||||
setup_notifications()
|
||||
if !hasSeenOnboardingSuggestions || damus_state!.settings.always_show_onboarding_suggestions {
|
||||
if damus_state.is_privkey_user {
|
||||
active_sheet = .onboardingSuggestions
|
||||
hasSeenOnboardingSuggestions = true
|
||||
}
|
||||
}
|
||||
self.appDelegate?.state = damus_state
|
||||
Task { // We probably don't need this to be a detached task. According to https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Defining-and-Calling-Asynchronous-Functions, awaits are only suspension points that do not block the thread.
|
||||
await self.listenAndHandleLocalNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $active_sheet) { item in
|
||||
@@ -370,7 +378,7 @@ struct ContentView: View {
|
||||
self.hide_bar = !show
|
||||
}
|
||||
.onReceive(timer) { n in
|
||||
self.damus_state?.nostrNetwork.postbox.try_flushing_events()
|
||||
Task{ await self.damus_state?.nostrNetwork.postbox.try_flushing_events() }
|
||||
self.damus_state!.profiles.profile_data(self.damus_state!.pubkey).status.try_expire()
|
||||
}
|
||||
.onReceive(handle_notify(.report)) { target in
|
||||
@@ -381,43 +389,46 @@ struct ContentView: View {
|
||||
self.confirm_mute = true
|
||||
}
|
||||
.onReceive(handle_notify(.attached_wallet)) { nwc in
|
||||
// update the lightning address on our profile when we attach a
|
||||
// wallet with an associated
|
||||
guard let ds = self.damus_state,
|
||||
let lud16 = nwc.lud16,
|
||||
let keypair = ds.keypair.to_full(),
|
||||
let profile_txn = ds.profiles.lookup(id: ds.pubkey),
|
||||
let profile = profile_txn.unsafeUnownedValue,
|
||||
lud16 != profile.lud16 else {
|
||||
return
|
||||
Task {
|
||||
try? await damus_state.nostrNetwork.userRelayList.load() // Reload relay list to apply changes
|
||||
|
||||
// update the lightning address on our profile when we attach a
|
||||
// wallet with an associated
|
||||
guard let ds = self.damus_state,
|
||||
let lud16 = nwc.lud16,
|
||||
let keypair = ds.keypair.to_full(),
|
||||
let profile = try? ds.profiles.lookup(id: ds.pubkey),
|
||||
lud16 != profile.lud16 else {
|
||||
return
|
||||
}
|
||||
|
||||
// clear zapper cache for old lud16
|
||||
if profile.lud16 != nil {
|
||||
// TODO: should this be somewhere else, where we process profile events!?
|
||||
invalidate_zapper_cache(pubkey: keypair.pubkey, profiles: ds.profiles, lnurl: ds.lnurls)
|
||||
}
|
||||
|
||||
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: profile.reactions)
|
||||
|
||||
guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
|
||||
await ds.nostrNetwork.postbox.send(ev)
|
||||
}
|
||||
|
||||
// clear zapper cache for old lud16
|
||||
if profile.lud16 != nil {
|
||||
// TODO: should this be somewhere else, where we process profile events!?
|
||||
invalidate_zapper_cache(pubkey: keypair.pubkey, profiles: ds.profiles, lnurl: ds.lnurls)
|
||||
}
|
||||
|
||||
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: profile.reactions)
|
||||
|
||||
guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
|
||||
ds.nostrNetwork.postbox.send(ev)
|
||||
}
|
||||
.onReceive(handle_notify(.broadcast)) { ev in
|
||||
guard let ds = self.damus_state else { return }
|
||||
|
||||
ds.nostrNetwork.postbox.send(ev)
|
||||
Task { await ds.nostrNetwork.postbox.send(ev) }
|
||||
}
|
||||
.onReceive(handle_notify(.unfollow)) { target in
|
||||
guard let state = self.damus_state else { return }
|
||||
_ = handle_unfollow(state: state, unfollow: target.follow_ref)
|
||||
Task { _ = await handle_unfollow(state: state, unfollow: target.follow_ref) }
|
||||
}
|
||||
.onReceive(handle_notify(.unfollowed)) { unfollow in
|
||||
home.resubscribe(.unfollowing(unfollow))
|
||||
}
|
||||
.onReceive(handle_notify(.follow)) { target in
|
||||
guard let state = self.damus_state else { return }
|
||||
handle_follow_notif(state: state, target: target)
|
||||
Task { await handle_follow_notif(state: state, target: target) }
|
||||
}
|
||||
.onReceive(handle_notify(.followed)) { _ in
|
||||
home.resubscribe(.following)
|
||||
@@ -428,8 +439,10 @@ struct ContentView: View {
|
||||
return
|
||||
}
|
||||
|
||||
if !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post) {
|
||||
self.active_sheet = nil
|
||||
Task {
|
||||
if await !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post, clientTag: state.clientTagComponents) {
|
||||
self.active_sheet = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.new_mutes)) { _ in
|
||||
@@ -447,6 +460,9 @@ struct ContentView: View {
|
||||
.onReceive(handle_notify(.present_full_screen_item)) { item in
|
||||
self.active_full_screen_item = item
|
||||
}
|
||||
.onReceive(handle_notify(.favoriteUpdated)) { _ in
|
||||
home.subscribe_to_favorites()
|
||||
}
|
||||
.onReceive(handle_notify(.zapping)) { zap_ev in
|
||||
guard !zap_ev.is_custom else {
|
||||
return
|
||||
@@ -472,35 +488,35 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.disconnect_relays)) { () in
|
||||
damus_state.nostrNetwork.pool.disconnect()
|
||||
Task { await damus_state.nostrNetwork.disconnectRelays() }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
|
||||
print("txn: 📙 DAMUS ACTIVE NOTIFY")
|
||||
if damus_state.ndb.reopen() {
|
||||
print("txn: NOSTRDB REOPENED")
|
||||
} else {
|
||||
print("txn: NOSTRDB FAILED TO REOPEN closed:\(damus_state.ndb.is_closed)")
|
||||
}
|
||||
if damus_state.purple.checkout_ids_in_progress.count > 0 {
|
||||
// For extra assurance, run this after one second, to avoid race conditions if the app is also handling a damus purple welcome url.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
Task {
|
||||
let freshly_completed_checkout_ids = try? await damus_state.purple.check_status_of_checkouts_in_progress()
|
||||
let there_is_a_completed_checkout: Bool = (freshly_completed_checkout_ids?.count ?? 0) > 0
|
||||
let account_info = try await damus_state.purple.fetch_account(pubkey: self.keypair.pubkey)
|
||||
if there_is_a_completed_checkout == true && account_info?.active == true {
|
||||
if damus_state.purple.onboarding_status.user_has_never_seen_the_onboarding_before() {
|
||||
// Show welcome sheet
|
||||
self.active_sheet = .purple_onboarding
|
||||
}
|
||||
else {
|
||||
self.active_sheet = .purple(DamusPurpleURL.init(is_staging: damus_state.purple.environment == .staging, variant: .landing))
|
||||
Task {
|
||||
if damus_state.ndb.reopen() {
|
||||
print("txn: NOSTRDB REOPENED")
|
||||
} else {
|
||||
print("txn: NOSTRDB FAILED TO REOPEN closed:\(damus_state.ndb.is_closed)")
|
||||
}
|
||||
if damus_state.purple.checkout_ids_in_progress.count > 0 {
|
||||
// For extra assurance, run this after one second, to avoid race conditions if the app is also handling a damus purple welcome url.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
Task {
|
||||
let freshly_completed_checkout_ids = try? await damus_state.purple.check_status_of_checkouts_in_progress()
|
||||
let there_is_a_completed_checkout: Bool = (freshly_completed_checkout_ids?.count ?? 0) > 0
|
||||
let account_info = try await damus_state.purple.fetch_account(pubkey: self.keypair.pubkey)
|
||||
if there_is_a_completed_checkout == true && account_info?.active == true {
|
||||
if damus_state.purple.onboarding_status.user_has_never_seen_the_onboarding_before() {
|
||||
// Show welcome sheet
|
||||
self.active_sheet = .purple_onboarding
|
||||
}
|
||||
else {
|
||||
self.active_sheet = .purple(DamusPurpleURL.init(is_staging: damus_state.purple.environment == .staging, variant: .landing))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Task {
|
||||
await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification)
|
||||
}
|
||||
}
|
||||
@@ -509,8 +525,21 @@ struct ContentView: View {
|
||||
switch phase {
|
||||
case .background:
|
||||
print("txn: 📙 DAMUS BACKGROUNDED")
|
||||
Task { @MainActor in
|
||||
damus_state.ndb.close()
|
||||
let bgTask = this_app.beginBackgroundTask(withName: "Closing things down gracefully", expirationHandler: { [weak damus_state] in
|
||||
})
|
||||
|
||||
damusClosingTask = Task { @MainActor in
|
||||
Log.debug("App background signal handling: App being backgrounded", for: .app_lifecycle)
|
||||
let startTime = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
// Stop periodic snapshots
|
||||
await damus_state.snapshotManager.stopPeriodicSnapshots()
|
||||
|
||||
await damus_state.nostrNetwork.handleAppBackgroundRequest() // Close ndb streaming tasks before closing ndb to avoid memory errors
|
||||
|
||||
Log.debug("App background signal handling: Nostr network manager closed after %.2f seconds", for: .app_lifecycle, CFAbsoluteTimeGetCurrent() - startTime)
|
||||
|
||||
this_app.endBackgroundTask(bgTask)
|
||||
}
|
||||
break
|
||||
case .inactive:
|
||||
@@ -518,26 +547,34 @@ struct ContentView: View {
|
||||
break
|
||||
case .active:
|
||||
print("txn: 📙 DAMUS ACTIVE")
|
||||
damus_state.nostrNetwork.pool.ping()
|
||||
Task {
|
||||
await damusClosingTask?.value // Wait for the closing task to finish before reopening things, to avoid race conditions
|
||||
damusClosingTask = nil
|
||||
await damus_state.nostrNetwork.handleAppForegroundRequest()
|
||||
|
||||
// Restart periodic snapshots when returning to foreground
|
||||
await damus_state.snapshotManager.startPeriodicSnapshots()
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.onlyzaps_mode)) { hide in
|
||||
home.filter_events()
|
||||
|
||||
guard let ds = damus_state,
|
||||
let profile_txn = ds.profiles.lookup(id: ds.pubkey),
|
||||
let profile = profile_txn.unsafeUnownedValue,
|
||||
let keypair = ds.keypair.to_full()
|
||||
else {
|
||||
return
|
||||
Task {
|
||||
home.filter_events()
|
||||
|
||||
guard let ds = damus_state,
|
||||
let profile = try? ds.profiles.lookup(id: ds.pubkey),
|
||||
let keypair = ds.keypair.to_full()
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: !hide)
|
||||
|
||||
guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
|
||||
await ds.nostrNetwork.postbox.send(profile_ev)
|
||||
}
|
||||
|
||||
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: !hide)
|
||||
|
||||
guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
|
||||
ds.nostrNetwork.postbox.send(profile_ev)
|
||||
}
|
||||
.alert(NSLocalizedString("User muted", comment: "Alert message to indicate the user has been muted"), isPresented: $user_muted_confirm, actions: {
|
||||
Button(NSLocalizedString("Thanks!", comment: "Button to close out of alert that informs that the action to muted a user was successful.")) {
|
||||
@@ -545,8 +582,7 @@ struct ContentView: View {
|
||||
}
|
||||
}, message: {
|
||||
if case let .user(pubkey, _) = self.muting {
|
||||
let profile_txn = damus_state!.profiles.lookup(id: pubkey)
|
||||
let profile = profile_txn?.unsafeUnownedValue
|
||||
let profile = try? damus_state!.profiles.lookup(id: pubkey)
|
||||
let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
|
||||
Text("\(name) has been muted", comment: "Alert message that informs a user was muted.")
|
||||
} else {
|
||||
@@ -560,20 +596,22 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
Button(NSLocalizedString("Yes, Overwrite", comment: "Text of button that confirms to overwrite the existing mutelist.")) {
|
||||
guard let ds = damus_state,
|
||||
let keypair = ds.keypair.to_full(),
|
||||
let muting,
|
||||
let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: muting)
|
||||
else {
|
||||
return
|
||||
Task {
|
||||
guard let ds = damus_state,
|
||||
let keypair = ds.keypair.to_full(),
|
||||
let muting,
|
||||
let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: muting)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
ds.mutelist_manager.set_mutelist(mutelist)
|
||||
await ds.nostrNetwork.postbox.send(mutelist)
|
||||
|
||||
confirm_overwrite_mutelist = false
|
||||
confirm_mute = false
|
||||
user_muted_confirm = true
|
||||
}
|
||||
|
||||
ds.mutelist_manager.set_mutelist(mutelist)
|
||||
ds.nostrNetwork.postbox.send(mutelist)
|
||||
|
||||
confirm_overwrite_mutelist = false
|
||||
confirm_mute = false
|
||||
user_muted_confirm = true
|
||||
}
|
||||
}, message: {
|
||||
Text("No mute list found, create a new one? This will overwrite any previous mute lists.", comment: "Alert message prompt that asks if the user wants to create a new mute list, overwriting previous mute lists.")
|
||||
@@ -587,6 +625,10 @@ struct ContentView: View {
|
||||
return
|
||||
}
|
||||
|
||||
if ds.mutelist_manager.event == nil {
|
||||
home.load_latest_mutelist_event_from_damus_state()
|
||||
}
|
||||
|
||||
if ds.mutelist_manager.event == nil {
|
||||
confirm_overwrite_mutelist = true
|
||||
} else {
|
||||
@@ -601,13 +643,12 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
ds.mutelist_manager.set_mutelist(ev)
|
||||
ds.nostrNetwork.postbox.send(ev)
|
||||
Task { await ds.nostrNetwork.postbox.send(ev) }
|
||||
}
|
||||
}
|
||||
}, message: {
|
||||
if case let .user(pubkey, _) = muting {
|
||||
let profile_txn = damus_state?.profiles.lookup(id: pubkey)
|
||||
let profile = profile_txn?.unsafeUnownedValue
|
||||
let profile = try? damus_state?.profiles.lookup(id: pubkey)
|
||||
let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
|
||||
Text("Mute \(name)?", comment: "Alert message prompt to ask if a user should be muted.")
|
||||
} else {
|
||||
@@ -653,7 +694,7 @@ struct ContentView: View {
|
||||
self.execute_open_action(openAction)
|
||||
}
|
||||
|
||||
func connect() {
|
||||
func connect() async {
|
||||
// nostrdb
|
||||
var mndb = Ndb()
|
||||
if mndb == nil {
|
||||
@@ -675,12 +716,13 @@ struct ContentView: View {
|
||||
|
||||
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
|
||||
|
||||
let new_relay_filters = load_relay_filters(pubkey) == nil
|
||||
let new_relay_filters = await load_relay_filters(pubkey) == nil
|
||||
|
||||
self.damus_state = DamusState(keypair: keypair,
|
||||
likes: EventCounter(our_pubkey: pubkey),
|
||||
boosts: EventCounter(our_pubkey: pubkey),
|
||||
contacts: Contacts(our_pubkey: pubkey),
|
||||
contactCards: ContactCardManager(),
|
||||
mutelist_manager: MutelistManager(user_keypair: keypair),
|
||||
profiles: Profiles(ndb: ndb),
|
||||
dms: home.dms,
|
||||
@@ -706,6 +748,8 @@ struct ContentView: View {
|
||||
|
||||
home.damus_state = self.damus_state!
|
||||
|
||||
await damus_state.snapshotManager.startPeriodicSnapshots()
|
||||
|
||||
if let damus_state, damus_state.purple.enable_purple {
|
||||
// Assign delegate so that we can send receipts to the Purple API server as soon as we get updates from user's purchases
|
||||
StoreObserver.standard.delegate = damus_state.purple
|
||||
@@ -717,8 +761,7 @@ struct ContentView: View {
|
||||
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
|
||||
}
|
||||
|
||||
damus_state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: home.handle_event)
|
||||
damus_state.nostrNetwork.connect()
|
||||
|
||||
|
||||
if #available(iOS 17, *) {
|
||||
if damus_state.settings.developer_mode && damus_state.settings.reset_tips_on_launch {
|
||||
@@ -734,29 +777,36 @@ struct ContentView: View {
|
||||
Log.error("Failed to configure tips: %s", for: .tips, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
await damus_state.nostrNetwork.connect()
|
||||
// TODO: Move this to a better spot. Not sure what is the best signal to listen to for sending initial filters
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: {
|
||||
self.home.send_initial_filters()
|
||||
})
|
||||
}
|
||||
|
||||
func music_changed(_ state: MusicState) {
|
||||
guard let damus_state else { return }
|
||||
switch state {
|
||||
case .playback_state:
|
||||
break
|
||||
case .song(let song):
|
||||
guard let song, let kp = damus_state.keypair.to_full() else { return }
|
||||
|
||||
let pdata = damus_state.profiles.profile_data(damus_state.pubkey)
|
||||
|
||||
let desc = "\(song.title ?? "Unknown") - \(song.artist ?? "Unknown")"
|
||||
let encodedDesc = desc.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
|
||||
let url = encodedDesc.flatMap { enc in
|
||||
URL(string: "spotify:search:\(enc)")
|
||||
Task {
|
||||
guard let damus_state else { return }
|
||||
switch state {
|
||||
case .playback_state:
|
||||
break
|
||||
case .song(let song):
|
||||
guard let song, let kp = damus_state.keypair.to_full() else { return }
|
||||
|
||||
let pdata = damus_state.profiles.profile_data(damus_state.pubkey)
|
||||
|
||||
let desc = "\(song.title ?? "Unknown") - \(song.artist ?? "Unknown")"
|
||||
let encodedDesc = desc.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
|
||||
let url = encodedDesc.flatMap { enc in
|
||||
URL(string: "spotify:search:\(enc)")
|
||||
}
|
||||
let music = UserStatus(type: .music, expires_at: Date.now.addingTimeInterval(song.playbackDuration), content: desc, created_at: UInt32(Date.now.timeIntervalSince1970), url: url)
|
||||
|
||||
pdata.status.music = music
|
||||
|
||||
guard let ev = music.to_note(keypair: kp) else { return }
|
||||
await damus_state.nostrNetwork.postbox.send(ev)
|
||||
}
|
||||
let music = UserStatus(type: .music, expires_at: Date.now.addingTimeInterval(song.playbackDuration), content: desc, created_at: UInt32(Date.now.timeIntervalSince1970), url: url)
|
||||
|
||||
pdata.status.music = music
|
||||
|
||||
guard let ev = music.to_note(keypair: kp) else { return }
|
||||
damus_state.nostrNetwork.postbox.send(ev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,7 +856,7 @@ struct TopbarSideMenuButton: View {
|
||||
Button {
|
||||
isSideBarOpened.toggle()
|
||||
} label: {
|
||||
ProfilePicView(pubkey: damus_state.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
ProfilePicView(pubkey: damus_state.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation, damusState: damus_state)
|
||||
.opacity(isSideBarOpened ? 0 : 1)
|
||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||
.accessibilityHidden(true) // Knowing there is a profile picture here leads to no actionable outcome to VoiceOver users, so it is best not to show it
|
||||
@@ -908,7 +958,7 @@ func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [Nos
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func setup_notifications() {
|
||||
this_app.registerForRemoteNotifications()
|
||||
let center = UNUserNotificationCenter.current()
|
||||
@@ -943,169 +993,11 @@ enum FindEventType {
|
||||
}
|
||||
|
||||
enum FoundEvent {
|
||||
// TODO: Why not return the profile record itself? Right now the code probably just wants to trigger ndb to ingest the profile record and be available at ndb in parallel, but it would be cleaner if the function that uses this simply does that ndb query on their behalf.
|
||||
case profile(Pubkey)
|
||||
case event(NostrEvent)
|
||||
}
|
||||
|
||||
/// Finds an event from NostrDB if it exists, or from the network
|
||||
///
|
||||
/// This is the callback version. There is also an asyc/await version of this function.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - state: Damus state
|
||||
/// - query_: The query, including the event being looked for, and the relays to use when looking
|
||||
/// - callback: The function to call with results
|
||||
func find_event(state: DamusState, query query_: FindEvent, callback: @escaping (FoundEvent?) -> ()) {
|
||||
return find_event_with_subid(state: state, query: query_, subid: UUID().description, callback: callback)
|
||||
}
|
||||
|
||||
/// Finds an event from NostrDB if it exists, or from the network
|
||||
///
|
||||
/// This is a the async/await version of `find_event`. Use this when using callbacks is impossible or cumbersome.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - state: Damus state
|
||||
/// - query_: The query, including the event being looked for, and the relays to use when looking
|
||||
/// - callback: The function to call with results
|
||||
func find_event(state: DamusState, query query_: FindEvent) async -> FoundEvent? {
|
||||
await withCheckedContinuation { continuation in
|
||||
find_event(state: state, query: query_) { event in
|
||||
var already_resumed = false
|
||||
if !already_resumed { // Ensure we do not resume twice, as it causes a crash
|
||||
continuation.resume(returning: event)
|
||||
already_resumed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: String, callback: @escaping (FoundEvent?) -> ()) {
|
||||
|
||||
var filter: NostrFilter? = nil
|
||||
let find_from = query_.find_from
|
||||
let query = query_.type
|
||||
|
||||
switch query {
|
||||
case .profile(let pubkey):
|
||||
if let profile_txn = state.ndb.lookup_profile(pubkey),
|
||||
let record = profile_txn.unsafeUnownedValue,
|
||||
record.profile != nil
|
||||
{
|
||||
callback(.profile(pubkey))
|
||||
return
|
||||
}
|
||||
filter = NostrFilter(kinds: [.metadata], limit: 1, authors: [pubkey])
|
||||
|
||||
case .event(let evid):
|
||||
if let ev = state.events.lookup(evid) {
|
||||
callback(.event(ev))
|
||||
return
|
||||
}
|
||||
|
||||
filter = NostrFilter(ids: [evid], limit: 1)
|
||||
}
|
||||
|
||||
var attempts: Int = 0
|
||||
var has_event = false
|
||||
guard let filter else { return }
|
||||
|
||||
state.nostrNetwork.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in
|
||||
guard case .nostr_event(let ev) = res else {
|
||||
return
|
||||
}
|
||||
|
||||
guard ev.subid == subid else {
|
||||
return
|
||||
}
|
||||
|
||||
switch ev {
|
||||
case .ok:
|
||||
break
|
||||
case .event(_, let ev):
|
||||
has_event = true
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: subid)
|
||||
|
||||
switch query {
|
||||
case .profile:
|
||||
if ev.known_kind == .metadata {
|
||||
callback(.profile(ev.pubkey))
|
||||
}
|
||||
case .event:
|
||||
callback(.event(ev))
|
||||
}
|
||||
case .eose:
|
||||
if !has_event {
|
||||
attempts += 1
|
||||
if attempts >= state.nostrNetwork.pool.our_descriptors.count {
|
||||
callback(nil) // If we could not find any events in any of the relays we are connected to, send back nil
|
||||
}
|
||||
}
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose
|
||||
case .notice:
|
||||
break
|
||||
case .auth:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Finds a replaceable event based on an `naddr` address.
|
||||
///
|
||||
/// This is the callback version of the function. There is another function that makes use of async/await
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - damus_state: The Damus state
|
||||
/// - naddr: the `naddr` address
|
||||
/// - callback: A function to handle the found event
|
||||
func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (NostrEvent?) -> ()) {
|
||||
let nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
|
||||
|
||||
let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
|
||||
|
||||
let subid = UUID().description
|
||||
|
||||
damus_state.nostrNetwork.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in
|
||||
guard case .nostr_event(let ev) = res else {
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
return
|
||||
}
|
||||
|
||||
if case .event(_, let ev) = ev {
|
||||
for tag in ev.tags {
|
||||
if(tag.count >= 2 && tag[0].string() == "d"){
|
||||
if (tag[1].string() == naddr.identifier){
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
callback(ev)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds a replaceable event based on an `naddr` address.
|
||||
///
|
||||
/// This is the async/await version of the function. Another version of this function which makes use of callback functions also exists .
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - damus_state: The Damus state
|
||||
/// - naddr: the `naddr` address
|
||||
/// - callback: A function to handle the found event
|
||||
func naddrLookup(damus_state: DamusState, naddr: NAddr) async -> NostrEvent? {
|
||||
await withCheckedContinuation { continuation in
|
||||
var already_resumed = false
|
||||
naddrLookup(damus_state: damus_state, naddr: naddr) { event in
|
||||
if !already_resumed { // Ensure we do not resume twice, as it causes a crash
|
||||
continuation.resume(returning: event)
|
||||
already_resumed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func timeline_name(_ timeline: Timeline?) -> String {
|
||||
guard let timeline else {
|
||||
return ""
|
||||
@@ -1123,14 +1015,15 @@ func timeline_name(_ timeline: Timeline?) -> String {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handle_unfollow(state: DamusState, unfollow: FollowRef) -> Bool {
|
||||
@MainActor
|
||||
func handle_unfollow(state: DamusState, unfollow: FollowRef) async -> Bool {
|
||||
guard let keypair = state.keypair.to_full() else {
|
||||
return false
|
||||
}
|
||||
|
||||
let old_contacts = state.contacts.event
|
||||
|
||||
guard let ev = unfollow_reference(postbox: state.nostrNetwork.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
|
||||
guard let ev = await unfollow_reference(postbox: state.nostrNetwork.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
@@ -1151,12 +1044,13 @@ func handle_unfollow(state: DamusState, unfollow: FollowRef) -> Bool {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handle_follow(state: DamusState, follow: FollowRef) -> Bool {
|
||||
@MainActor
|
||||
func handle_follow(state: DamusState, follow: FollowRef) async -> Bool {
|
||||
guard let keypair = state.keypair.to_full() else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let ev = follow_reference(box: state.nostrNetwork.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
|
||||
guard let ev = await follow_reference(box: state.nostrNetwork.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
@@ -1176,37 +1070,51 @@ func handle_follow(state: DamusState, follow: FollowRef) -> Bool {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handle_follow_notif(state: DamusState, target: FollowTarget) -> Bool {
|
||||
func handle_follow_notif(state: DamusState, target: FollowTarget) async -> Bool {
|
||||
switch target {
|
||||
case .pubkey(let pk):
|
||||
state.contacts.add_friend_pubkey(pk)
|
||||
await state.contacts.add_friend_pubkey(pk)
|
||||
case .contact(let ev):
|
||||
state.contacts.add_friend_contact(ev)
|
||||
await state.contacts.add_friend_contact(ev)
|
||||
}
|
||||
|
||||
return handle_follow(state: state, follow: target.follow_ref)
|
||||
return await handle_follow(state: state, follow: target.follow_ref)
|
||||
}
|
||||
|
||||
func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, post: NostrPostResult) -> Bool {
|
||||
/// Handles a post notification by converting the post to a signed nostr event and broadcasting it.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - keypair: The user's full keypair used to sign the event.
|
||||
/// - postbox: The postbox used to broadcast the event to relays.
|
||||
/// - events: The event cache used to look up referenced events for rebroadcasting.
|
||||
/// - post: The post result, either a post to publish or a cancellation.
|
||||
/// - clientTag: Optional client tag array (e.g., `["client", "Damus"]`) to include in the event,
|
||||
/// identifying which application created the post. Pass `nil` to omit the tag.
|
||||
/// - Returns: `true` if the post was successfully converted and sent, `false` if the post was
|
||||
/// cancelled or if event conversion failed.
|
||||
///
|
||||
/// When successful, this function also rebroadcasts up to 3 referenced events and 3 quoted events
|
||||
/// to help ensure they are available on relays.
|
||||
func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, post: NostrPostResult, clientTag: [String]? = nil) async -> Bool {
|
||||
switch post {
|
||||
case .post(let post):
|
||||
//let post = tup.0
|
||||
//let to_relays = tup.1
|
||||
print("post \(post.content)")
|
||||
guard let new_ev = post.to_event(keypair: keypair) else {
|
||||
guard let new_ev = post.to_event(keypair: keypair, clientTag: clientTag) else {
|
||||
return false
|
||||
}
|
||||
postbox.send(new_ev)
|
||||
await postbox.send(new_ev)
|
||||
for eref in new_ev.referenced_ids.prefix(3) {
|
||||
// also broadcast at most 3 referenced events
|
||||
if let ev = events.lookup(eref) {
|
||||
postbox.send(ev)
|
||||
await postbox.send(ev)
|
||||
}
|
||||
}
|
||||
for qref in new_ev.referenced_quote_ids.prefix(3) {
|
||||
// also broadcast at most 3 referenced quoted events
|
||||
if let ev = events.lookup(qref.note_id) {
|
||||
postbox.send(ev)
|
||||
await postbox.send(ev)
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -1218,16 +1126,18 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
|
||||
|
||||
extension LossyLocalNotification {
|
||||
/// Computes a view open action from a mention reference.
|
||||
/// Use this when opening a user-presentable interface to a specific mention reference.
|
||||
/// Converts this mention's NIP-19 reference into a UI action for the app.
|
||||
///
|
||||
/// Maps NPUB and NPROFILE references to profile routes, NOTE/NEVENT/NADDR references to loadable note routes, NSCRIPT to a script view, and returns an error sheet for deprecated or unsafe references (`nrelay`, `nsec`).
|
||||
/// - Returns: A `ContentView.ViewOpenAction` that represents the route or sheet to present for this mention.
|
||||
func toViewOpenAction() -> ContentView.ViewOpenAction {
|
||||
switch self.mention.nip19 {
|
||||
case .npub(let pubkey):
|
||||
return .route(.ProfileByKey(pubkey: pubkey))
|
||||
case .note(let noteId):
|
||||
return .route(.LoadableNostrEvent(note_reference: .note_id(noteId)))
|
||||
return .route(.LoadableNostrEvent(note_reference: .note_id(noteId, relays: [])))
|
||||
case .nevent(let nEvent):
|
||||
// TODO: Improve this by implementing a route that handles nevents with their relay hints.
|
||||
return .route(.LoadableNostrEvent(note_reference: .note_id(nEvent.noteid)))
|
||||
return .route(.LoadableNostrEvent(note_reference: .note_id(nEvent.noteid, relays: nEvent.relays)))
|
||||
case .nprofile(let nProfile):
|
||||
// TODO: Improve this by implementing a profile route that handles nprofiles with their relay hints.
|
||||
return .route(.ProfileByKey(pubkey: nProfile.author))
|
||||
@@ -1254,10 +1164,8 @@ extension LossyLocalNotification {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func logout(_ state: DamusState?)
|
||||
{
|
||||
state?.close()
|
||||
notify(.logout)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -12,11 +12,11 @@ struct NIP04 {}
|
||||
extension NIP04 {
|
||||
/// Encrypts a message using NIP-04.
|
||||
static func encrypt_message(message: String, privkey: Privkey, to_pk: Pubkey, encoding: EncEncoding = .base64) -> String? {
|
||||
let iv = random_bytes(count: 16).bytes
|
||||
let iv = random_bytes(count: 16).byteArray
|
||||
guard let shared_sec = get_shared_secret(privkey: privkey, pubkey: to_pk) else {
|
||||
return nil
|
||||
}
|
||||
let utf8_message = Data(message.utf8).bytes
|
||||
let utf8_message = Data(message.utf8).byteArray
|
||||
guard let enc_message = aes_encrypt(data: utf8_message, iv: iv, shared_sec: shared_sec) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ extension NIP65 {
|
||||
self.relays = Self.relayOrderedDictionary(from: relays)
|
||||
}
|
||||
|
||||
init() {
|
||||
self.relays = Self.relayOrderedDictionary(from: [])
|
||||
}
|
||||
|
||||
init(relays: [RelayURL]) {
|
||||
let relayItemList = relays.map({ RelayItem(url: $0, rwConfiguration: .readWrite) })
|
||||
self.relays = Self.relayOrderedDictionary(from: relayItemList)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//
|
||||
// EntityPreloader.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D'Aquino on 2026-01-22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
import Negentropy
|
||||
|
||||
extension NostrNetworkManager {
|
||||
/// Preloads entities referenced in notes to improve user experience.
|
||||
///
|
||||
/// This actor efficiently batches entity preload requests to avoid overloading the network.
|
||||
/// Currently limited to preloading profile metadata, but designed to be expanded to other
|
||||
/// entity types (e.g., referenced events, media) in the future.
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - Uses a queue to collect preload requests
|
||||
/// - Batches requests intelligently: either when 500 pending requests accumulate, or after 1 second
|
||||
/// - Uses standard Nostr subscriptions to fetch metadata
|
||||
/// - Runs a long-running task to process the queue continuously
|
||||
actor EntityPreloader {
|
||||
private let pool: RelayPool
|
||||
private let ndb: Ndb
|
||||
private let queue: QueueableNotify<Set<Pubkey>>
|
||||
private var processingTask: Task<Void, Never>?
|
||||
private var accumulatedPubkeys = Set<Pubkey>()
|
||||
|
||||
private static let logger = Logger(
|
||||
subsystem: Constants.MAIN_APP_BUNDLE_IDENTIFIER,
|
||||
category: "entity_preloader"
|
||||
)
|
||||
|
||||
/// Maximum number of items allowed in the queue before old items are discarded
|
||||
private static let maxQueueItems = 1000
|
||||
/// Batch size threshold - preload immediately when this many requests are pending
|
||||
private static let batchSizeThreshold = 500
|
||||
/// Time threshold - preload after this duration even if batch size not reached
|
||||
private static let timeThreshold: Duration = .seconds(1)
|
||||
|
||||
init(pool: RelayPool, ndb: Ndb) {
|
||||
self.pool = pool
|
||||
self.ndb = ndb
|
||||
self.queue = QueueableNotify<Set<Pubkey>>(maxQueueItems: Self.maxQueueItems)
|
||||
}
|
||||
|
||||
/// Starts the preloader's background processing task
|
||||
func start() {
|
||||
guard processingTask == nil else {
|
||||
Self.logger.warning("EntityPreloader already started")
|
||||
return
|
||||
}
|
||||
|
||||
Self.logger.info("Starting EntityPreloader")
|
||||
processingTask = Task {
|
||||
await monitorQueue()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the preloader's background processing task
|
||||
func stop() {
|
||||
Self.logger.info("Stopping EntityPreloader")
|
||||
processingTask?.cancel()
|
||||
processingTask = nil
|
||||
}
|
||||
|
||||
/// Preloads metadata for the author and referenced profiles in a note
|
||||
///
|
||||
/// - Parameter noteLender: The note to extract profiles from
|
||||
nonisolated func preload(note noteLender: NdbNoteLender) {
|
||||
Task {
|
||||
do {
|
||||
let pubkeys = try noteLender.borrow { event in
|
||||
if event.known_kind == .metadata { return Set<Pubkey>() } // Don't preload pubkeys from a user profile
|
||||
var pubkeys = Set<Pubkey>()
|
||||
|
||||
// Add the author
|
||||
pubkeys.insert(event.pubkey)
|
||||
|
||||
// Add all referenced pubkeys from p tags
|
||||
for referencedPubkey in event.referenced_pubkeys {
|
||||
pubkeys.insert(referencedPubkey)
|
||||
}
|
||||
|
||||
return pubkeys
|
||||
}
|
||||
|
||||
guard !pubkeys.isEmpty else { return }
|
||||
|
||||
// Filter out pubkeys that already have profiles in ndb
|
||||
let pubkeysToPreload = await pubkeys.asyncFilter { pubkey in
|
||||
let hasProfile = (try? await ndb.lookup_profile(pubkey, borrow: { pr in
|
||||
pr != nil
|
||||
})) ?? false
|
||||
return !hasProfile
|
||||
}
|
||||
|
||||
guard !pubkeysToPreload.isEmpty else {
|
||||
Self.logger.debug("All \(pubkeys.count, privacy: .public) profiles already in ndb, skipping preload")
|
||||
return
|
||||
}
|
||||
|
||||
Self.logger.debug("Queueing preload for \(pubkeysToPreload.count, privacy: .public) profiles (\(pubkeys.count - pubkeysToPreload.count, privacy: .public) already cached)")
|
||||
await queue.add(item: pubkeysToPreload)
|
||||
} catch {
|
||||
Self.logger.error("Error extracting pubkeys from note: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes the queue continuously, batching requests intelligently
|
||||
private func monitorQueue() async {
|
||||
await withThrowingTaskGroup { group in
|
||||
group.addTask {
|
||||
for await newPubkeys in await self.queue.stream {
|
||||
try Task.checkCancellation()
|
||||
await self.handle(newQueueItem: newPubkeys)
|
||||
}
|
||||
}
|
||||
|
||||
group.addTask {
|
||||
while !Task.isCancelled {
|
||||
try await Task.sleep(for: Self.timeThreshold)
|
||||
await self.handleTimerTick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTimerTick() async {
|
||||
if accumulatedPubkeys.count > 0 {
|
||||
await self.performPreload()
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(newQueueItem: Set<Pubkey>) async {
|
||||
accumulatedPubkeys = self.accumulatedPubkeys.union(newQueueItem)
|
||||
if accumulatedPubkeys.count > Self.batchSizeThreshold {
|
||||
await self.performPreload()
|
||||
}
|
||||
}
|
||||
|
||||
private func performPreload() async {
|
||||
let pubkeysToPreload = accumulatedPubkeys
|
||||
accumulatedPubkeys.removeAll()
|
||||
Self.logger.debug("Preloading \(pubkeysToPreload.count, privacy: .public) profiles")
|
||||
await self.performPreload(pubkeys: pubkeysToPreload)
|
||||
}
|
||||
|
||||
/// Performs the actual preload operation using standard Nostr subscriptions.
|
||||
///
|
||||
/// - Parameter pubkeys: The set of pubkeys to preload metadata for
|
||||
private func performPreload(pubkeys: Set<Pubkey>) async {
|
||||
guard !pubkeys.isEmpty else { return }
|
||||
|
||||
print("EntityPreloader.performPreload: Starting preload for \(pubkeys.count) pubkeys")
|
||||
|
||||
let filter = NostrFilter(
|
||||
kinds: [.metadata],
|
||||
authors: Array(pubkeys)
|
||||
)
|
||||
|
||||
for try await _ in await pool.subscribeExistingItems(
|
||||
filters: [filter],
|
||||
to: nil,
|
||||
eoseTimeout: .seconds(10),
|
||||
) {
|
||||
// NO-OP: We are only subscribing to let nostrdb ingest those events, but we do not need special handling here.
|
||||
guard !Task.isCancelled else { break }
|
||||
}
|
||||
|
||||
Self.logger.debug("Completed metadata fetch for \(pubkeys.count, privacy: .public) profiles")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Extensions
|
||||
|
||||
private extension Set {
|
||||
/// Asynchronously filters the set based on an async predicate
|
||||
///
|
||||
/// - Parameter predicate: An async closure that returns true for elements to include
|
||||
/// - Returns: A new set containing only elements for which predicate returns true
|
||||
func asyncFilter(_ predicate: (Element) async -> Bool) async -> Set<Element> {
|
||||
var result = Set<Element>()
|
||||
for element in self {
|
||||
if await predicate(element) {
|
||||
result.insert(element)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class NostrNetworkManager {
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - This will be marked `private` in the future to prevent other code from accessing the relay pool directly. Code outside this layer should use a higher level interface
|
||||
let pool: RelayPool // TODO: Make this private and make higher level interface for classes outside the NostrNetworkManager
|
||||
private let pool: RelayPool // TODO: Make this private and make higher level interface for classes outside the NostrNetworkManager
|
||||
/// A delegate that allows us to interact with the rest of app without introducing hard or circular dependencies
|
||||
private var delegate: Delegate
|
||||
/// Manages the user's relay list, controls RelayPool's connected relays
|
||||
@@ -33,34 +33,260 @@ class NostrNetworkManager {
|
||||
let postbox: PostBox
|
||||
/// Handles subscriptions and functions to read or consume data from the Nostr network
|
||||
let reader: SubscriptionManager
|
||||
let profilesManager: ProfilesManager
|
||||
|
||||
init(delegate: Delegate) {
|
||||
/// Tracks whether the network manager has completed its initial connection
|
||||
private var isConnected = false
|
||||
/// A list of continuations waiting for connection to complete
|
||||
///
|
||||
/// We use a unique ID for each connection request so that multiple concurrent calls to `awaitConnection()`
|
||||
/// can be properly tracked and resumed. This follows the pattern established in `RelayConnection` and `WalletModel`.
|
||||
private var connectionContinuations: [UUID: CheckedContinuation<Void, Never>] = [:]
|
||||
/// A lock to ensure thread-safe access to the continuations dictionary and connection state
|
||||
private let continuationsLock = NSLock()
|
||||
|
||||
init(delegate: Delegate, addNdbToRelayPool: Bool = true) {
|
||||
self.delegate = delegate
|
||||
let pool = RelayPool(ndb: delegate.ndb, keypair: delegate.keypair)
|
||||
let pool = RelayPool(ndb: addNdbToRelayPool ? delegate.ndb : nil, keypair: delegate.keypair)
|
||||
self.pool = pool
|
||||
let reader = SubscriptionManager(pool: pool, ndb: delegate.ndb)
|
||||
let reader = SubscriptionManager(pool: pool, ndb: delegate.ndb, experimentalLocalRelayModelSupport: self.delegate.experimentalLocalRelayModelSupport)
|
||||
let userRelayList = UserRelayListManager(delegate: delegate, pool: pool, reader: reader)
|
||||
self.reader = reader
|
||||
self.userRelayList = userRelayList
|
||||
self.postbox = PostBox(pool: pool)
|
||||
self.profilesManager = ProfilesManager(subscriptionManager: reader, ndb: delegate.ndb)
|
||||
}
|
||||
|
||||
// MARK: - Control functions
|
||||
// MARK: - Control and lifecycle functions
|
||||
|
||||
/// Connects the app to the Nostr network
|
||||
func connect() {
|
||||
self.userRelayList.connect()
|
||||
func connect() async {
|
||||
await self.userRelayList.connect() // Will load the user's list, apply it, and get RelayPool to connect to it.
|
||||
await self.profilesManager.load()
|
||||
await self.reader.startPreloader()
|
||||
|
||||
continuationsLock.lock()
|
||||
isConnected = true
|
||||
continuationsLock.unlock()
|
||||
|
||||
resumeAllConnectionContinuations()
|
||||
}
|
||||
|
||||
/// Waits for the app to be connected to the network by checking for the next `connect()` call to complete
|
||||
///
|
||||
/// This method allows code to await the app to load the relay list and connect to it.
|
||||
/// It uses Swift continuations to handle completion notifications from potentially different threads.
|
||||
///
|
||||
/// - Parameter timeout: Optional timeout duration (defaults to 30 seconds)
|
||||
///
|
||||
/// ## Usage
|
||||
/// ```swift
|
||||
/// await nostrNetworkManager.awaitConnection()
|
||||
/// // Code here runs after connection is established
|
||||
/// ```
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - Thread-safe: Can be called from any thread and will handle synchronization properly
|
||||
/// - Multiple callers: Supports multiple concurrent calls, each tracked by a unique ID
|
||||
/// - Timeout handling: Automatically resumes after timeout even if connection fails
|
||||
/// - Short-circuits immediately if already connected, preventing unnecessary waiting
|
||||
func awaitConnection(timeout: Duration = .seconds(30)) async {
|
||||
// Short-circuit if already connected
|
||||
continuationsLock.lock()
|
||||
let alreadyConnected = isConnected
|
||||
continuationsLock.unlock()
|
||||
|
||||
guard !alreadyConnected else {
|
||||
return
|
||||
}
|
||||
|
||||
let requestId = UUID()
|
||||
var timeoutTask: Task<Void, Never>?
|
||||
var isResumed = false
|
||||
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
// Store the continuation in a thread-safe manner
|
||||
continuationsLock.lock()
|
||||
connectionContinuations[requestId] = continuation
|
||||
continuationsLock.unlock()
|
||||
|
||||
// Set up timeout
|
||||
timeoutTask = Task {
|
||||
try? await Task.sleep(for: timeout)
|
||||
if !isResumed {
|
||||
self.resumeConnectionContinuation(requestId: requestId, isResumed: &isResumed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timeoutTask?.cancel()
|
||||
}
|
||||
|
||||
/// Resumes a connection continuation in a thread-safe manner
|
||||
///
|
||||
/// This can be called from any thread and ensures the continuation is only resumed once
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - requestId: The unique identifier for this connection request
|
||||
/// - isResumed: Flag to track if the continuation has already been resumed
|
||||
private func resumeConnectionContinuation(requestId: UUID, isResumed: inout Bool) {
|
||||
continuationsLock.lock()
|
||||
defer { continuationsLock.unlock() }
|
||||
|
||||
guard !isResumed, let continuation = connectionContinuations[requestId] else {
|
||||
return
|
||||
}
|
||||
|
||||
isResumed = true
|
||||
connectionContinuations.removeValue(forKey: requestId)
|
||||
continuation.resume()
|
||||
}
|
||||
|
||||
/// Resumes all pending connection continuations in a thread-safe manner
|
||||
///
|
||||
/// This is useful for notifying all waiting callers when the connection is established
|
||||
/// or when you need to unblock all pending connection requests.
|
||||
///
|
||||
/// This can be called from any thread and ensures all continuations are resumed safely.
|
||||
private func resumeAllConnectionContinuations() {
|
||||
continuationsLock.lock()
|
||||
defer { continuationsLock.unlock() }
|
||||
|
||||
// Resume all pending continuations
|
||||
for (_, continuation) in connectionContinuations {
|
||||
continuation.resume()
|
||||
}
|
||||
|
||||
// Clear the dictionary
|
||||
connectionContinuations.removeAll()
|
||||
}
|
||||
|
||||
func disconnectRelays() async {
|
||||
await self.pool.disconnect()
|
||||
|
||||
continuationsLock.lock()
|
||||
isConnected = false
|
||||
continuationsLock.unlock()
|
||||
}
|
||||
|
||||
func handleAppBackgroundRequest() async {
|
||||
await self.reader.cancelAllTasks()
|
||||
await self.reader.stopPreloader()
|
||||
await self.pool.cleanQueuedRequestForSessionEnd()
|
||||
}
|
||||
|
||||
func handleAppForegroundRequest() async {
|
||||
// Pinging the network will automatically reconnect any dead websocket connections
|
||||
await self.ping()
|
||||
await self.reader.startPreloader()
|
||||
}
|
||||
|
||||
func close() async {
|
||||
await withTaskGroup { group in
|
||||
// Spawn each cancellation task in parallel for faster execution speed
|
||||
group.addTask {
|
||||
await self.reader.cancelAllTasks()
|
||||
}
|
||||
group.addTask {
|
||||
await self.profilesManager.stop()
|
||||
}
|
||||
group.addTask {
|
||||
await self.reader.stopPreloader()
|
||||
}
|
||||
// But await on each one to prevent race conditions
|
||||
for await value in group { continue }
|
||||
await pool.close()
|
||||
}
|
||||
|
||||
continuationsLock.lock()
|
||||
isConnected = false
|
||||
continuationsLock.unlock()
|
||||
}
|
||||
|
||||
func ping() async {
|
||||
await self.pool.ping()
|
||||
}
|
||||
|
||||
func relaysForEvent(event: NostrEvent) -> [RelayURL] {
|
||||
@MainActor
|
||||
func relaysForEvent(event: NostrEvent) async -> [RelayURL] {
|
||||
// TODO(tyiu) Ideally this list would be sorted by the event author's outbox relay preferences
|
||||
// and reliability of relays to maximize chances of others finding this event.
|
||||
if let relays = pool.seen[event.id] {
|
||||
if let relays = await pool.seen[event.id] {
|
||||
return Array(relays)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
// TODO: ORGANIZE THESE
|
||||
|
||||
// MARK: - Communication with the Nostr Network
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - This class hides the relay pool on purpose to avoid other code from dealing with complex relay + nostrDB logic.
|
||||
/// - Instead, we provide an easy to use interface so that normal code can just get the info they want.
|
||||
/// - This is also to help us migrate to the relay model.
|
||||
// TODO: Define a better interface. This is a temporary scaffold to replace direct relay pool access. After that is done, we can refactor this interface to be cleaner and reduce non-sense.
|
||||
|
||||
func sendToNostrDB(event: NostrEvent) async {
|
||||
await self.pool.send_raw_to_local_ndb(.typical(.event(event)))
|
||||
}
|
||||
|
||||
func send(event: NostrEvent, to targetRelays: [RelayURL]? = nil, skipEphemeralRelays: Bool = true) async {
|
||||
await self.pool.send(.event(event), to: targetRelays, skip_ephemeral: skipEphemeralRelays)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func getRelay(_ id: RelayURL) -> RelayPool.Relay? {
|
||||
pool.get_relay(id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var connectedRelays: [RelayPool.Relay] {
|
||||
self.pool.relays
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var ourRelayDescriptors: [RelayPool.RelayDescriptor] {
|
||||
self.pool.our_descriptors
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func relayURLsThatSawNote(id: NoteId) async -> Set<RelayURL>? {
|
||||
return await self.pool.seen[id]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func determineToRelays(filters: RelayFilters) -> [RelayURL] {
|
||||
return self.pool.our_descriptors
|
||||
.map { $0.url }
|
||||
.filter { !filters.is_filtered(timeline: .search, relay_id: $0) }
|
||||
}
|
||||
|
||||
// MARK: NWC
|
||||
// TODO: Move this to NWCManager
|
||||
|
||||
@discardableResult
|
||||
func nwcPay(url: WalletConnectURL, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil, zap_request: NostrEvent? = nil) async -> NostrEvent? {
|
||||
await WalletConnect.pay(url: url, pool: self.pool, post: post, invoice: invoice, zap_request: nil)
|
||||
}
|
||||
|
||||
/// Send a donation zap to the Damus team
|
||||
func send_donation_zap(nwc: WalletConnectURL, percent: Int, base_msats: Int64) async {
|
||||
let percent_f = Double(percent) / 100.0
|
||||
let donations_msats = Int64(percent_f * Double(base_msats))
|
||||
|
||||
let payreq = LNUrlPayRequest(allowsNostr: true, commentAllowed: nil, nostrPubkey: "", callback: "https://sendsats.lol/@damus")
|
||||
guard let invoice = await fetch_zap_invoice(payreq, zapreq: nil, msats: donations_msats, zap_type: .non_zap, comment: nil) else {
|
||||
// we failed... oh well. no donation for us.
|
||||
print("damus-donation failed to fetch invoice")
|
||||
return
|
||||
}
|
||||
|
||||
print("damus-donation donating...")
|
||||
await WalletConnect.pay(url: nwc, pool: self.pool, post: self.postbox, invoice: invoice, zap_request: nil, delay: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +311,7 @@ extension NostrNetworkManager {
|
||||
/// The latest contact list `NostrEvent`
|
||||
///
|
||||
/// Note: Read-only access, because `NostrNetworkManager` does not manage contact lists.
|
||||
@MainActor
|
||||
var latestContactListEvent: NostrEvent? { get }
|
||||
|
||||
/// Default bootstrap relays to start with when a user relay list is not present
|
||||
@@ -93,6 +320,9 @@ extension NostrNetworkManager {
|
||||
/// Whether the app is in developer mode
|
||||
var developerMode: Bool { get }
|
||||
|
||||
/// Whether the app has the experimental local relay model flag that streams data only from the local relay (ndb)
|
||||
var experimentalLocalRelayModelSupport: Bool { get }
|
||||
|
||||
/// The cache of relay model information
|
||||
var relayModelCache: RelayModelCache { get }
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//
|
||||
// ProfilesManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-09-19.
|
||||
//
|
||||
import Foundation
|
||||
|
||||
extension NostrNetworkManager {
|
||||
/// Efficiently manages getting profile metadata from the network and NostrDB without too many relay subscriptions
|
||||
///
|
||||
/// This is necessary because relays have a limit on how many subscriptions can be sent to relays at one given time.
|
||||
actor ProfilesManager {
|
||||
private var profileListenerTask: Task<Void, any Error>? = nil
|
||||
private var subscriptionSwitcherTask: Task<Void, any Error>? = nil
|
||||
private var subscriptionNeedsUpdate: Bool = false
|
||||
private let subscriptionManager: SubscriptionManager
|
||||
private let ndb: Ndb
|
||||
private var streams: [Pubkey: [UUID: ProfileStreamInfo]]
|
||||
|
||||
|
||||
// MARK: - Initialization and deinitialization
|
||||
|
||||
init(subscriptionManager: SubscriptionManager, ndb: Ndb) {
|
||||
self.subscriptionManager = subscriptionManager
|
||||
self.ndb = ndb
|
||||
self.streams = [:]
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.subscriptionSwitcherTask?.cancel()
|
||||
self.profileListenerTask?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Task management
|
||||
|
||||
func load() {
|
||||
self.restartProfileListenerTask()
|
||||
self.subscriptionSwitcherTask?.cancel()
|
||||
self.subscriptionSwitcherTask = Task {
|
||||
while true {
|
||||
try await Task.sleep(for: .seconds(1))
|
||||
try Task.checkCancellation()
|
||||
if subscriptionNeedsUpdate {
|
||||
try Task.checkCancellation()
|
||||
self.restartProfileListenerTask()
|
||||
subscriptionNeedsUpdate = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
await withTaskGroup { group in
|
||||
// Spawn each cancellation in parallel for better execution speed
|
||||
group.addTask {
|
||||
await self.subscriptionSwitcherTask?.cancel()
|
||||
try? await self.subscriptionSwitcherTask?.value
|
||||
}
|
||||
group.addTask {
|
||||
await self.profileListenerTask?.cancel()
|
||||
try? await self.profileListenerTask?.value
|
||||
}
|
||||
// But await for all of them to be done before returning to avoid race conditions
|
||||
for await value in group { continue }
|
||||
}
|
||||
}
|
||||
|
||||
private func restartProfileListenerTask() {
|
||||
self.profileListenerTask?.cancel()
|
||||
self.profileListenerTask = Task {
|
||||
try await self.listenToProfileChanges()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Listening and publishing of profile changes
|
||||
|
||||
private func listenToProfileChanges() async throws {
|
||||
let pubkeys = Array(streams.keys)
|
||||
guard pubkeys.count > 0 else { return }
|
||||
let profileFilter = NostrFilter(kinds: [.metadata], authors: pubkeys)
|
||||
try Task.checkCancellation()
|
||||
for await ndbLender in self.subscriptionManager.streamIndefinitely(filters: [profileFilter], streamMode: .ndbFirst(networkOptimization: nil)) {
|
||||
try Task.checkCancellation()
|
||||
try? ndbLender.borrow { ev in
|
||||
publishProfileUpdates(metadataEvent: ev)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
}
|
||||
}
|
||||
|
||||
private func publishProfileUpdates(metadataEvent: borrowing UnownedNdbNote) {
|
||||
let now = UInt64(Date.now.timeIntervalSince1970)
|
||||
try? ndb.write_profile_last_fetched(pubkey: metadataEvent.pubkey, fetched_at: now)
|
||||
|
||||
if let relevantStreams = streams[metadataEvent.pubkey] {
|
||||
// If we have the user metadata event in ndb, then we should have the profile record as well.
|
||||
guard let profile = try? ndb.lookup_profile_and_copy(metadataEvent.pubkey) else { return }
|
||||
for relevantStream in relevantStreams.values {
|
||||
relevantStream.continuation.yield(profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually trigger profile updates for a given pubkey
|
||||
/// This is useful for local profile changes (e.g., nip05 validation, donation percentage updates)
|
||||
func notifyProfileUpdate(pubkey: Pubkey) {
|
||||
if let relevantStreams = streams[pubkey] {
|
||||
guard let profile = try? ndb.lookup_profile_and_copy(pubkey) else { return }
|
||||
for relevantStream in relevantStreams.values {
|
||||
relevantStream.continuation.yield(profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Streaming interface
|
||||
|
||||
/// Streams profile updates for a single pubkey.
|
||||
///
|
||||
/// By default, the stream immediately yields the existing profile from NostrDB
|
||||
/// (if available), then continues yielding updates as they arrive from the network.
|
||||
///
|
||||
/// This immediate yield is essential for views that display profile data (names,
|
||||
/// pictures) because the subscription restart has a ~1 second delay. Without it,
|
||||
/// views would flash abbreviated pubkeys or robohash placeholders.
|
||||
///
|
||||
/// Set `yieldCached: false` for subscribers that only need network updates (e.g.,
|
||||
/// re-rendering content when profiles change) and already handle initial state
|
||||
/// through other means.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - pubkey: The pubkey to stream profile updates for
|
||||
/// - yieldCached: Whether to immediately yield the cached profile. Defaults to `true`.
|
||||
/// - Returns: An AsyncStream that yields Profile objects
|
||||
func streamProfile(pubkey: Pubkey, yieldCached: Bool = true) -> AsyncStream<ProfileStreamItem> {
|
||||
return AsyncStream<ProfileStreamItem> { continuation in
|
||||
let stream = ProfileStreamInfo(continuation: continuation)
|
||||
self.add(pubkey: pubkey, stream: stream)
|
||||
|
||||
// Yield cached profile immediately so views don't flash placeholder content.
|
||||
// Callers that only need updates (not initial state) can opt out via yieldCached: false.
|
||||
if yieldCached, let existingProfile = try? ndb.lookup_profile_and_copy(pubkey) {
|
||||
continuation.yield(existingProfile)
|
||||
}
|
||||
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task { await self.removeStream(pubkey: pubkey, id: stream.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Streams profile updates for multiple pubkeys.
|
||||
///
|
||||
/// Same behavior as `streamProfile(_:yieldCached:)` but for a set of pubkeys.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - pubkeys: The set of pubkeys to stream profile updates for
|
||||
/// - yieldCached: Whether to immediately yield cached profiles. Defaults to `true`.
|
||||
/// - Returns: An AsyncStream that yields Profile objects
|
||||
func streamProfiles(pubkeys: Set<Pubkey>, yieldCached: Bool = true) -> AsyncStream<ProfileStreamItem> {
|
||||
guard !pubkeys.isEmpty else {
|
||||
return AsyncStream<ProfileStreamItem> { continuation in
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
return AsyncStream<ProfileStreamItem> { continuation in
|
||||
let stream = ProfileStreamInfo(continuation: continuation)
|
||||
for pubkey in pubkeys {
|
||||
self.add(pubkey: pubkey, stream: stream)
|
||||
}
|
||||
|
||||
// Yield cached profiles immediately so views render correctly from the start.
|
||||
// Callers that only need updates (not initial state) can opt out via yieldCached: false.
|
||||
if yieldCached {
|
||||
for pubkey in pubkeys {
|
||||
if let existingProfile = try? ndb.lookup_profile_and_copy(pubkey) {
|
||||
continuation.yield(existingProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task {
|
||||
for pubkey in pubkeys {
|
||||
await self.removeStream(pubkey: pubkey, id: stream.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Stream management
|
||||
|
||||
private func add(pubkey: Pubkey, stream: ProfileStreamInfo) {
|
||||
if self.streams[pubkey] == nil {
|
||||
self.streams[pubkey] = [:]
|
||||
self.subscriptionNeedsUpdate = true
|
||||
}
|
||||
self.streams[pubkey]?[stream.id] = stream
|
||||
}
|
||||
|
||||
func removeStream(pubkey: Pubkey, id: UUID) {
|
||||
self.streams[pubkey]?[id] = nil
|
||||
if self.streams[pubkey]?.keys.count == 0 {
|
||||
// We don't need to subscribe to this profile anymore
|
||||
self.streams[pubkey] = nil
|
||||
self.subscriptionNeedsUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Helper types
|
||||
|
||||
typealias ProfileStreamItem = Profile
|
||||
|
||||
struct ProfileStreamInfo {
|
||||
let id: UUID = UUID()
|
||||
let continuation: AsyncStream<ProfileStreamItem>.Continuation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-03-25.
|
||||
//
|
||||
import Foundation
|
||||
import os
|
||||
import Negentropy
|
||||
|
||||
|
||||
extension NostrNetworkManager {
|
||||
/// Reads or fetches information from RelayPool and NostrDB, and provides an easier and unified higher-level interface.
|
||||
@@ -14,48 +18,647 @@ extension NostrNetworkManager {
|
||||
class SubscriptionManager {
|
||||
private let pool: RelayPool
|
||||
private var ndb: Ndb
|
||||
private var taskManager: TaskManager
|
||||
private let experimentalLocalRelayModelSupport: Bool
|
||||
private let entityPreloader: EntityPreloader
|
||||
|
||||
init(pool: RelayPool, ndb: Ndb) {
|
||||
private static let logger = Logger(
|
||||
subsystem: Constants.MAIN_APP_BUNDLE_IDENTIFIER,
|
||||
category: "subscription_manager"
|
||||
)
|
||||
|
||||
init(pool: RelayPool, ndb: Ndb, experimentalLocalRelayModelSupport: Bool) {
|
||||
self.pool = pool
|
||||
self.ndb = ndb
|
||||
self.taskManager = TaskManager()
|
||||
self.experimentalLocalRelayModelSupport = experimentalLocalRelayModelSupport
|
||||
self.entityPreloader = EntityPreloader(pool: pool, ndb: ndb)
|
||||
}
|
||||
|
||||
// MARK: - Reading data from Nostr
|
||||
// MARK: - Subscribing and Streaming data from Nostr
|
||||
|
||||
/// Subscribes to data from the user's relays
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - When we migrate to the local relay model, we should modify this function to stream directly from NostrDB
|
||||
///
|
||||
/// - Parameter filters: The nostr filters to specify what kind of data to subscribe to
|
||||
/// - Returns: An async stream of nostr data
|
||||
func subscribe(filters: [NostrFilter]) -> AsyncStream<StreamItem> {
|
||||
return AsyncStream<StreamItem> { continuation in
|
||||
let streamTask = Task {
|
||||
for await item in self.pool.subscribe(filters: filters) {
|
||||
/// Streams notes until the EOSE signal
|
||||
func streamExistingEvents(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, timeout: Duration? = nil, streamMode: StreamMode? = nil, preloadStrategy: PreloadStrategy? = nil, id: UUID? = nil) -> AsyncStream<NdbNoteLender> {
|
||||
let timeout = timeout ?? .seconds(10)
|
||||
return AsyncStream<NdbNoteLender> { continuation in
|
||||
let streamingTask = Task {
|
||||
outerLoop: for await item in self.advancedStream(filters: filters, to: desiredRelays, timeout: timeout, streamMode: streamMode, preloadStrategy: preloadStrategy, id: id) {
|
||||
try Task.checkCancellation()
|
||||
switch item {
|
||||
case .eose: continuation.yield(.eose)
|
||||
case .event(let nostrEvent):
|
||||
// At this point of the pipeline, if the note is valid it should have been processed and verified by NostrDB,
|
||||
// in which case we should pull the note from NostrDB to ensure validity.
|
||||
// However, NdbNotes are unowned, so we return a function where our callers can temporarily borrow the NostrDB note
|
||||
let noteId = nostrEvent.id
|
||||
let lender: NdbNoteLender = { lend in
|
||||
guard let ndbNoteTxn = self.ndb.lookup_note(noteId) else {
|
||||
throw NdbNoteLenderError.errorLoadingNote
|
||||
}
|
||||
guard let unownedNote = UnownedNdbNote(ndbNoteTxn) else {
|
||||
throw NdbNoteLenderError.errorLoadingNote
|
||||
}
|
||||
lend(unownedNote)
|
||||
}
|
||||
continuation.yield(.event(borrow: lender))
|
||||
case .event(let lender):
|
||||
continuation.yield(lender)
|
||||
case .eose:
|
||||
break outerLoop
|
||||
case .ndbEose:
|
||||
continue
|
||||
case .networkEose:
|
||||
continue
|
||||
}
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
streamingTask.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribes to data from user's relays, for a maximum period of time — after which the stream will end.
|
||||
///
|
||||
/// This is useful when waiting for some specific data from Nostr, but not indefinitely.
|
||||
func timedStream(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, timeout: Duration, streamMode: StreamMode? = nil, preloadStrategy: PreloadStrategy? = nil, id: UUID? = nil) -> AsyncStream<NdbNoteLender> {
|
||||
return AsyncStream<NdbNoteLender> { continuation in
|
||||
let streamingTask = Task {
|
||||
for await item in self.advancedStream(filters: filters, to: desiredRelays, timeout: timeout, streamMode: streamMode, preloadStrategy: preloadStrategy, id: id) {
|
||||
try Task.checkCancellation()
|
||||
switch item {
|
||||
case .event(lender: let lender):
|
||||
continuation.yield(lender)
|
||||
case .eose: break
|
||||
case .ndbEose: break
|
||||
case .networkEose: break
|
||||
}
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
streamingTask.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribes to notes indefinitely
|
||||
///
|
||||
/// This is useful when simply streaming all events indefinitely
|
||||
func streamIndefinitely(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, streamMode: StreamMode? = nil, preloadStrategy: PreloadStrategy? = nil, id: UUID? = nil) -> AsyncStream<NdbNoteLender> {
|
||||
return AsyncStream<NdbNoteLender> { continuation in
|
||||
let streamingTask = Task {
|
||||
for await item in self.advancedStream(filters: filters, to: desiredRelays, streamMode: streamMode, preloadStrategy: preloadStrategy, id: id) {
|
||||
try Task.checkCancellation()
|
||||
switch item {
|
||||
case .event(lender: let lender):
|
||||
continuation.yield(lender)
|
||||
case .eose:
|
||||
break
|
||||
case .ndbEose:
|
||||
break
|
||||
case .networkEose:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
streamTask.cancel() // Close the RelayPool stream when caller stops streaming
|
||||
streamingTask.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func advancedStream(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, timeout: Duration? = nil, streamMode: StreamMode? = nil, preloadStrategy: PreloadStrategy? = nil, id: UUID? = nil) -> AsyncStream<StreamItem> {
|
||||
let id = id ?? UUID()
|
||||
let streamMode = streamMode ?? defaultStreamMode()
|
||||
let preloadStrategy = preloadStrategy ?? self.defaultPreloadingMode()
|
||||
return AsyncStream<StreamItem> { continuation in
|
||||
let timeoutTask = Task {
|
||||
guard let timeout else { return }
|
||||
try? await Task.sleep(for: timeout)
|
||||
Self.logger.debug("Subscription \(id.uuidString, privacy: .public): Timed out!")
|
||||
continuation.finish()
|
||||
}
|
||||
|
||||
let startTime = CFAbsoluteTimeGetCurrent()
|
||||
Self.logger.debug("Session subscription \(id.uuidString, privacy: .public): Started")
|
||||
var ndbEOSEIssued = false
|
||||
var networkEOSEIssued = false
|
||||
|
||||
// This closure function issues (yields) an EOSE signal to the stream if all relevant conditions are met
|
||||
let yieldEOSEIfReady = {
|
||||
let connectedToNetwork = self.pool.network_monitor.currentPath.status == .satisfied
|
||||
// In normal mode: Issuing EOSE requires EOSE from both NDB and the network, since they are all considered separate relays
|
||||
// In experimental local relay model mode: Issuing EOSE requires only EOSE from NDB, since that is the only relay that "matters"
|
||||
let canIssueEOSE = switch streamMode {
|
||||
case .ndbFirst, .ndbOnly: (ndbEOSEIssued)
|
||||
case .ndbAndNetworkParallel: (ndbEOSEIssued && (networkEOSEIssued || !connectedToNetwork))
|
||||
}
|
||||
|
||||
if canIssueEOSE {
|
||||
Self.logger.debug("Session subscription \(id.uuidString, privacy: .public): Issued EOSE for session. Elapsed: \(CFAbsoluteTimeGetCurrent() - startTime, format: .fixed(precision: 2), privacy: .public) seconds")
|
||||
logStreamPipelineStats("SubscriptionManager_Advanced_Stream_\(id)", "Consumer_\(id)")
|
||||
continuation.yield(.eose)
|
||||
}
|
||||
}
|
||||
|
||||
var networkStreamTask: Task<Void, any Error>? = nil
|
||||
var latestNoteTimestampSeen: UInt32? = nil
|
||||
var negentropyStorageVector = NegentropyStorageVector()
|
||||
|
||||
let startNetworkStreamTask = {
|
||||
guard streamMode.shouldStreamFromNetwork else { return }
|
||||
networkStreamTask = Task {
|
||||
while !Task.isCancelled {
|
||||
let networkOptimizationData = StreamMode.NetworkOptimizationData.from(
|
||||
strategy: streamMode.networkOptimizationStrategy,
|
||||
latestNoteTimestampSeen: latestNoteTimestampSeen,
|
||||
negentropyStorageVector: negentropyStorageVector
|
||||
)
|
||||
for await item in self.multiSessionNetworkStream(filters: filters, to: desiredRelays, streamMode: streamMode, id: id, networkOptimizationData: networkOptimizationData) {
|
||||
try Task.checkCancellation()
|
||||
logStreamPipelineStats("SubscriptionManager_Network_Stream_\(id)", "SubscriptionManager_Advanced_Stream_\(id)")
|
||||
switch item {
|
||||
case .event(let lender):
|
||||
logStreamPipelineStats("SubscriptionManager_Advanced_Stream_\(id)", "Consumer_\(id)")
|
||||
// Preload entities if requested
|
||||
if case .preload = preloadStrategy {
|
||||
self.entityPreloader.preload(note: lender)
|
||||
}
|
||||
continuation.yield(item)
|
||||
case .eose:
|
||||
break // Should not happen
|
||||
case .ndbEose:
|
||||
break // Should not happen
|
||||
case .networkEose:
|
||||
logStreamPipelineStats("SubscriptionManager_Advanced_Stream_\(id)", "Consumer_\(id)")
|
||||
continuation.yield(item)
|
||||
networkEOSEIssued = true
|
||||
yieldEOSEIfReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if streamMode.optimizeNetworkFilter == false && streamMode.shouldStreamFromNetwork {
|
||||
// Start streaming from the network straight away
|
||||
startNetworkStreamTask()
|
||||
}
|
||||
|
||||
let ndbStreamTask = Task {
|
||||
while !Task.isCancelled {
|
||||
for await item in self.multiSessionNdbStream(filters: filters, to: desiredRelays, streamMode: streamMode, id: id) {
|
||||
try Task.checkCancellation()
|
||||
logStreamPipelineStats("SubscriptionManager_Ndb_MultiSession_Stream_\(id)", "SubscriptionManager_Advanced_Stream_\(id)")
|
||||
switch item {
|
||||
case .event(let lender):
|
||||
logStreamPipelineStats("SubscriptionManager_Advanced_Stream_\(id)", "Consumer_\(id)")
|
||||
try? lender.borrow({ event in
|
||||
if let latestTimestamp = latestNoteTimestampSeen {
|
||||
latestNoteTimestampSeen = max(latestTimestamp, event.createdAt)
|
||||
}
|
||||
else {
|
||||
latestNoteTimestampSeen = event.createdAt
|
||||
}
|
||||
negentropyStorageVector.unsealAndInsert(nostrEvent: event)
|
||||
})
|
||||
// Preload entities if requested
|
||||
if case .preload = preloadStrategy {
|
||||
self.entityPreloader.preload(note: lender)
|
||||
}
|
||||
continuation.yield(item)
|
||||
case .eose:
|
||||
break // Should not happen
|
||||
case .ndbEose:
|
||||
logStreamPipelineStats("SubscriptionManager_Advanced_Stream_\(id)", "Consumer_\(id)")
|
||||
continuation.yield(item)
|
||||
ndbEOSEIssued = true
|
||||
if streamMode.optimizeNetworkFilter && streamMode.shouldStreamFromNetwork {
|
||||
startNetworkStreamTask()
|
||||
}
|
||||
yieldEOSEIfReady()
|
||||
case .networkEose:
|
||||
break // Should not happen
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
timeoutTask.cancel()
|
||||
networkStreamTask?.cancel()
|
||||
ndbStreamTask.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func multiSessionNetworkStream(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, streamMode: StreamMode? = nil, id: UUID? = nil, networkOptimizationData: StreamMode.NetworkOptimizationData?) -> AsyncStream<StreamItem> {
|
||||
let id = id ?? UUID()
|
||||
let streamMode = streamMode ?? defaultStreamMode()
|
||||
return AsyncStream<StreamItem> { continuation in
|
||||
let startTime = CFAbsoluteTimeGetCurrent()
|
||||
Self.logger.debug("Network subscription \(id.uuidString, privacy: .public): Started")
|
||||
|
||||
let streamTask = Task {
|
||||
while await !self.pool.open {
|
||||
Self.logger.info("\(id.uuidString, privacy: .public): RelayPool closed. Sleeping for 1 second before resuming.")
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
for await item in await self.sessionNetworkStreamWithOptimization(filters: filters, to: desiredRelays, id: id, networkOptimizationData: networkOptimizationData) {
|
||||
try Task.checkCancellation()
|
||||
logStreamPipelineStats("RelayPool_Handler_\(id)", "SubscriptionManager_Network_Stream_\(id)")
|
||||
switch item {
|
||||
case .event(let event):
|
||||
switch streamMode {
|
||||
case .ndbFirst, .ndbOnly:
|
||||
break // NO-OP
|
||||
case .ndbAndNetworkParallel:
|
||||
continuation.yield(.event(lender: NdbNoteLender(ownedNdbNote: event)))
|
||||
}
|
||||
case .eose:
|
||||
Self.logger.debug("Session subscription \(id.uuidString, privacy: .public): Received EOSE from the network. Elapsed: \(CFAbsoluteTimeGetCurrent() - startTime, format: .fixed(precision: 2), privacy: .public) seconds")
|
||||
continuation.yield(.networkEose)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Self.logger.error("Network subscription \(id.uuidString, privacy: .public): Streaming error: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
Self.logger.debug("Network subscription \(id.uuidString, privacy: .public): Network streaming ended")
|
||||
continuation.finish()
|
||||
}
|
||||
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
streamTask.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream from the network with some optional optimization
|
||||
private func sessionNetworkStreamWithOptimization(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, id: UUID? = nil, networkOptimizationData: StreamMode.NetworkOptimizationData?) async -> AsyncStream<RelayPool.StreamItem> {
|
||||
guard let networkOptimizationData else {
|
||||
// No optimization, just return a regular RelayPool subscription
|
||||
return await self.pool.subscribe(filters: filters, to: desiredRelays, id: id)
|
||||
}
|
||||
switch networkOptimizationData {
|
||||
case .sinceOptimization(let latestNoteTimestampSeen):
|
||||
let optimizedFilters = filters.map {
|
||||
var optimizedFilter = $0
|
||||
// Shift the since filter 2 minutes (120 seconds) before the last note timestamp
|
||||
optimizedFilter.since = latestNoteTimestampSeen > 120 ? latestNoteTimestampSeen - 120 : 0
|
||||
return optimizedFilter
|
||||
}
|
||||
return await self.pool.subscribe(filters: optimizedFilters, to: desiredRelays, id: id)
|
||||
case .negentropy(let negentropyStorageVector):
|
||||
return AsyncStream<RelayPool.StreamItem>.with(task: { continuation in
|
||||
let id = id ?? UUID()
|
||||
do {
|
||||
for try await item in try await self.pool.negentropySubscribe(filters: filters, to: desiredRelays, negentropyVector: negentropyStorageVector, id: id, ignoreUnsupportedRelays: true) {
|
||||
continuation.yield(item)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Self.logger.error("Network subscription \(id.uuidString, privacy: .public): Streaming error: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func multiSessionNdbStream(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, streamMode: StreamMode? = nil, id: UUID? = nil) -> AsyncStream<StreamItem> {
|
||||
return AsyncStream<StreamItem> { continuation in
|
||||
let subscriptionId = id ?? UUID()
|
||||
let startTime = CFAbsoluteTimeGetCurrent()
|
||||
Self.logger.info("Starting multi-session NDB subscription \(subscriptionId.uuidString, privacy: .public): \(filters.debugDescription, privacy: .private)")
|
||||
let multiSessionStreamingTask = Task {
|
||||
while !Task.isCancelled {
|
||||
do {
|
||||
guard !self.ndb.is_closed else {
|
||||
Self.logger.info("\(subscriptionId.uuidString, privacy: .public): Ndb closed. Sleeping for 1 second before resuming.")
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
}
|
||||
Self.logger.info("\(subscriptionId.uuidString, privacy: .public): Streaming from NDB.")
|
||||
for await item in self.sessionNdbStream(filters: filters, to: desiredRelays, streamMode: streamMode, id: id) {
|
||||
try Task.checkCancellation()
|
||||
logStreamPipelineStats("SubscriptionManager_Ndb_Session_Stream_\(id?.uuidString ?? "NoID")", "SubscriptionManager_Ndb_MultiSession_Stream_\(id?.uuidString ?? "NoID")")
|
||||
continuation.yield(item)
|
||||
}
|
||||
Self.logger.info("\(subscriptionId.uuidString, privacy: .public): Session subscription ended. Sleeping for 1 second before resuming.")
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
catch {
|
||||
Self.logger.error("Session subscription \(subscriptionId.uuidString, privacy: .public): Error: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
Self.logger.info("\(subscriptionId.uuidString, privacy: .public): Terminated.")
|
||||
}
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Self.logger.info("\(subscriptionId.uuidString, privacy: .public): Cancelled multi-session NDB stream.")
|
||||
multiSessionStreamingTask.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sessionNdbStream(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, streamMode: StreamMode? = nil, id: UUID? = nil) -> AsyncStream<StreamItem> {
|
||||
let id = id ?? UUID()
|
||||
//let streamMode = streamMode ?? defaultStreamMode()
|
||||
return AsyncStream<StreamItem> { continuation in
|
||||
let startTime = CFAbsoluteTimeGetCurrent()
|
||||
Self.logger.debug("Session subscription \(id.uuidString, privacy: .public): Started")
|
||||
|
||||
let ndbStreamTask = Task {
|
||||
do {
|
||||
for await item in try self.ndb.subscribe(filters: try filters.map({ try NdbFilter(from: $0) })) {
|
||||
try Task.checkCancellation()
|
||||
switch item {
|
||||
case .eose:
|
||||
Self.logger.debug("Session subscription \(id.uuidString, privacy: .public): Received EOSE from nostrdb. Elapsed: \(CFAbsoluteTimeGetCurrent() - startTime, format: .fixed(precision: 2), privacy: .public) seconds")
|
||||
continuation.yield(.ndbEose)
|
||||
case .event(let noteKey):
|
||||
let lender = NdbNoteLender(ndb: self.ndb, noteKey: noteKey)
|
||||
try Task.checkCancellation()
|
||||
guard let desiredRelays else {
|
||||
continuation.yield(.event(lender: lender)) // If no desired relays are specified, return all notes we see.
|
||||
break
|
||||
}
|
||||
if try ndb.was(noteKey: noteKey, seenOnAnyOf: desiredRelays) {
|
||||
continuation.yield(.event(lender: lender)) // If desired relays were specified and this note was seen there, return it.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Self.logger.error("Session subscription \(id.uuidString, privacy: .public): NDB streaming error: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
Self.logger.debug("Session subscription \(id.uuidString, privacy: .public): NDB streaming ended")
|
||||
continuation.finish()
|
||||
}
|
||||
|
||||
Task {
|
||||
// Add the ndb streaming task to the task manager so that it can be cancelled when the app is backgrounded
|
||||
let ndbStreamTaskId = await self.taskManager.add(task: ndbStreamTask)
|
||||
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task {
|
||||
await self.taskManager.cancelAndCleanUp(taskId: ndbStreamTaskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Utility functions
|
||||
|
||||
private func defaultStreamMode() -> StreamMode {
|
||||
// Note: Network optimizations disabled by default for now because we need more testing to understand the effects of turning them on by default.
|
||||
self.experimentalLocalRelayModelSupport ? .ndbFirst(networkOptimization: nil) : .ndbAndNetworkParallel(networkOptimization: nil)
|
||||
}
|
||||
|
||||
private func defaultPreloadingMode() -> PreloadStrategy {
|
||||
return .preload
|
||||
}
|
||||
|
||||
// MARK: - Finding specific data from Nostr
|
||||
|
||||
/// Finds a non-replaceable event based on a note ID.
|
||||
///
|
||||
/// When relay hints are provided, they get a short exclusive window to respond.
|
||||
/// If no event is found within that window, the remaining time is used to broadcast
|
||||
/// to all connected relays. The `timeout` parameter is a total deadline for both phases.
|
||||
func lookup(noteId: NoteId, to targetRelays: [RelayURL]? = nil, timeout: Duration? = nil) async throws -> NdbNoteLender? {
|
||||
// Since note ids point to immutable objects, we can do a simple ndb lookup first
|
||||
if let note = try? self.ndb.lookup_note_and_copy(noteId) {
|
||||
return NdbNoteLender(ownedNdbNote: note)
|
||||
}
|
||||
|
||||
// Not available in local ndb, stream from network
|
||||
let filter = NostrFilter(ids: [noteId], limit: 1)
|
||||
let totalTimeout = timeout ?? .seconds(10)
|
||||
let startTime = ContinuousClock.now
|
||||
|
||||
// If relay hints provided, try them first with a short timeout
|
||||
if let targetRelays, !targetRelays.isEmpty {
|
||||
// Acquire ephemeral relays and connect to them
|
||||
await self.pool.acquireEphemeralRelays(targetRelays)
|
||||
defer {
|
||||
Task { await self.pool.releaseEphemeralRelays(targetRelays) }
|
||||
}
|
||||
|
||||
let connectedRelays = await self.pool.ensureConnected(to: targetRelays)
|
||||
guard !connectedRelays.isEmpty else {
|
||||
#if DEBUG
|
||||
Self.logger.info("lookup(noteId): No hint relays connected, skipping to broadcast")
|
||||
#endif
|
||||
return await fetchFromRelays(filter: filter, relays: nil, timeout: totalTimeout)
|
||||
}
|
||||
|
||||
// Use min of 3 seconds or half of total timeout for hint phase
|
||||
let hintTimeout = min(.seconds(3), totalTimeout / 2)
|
||||
|
||||
#if DEBUG
|
||||
Self.logger.info("lookup(noteId): Trying \(connectedRelays.count)/\(targetRelays.count) hint relay(s) with \(hintTimeout) timeout")
|
||||
#endif
|
||||
|
||||
let result = await fetchFromRelays(filter: filter, relays: connectedRelays, timeout: hintTimeout)
|
||||
if let result {
|
||||
return result
|
||||
}
|
||||
|
||||
// Calculate remaining time for broadcast phase
|
||||
let elapsed = ContinuousClock.now - startTime
|
||||
let remaining = totalTimeout - elapsed
|
||||
|
||||
guard remaining > .zero else {
|
||||
#if DEBUG
|
||||
Self.logger.info("lookup(noteId): Total timeout exceeded, skipping broadcast")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
// Hint relays didn't respond, fallback to broadcast with remaining time
|
||||
#if DEBUG
|
||||
Self.logger.info("lookup(noteId): Hint relays didn't respond, falling back to broadcast (\(remaining) remaining)")
|
||||
#endif
|
||||
return await fetchFromRelays(filter: filter, relays: nil, timeout: remaining)
|
||||
}
|
||||
|
||||
// No hints, broadcast to all relays
|
||||
return await fetchFromRelays(filter: filter, relays: nil, timeout: totalTimeout)
|
||||
}
|
||||
|
||||
/// Fetches the first event matching the filter from the specified relays.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - filter: The NostrFilter to match events against.
|
||||
/// - relays: Optional relay URLs to query. If nil, broadcasts to all connected relays.
|
||||
/// - timeout: Maximum duration to wait for a response.
|
||||
/// - Returns: An `NdbNoteLender` for the first matching event, or `nil` if EOSE is received
|
||||
/// or the timeout expires without finding a match.
|
||||
private func fetchFromRelays(filter: NostrFilter, relays: [RelayURL]?, timeout: Duration) async -> NdbNoteLender? {
|
||||
for await item in await self.pool.subscribe(filters: [filter], to: relays, eoseTimeout: timeout) {
|
||||
switch item {
|
||||
case .event(let event):
|
||||
return NdbNoteLender(ownedNdbNote: event)
|
||||
case .eose:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func query(filters: [NostrFilter], to: [RelayURL]? = nil, timeout: Duration? = nil) async -> [NostrEvent] {
|
||||
var events: [NostrEvent] = []
|
||||
for await noteLender in self.streamExistingEvents(filters: filters, to: to, timeout: timeout) {
|
||||
noteLender.justUseACopy({ events.append($0) })
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/// Finds a Nostr event that corresponds to the provided naddr identifier.
|
||||
/// - Parameters:
|
||||
/// - naddr: The NAddr (network address) that identifies the target replaceable event (contains kind, author, and identifier).
|
||||
/// - targetRelays: Optional relay URLs to hint where to search; the method may acquire ephemeral relays and will use only the subset of those that become connected.
|
||||
/// - timeout: Optional duration to bound the search.
|
||||
/// - Returns: The matching `NostrEvent` whose first referenced parameter equals `naddr.identifier`, or `nil` if no matching event is found.
|
||||
func lookup(naddr: NAddr, to targetRelays: [RelayURL]? = nil, timeout: Duration? = nil) async -> NostrEvent? {
|
||||
var connectedTargetRelays = targetRelays
|
||||
var ephemeralRelays: [RelayURL] = []
|
||||
if let relays = targetRelays, !relays.isEmpty {
|
||||
await self.pool.acquireEphemeralRelays(relays)
|
||||
ephemeralRelays = relays
|
||||
let connectedRelays = await self.pool.ensureConnected(to: relays)
|
||||
connectedTargetRelays = connectedRelays.isEmpty ? nil : connectedRelays
|
||||
#if DEBUG
|
||||
Self.logger.info("lookup(naddr): Using \(connectedRelays.count)/\(relays.count) relay hints: \(connectedRelays.map { $0.absoluteString }.joined(separator: ", "), privacy: .public)")
|
||||
#endif
|
||||
}
|
||||
|
||||
defer {
|
||||
if !ephemeralRelays.isEmpty {
|
||||
Task { await self.pool.releaseEphemeralRelays(ephemeralRelays) }
|
||||
}
|
||||
}
|
||||
|
||||
let nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
|
||||
let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
|
||||
|
||||
for await noteLender in self.streamExistingEvents(filters: [filter], to: connectedTargetRelays, timeout: timeout) {
|
||||
guard let event = noteLender.justGetACopy() else { continue }
|
||||
if event.referenced_params.first?.param.string() == naddr.identifier {
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Searches for a profile or event specified by `query` and returns the first matching result.
|
||||
/// The function first checks the local NDB cache and, if not found, queries relays (honoring any relay hints in the query).
|
||||
/// - Parameter query: Specifies what to find (profile by pubkey or event by id) and optional relay hints to use for network lookup.
|
||||
/// - Returns: A `FoundEvent` containing the matched profile or event, or `nil` if no match is found.
|
||||
func findEvent(query: FindEvent) async -> FoundEvent? {
|
||||
var filter: NostrFilter? = nil
|
||||
let find_from = query.find_from
|
||||
let query = query.type
|
||||
|
||||
switch query {
|
||||
case .profile(let pubkey):
|
||||
let profileNotNil = try? self.ndb.lookup_profile(pubkey, borrow: { pr in
|
||||
switch pr {
|
||||
case .some(let pr): return pr.profile != nil
|
||||
case .none: return true
|
||||
}
|
||||
})
|
||||
if profileNotNil ?? false {
|
||||
return .profile(pubkey)
|
||||
}
|
||||
filter = NostrFilter(kinds: [.metadata], limit: 1, authors: [pubkey])
|
||||
case .event(let evid):
|
||||
if let event = try? self.ndb.lookup_note_and_copy(evid) {
|
||||
return .event(event)
|
||||
}
|
||||
filter = NostrFilter(ids: [evid], limit: 1)
|
||||
}
|
||||
|
||||
guard let filter else { return nil }
|
||||
|
||||
var targetRelays = find_from
|
||||
var ephemeralRelays: [RelayURL] = []
|
||||
if let relays = find_from, !relays.isEmpty {
|
||||
await self.pool.acquireEphemeralRelays(relays)
|
||||
ephemeralRelays = relays
|
||||
let connectedRelays = await self.pool.ensureConnected(to: relays)
|
||||
targetRelays = connectedRelays.isEmpty ? nil : connectedRelays
|
||||
#if DEBUG
|
||||
Self.logger.info("findEvent: Using \(connectedRelays.count)/\(relays.count) relay hints: \(connectedRelays.map { $0.absoluteString }.joined(separator: ", "), privacy: .public)")
|
||||
#endif
|
||||
}
|
||||
|
||||
defer {
|
||||
if !ephemeralRelays.isEmpty {
|
||||
Task { await self.pool.releaseEphemeralRelays(ephemeralRelays) }
|
||||
}
|
||||
}
|
||||
|
||||
for await noteLender in self.streamExistingEvents(filters: [filter], to: targetRelays) {
|
||||
let foundEvent: FoundEvent? = try? noteLender.borrow({ event in
|
||||
switch query {
|
||||
case .profile:
|
||||
if event.known_kind == .metadata {
|
||||
return .profile(event.pubkey)
|
||||
}
|
||||
case .event:
|
||||
return .event(event.toOwned())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if let foundEvent {
|
||||
return foundEvent
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Task management
|
||||
|
||||
func startPreloader() async {
|
||||
await self.entityPreloader.start()
|
||||
}
|
||||
|
||||
func stopPreloader() async {
|
||||
await self.entityPreloader.stop()
|
||||
}
|
||||
|
||||
func cancelAllTasks() async {
|
||||
await self.taskManager.cancelAllTasks()
|
||||
}
|
||||
|
||||
actor TaskManager {
|
||||
private var tasks: [UUID: Task<Void, Never>] = [:]
|
||||
|
||||
private static let logger = Logger(
|
||||
subsystem: "com.jb55.damus",
|
||||
category: "subscription_manager.task_manager"
|
||||
)
|
||||
|
||||
func add(task: Task<Void, Never>) -> UUID {
|
||||
let taskId = UUID()
|
||||
self.tasks[taskId] = task
|
||||
return taskId
|
||||
}
|
||||
|
||||
func cancelAndCleanUp(taskId: UUID) async {
|
||||
self.tasks[taskId]?.cancel()
|
||||
await self.tasks[taskId]?.value
|
||||
self.tasks[taskId] = nil
|
||||
return
|
||||
}
|
||||
|
||||
func cancelAllTasks() async {
|
||||
await withTaskGroup { group in
|
||||
Self.logger.info("Cancelling all SubscriptionManager tasks")
|
||||
// Start each task cancellation in parallel for faster execution
|
||||
for (taskId, _) in self.tasks {
|
||||
Self.logger.info("Cancelling SubscriptionManager task \(taskId.uuidString, privacy: .public)")
|
||||
group.addTask {
|
||||
await self.cancelAndCleanUp(taskId: taskId)
|
||||
}
|
||||
}
|
||||
// However, wait until all cancellations are complete to avoid race conditions.
|
||||
for await value in group {
|
||||
continue
|
||||
}
|
||||
Self.logger.info("Cancelled all SubscriptionManager tasks")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,8 +666,98 @@ extension NostrNetworkManager {
|
||||
|
||||
enum StreamItem {
|
||||
/// An event which can be borrowed from NostrDB
|
||||
case event(borrow: NdbNoteLender)
|
||||
/// The end of stored events
|
||||
case event(lender: NdbNoteLender)
|
||||
/// The canonical generic "end of stored events", which depends on the stream mode. See `StreamMode` to see when this event is fired in relation to other EOSEs
|
||||
case eose
|
||||
/// "End of stored events" from NostrDB.
|
||||
case ndbEose
|
||||
/// "End of stored events" from all relays in `RelayPool`.
|
||||
case networkEose
|
||||
|
||||
var debugDescription: String {
|
||||
switch self {
|
||||
case .event(lender: let lender):
|
||||
let detailedDescription = try? lender.borrow({ event in
|
||||
"Note with ID: \(event.id.hex())"
|
||||
})
|
||||
return detailedDescription ?? "Some note"
|
||||
case .eose:
|
||||
return "EOSE"
|
||||
case .ndbEose:
|
||||
return "NDB EOSE"
|
||||
case .networkEose:
|
||||
return "NETWORK EOSE"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode of streaming
|
||||
enum StreamMode {
|
||||
/// Returns notes exclusively through NostrDB, treating it as the only channel for information in the pipeline. Generic EOSE is fired when EOSE is received from NostrDB
|
||||
case ndbFirst(networkOptimization: NetworkOptimizationStrategy?)
|
||||
/// Returns notes from both NostrDB and the network, in parallel, treating it with similar importance against the network relays. Generic EOSE is fired when EOSE is received from both the network and NostrDB
|
||||
case ndbAndNetworkParallel(networkOptimization: NetworkOptimizationStrategy?)
|
||||
/// Ignores the network.
|
||||
case ndbOnly
|
||||
|
||||
var optimizeNetworkFilter: Bool {
|
||||
return networkOptimizationStrategy != nil
|
||||
}
|
||||
|
||||
var networkOptimizationStrategy: NetworkOptimizationStrategy? {
|
||||
switch self {
|
||||
case .ndbFirst(networkOptimization: let networkOptimization):
|
||||
return networkOptimization
|
||||
case .ndbAndNetworkParallel(networkOptimization: let networkOptimization):
|
||||
return networkOptimization
|
||||
case .ndbOnly:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var shouldStreamFromNetwork: Bool {
|
||||
switch self {
|
||||
case .ndbFirst:
|
||||
return true
|
||||
case .ndbAndNetworkParallel:
|
||||
return true
|
||||
case .ndbOnly:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkOptimizationStrategy {
|
||||
/// Returns notes from ndb, then streams from the network with an added "since" filter set to the latest note stored on ndb.
|
||||
case sinceOptimization
|
||||
/// Returns notes from ndb, negentropy syncs missing notes with relays, then streams normally
|
||||
case negentropy
|
||||
}
|
||||
|
||||
enum NetworkOptimizationData {
|
||||
/// Returns notes from ndb, then streams from the network with an added "since" filter set to the latest note stored on ndb.
|
||||
case sinceOptimization(latestNoteTimestampSeen: UInt32)
|
||||
/// Returns notes from ndb, negentropy syncs missing notes with relays, then streams normally
|
||||
case negentropy(negentropyStorageVector: NegentropyStorageVector)
|
||||
|
||||
static func from(strategy: NetworkOptimizationStrategy?, latestNoteTimestampSeen: UInt32?, negentropyStorageVector: NegentropyStorageVector?) -> Self? {
|
||||
guard let strategy else { return nil }
|
||||
switch strategy {
|
||||
case .sinceOptimization:
|
||||
guard let latestNoteTimestampSeen else { return nil }
|
||||
return .sinceOptimization(latestNoteTimestampSeen: latestNoteTimestampSeen)
|
||||
case .negentropy:
|
||||
guard let negentropyStorageVector else { return nil }
|
||||
return .negentropy(negentropyStorageVector: negentropyStorageVector)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines the preloading strategy for a stream
|
||||
enum PreloadStrategy {
|
||||
/// No preloading - notes are not sent to EntityPreloader
|
||||
case noPreloading
|
||||
/// Preload metadata for authors and referenced profiles
|
||||
case preload
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ extension NostrNetworkManager {
|
||||
|
||||
// MARK: - Computing the relays to connect to
|
||||
|
||||
@MainActor
|
||||
private func relaysToConnectTo() -> [RelayPool.RelayDescriptor] {
|
||||
return self.computeRelaysToConnectTo(with: self.getBestEffortRelayList())
|
||||
}
|
||||
@@ -49,6 +50,7 @@ extension NostrNetworkManager {
|
||||
/// It attempts to get a relay list from the user. If one is not available, it uses the default bootstrap list.
|
||||
///
|
||||
/// This is always guaranteed to return a relay list.
|
||||
@MainActor
|
||||
func getBestEffortRelayList() -> NIP65.RelayList {
|
||||
guard let userCurrentRelayList = self.getUserCurrentRelayList() else {
|
||||
return NIP65.RelayList(relays: delegate.bootstrapRelays)
|
||||
@@ -59,6 +61,7 @@ extension NostrNetworkManager {
|
||||
/// Gets the user's current relay list.
|
||||
///
|
||||
/// It attempts to get a NIP-65 relay list from the local database, or falls back to a legacy list.
|
||||
@MainActor
|
||||
func getUserCurrentRelayList() -> NIP65.RelayList? {
|
||||
if let latestRelayListEvent = try? self.getLatestNIP65RelayList() { return latestRelayListEvent }
|
||||
if let latestRelayListEvent = try? self.getLatestKind3RelayList() { return latestRelayListEvent }
|
||||
@@ -87,12 +90,13 @@ extension NostrNetworkManager {
|
||||
private func getLatestNIP65RelayListEvent() -> NdbNote? {
|
||||
guard let latestRelayListEventId = delegate.latestRelayListEventIdHex else { return nil }
|
||||
guard let latestRelayListEventId = NoteId(hex: latestRelayListEventId) else { return nil }
|
||||
return delegate.ndb.lookup_note(latestRelayListEventId)?.unsafeUnownedValue?.to_owned()
|
||||
return try? delegate.ndb.lookup_note_and_copy(latestRelayListEventId)
|
||||
}
|
||||
|
||||
/// Gets the latest `kind:3` relay list from NostrDB.
|
||||
///
|
||||
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
|
||||
@MainActor
|
||||
private func getLatestKind3RelayList() throws(LoadingError) -> NIP65.RelayList? {
|
||||
guard let latestContactListEvent = delegate.latestContactListEvent else { return nil }
|
||||
guard let legacyContactList = try? NIP65.RelayList.fromLegacyContactList(latestContactListEvent) else { throw .relayListParseError }
|
||||
@@ -114,6 +118,7 @@ extension NostrNetworkManager {
|
||||
|
||||
/// Gets the creation date of the user's current relay list, with preference to NIP-65 relay lists
|
||||
/// - Returns: The current relay list's creation date
|
||||
@MainActor
|
||||
private func getUserCurrentRelayListCreationDate() -> UInt32? {
|
||||
if let latestNIP65RelayListEvent = self.getLatestNIP65RelayListEvent() { return latestNIP65RelayListEvent.created_at }
|
||||
if let latestKind3RelayListEvent = delegate.latestContactListEvent { return latestKind3RelayListEvent.created_at }
|
||||
@@ -122,72 +127,67 @@ extension NostrNetworkManager {
|
||||
|
||||
// MARK: - Listening to and handling relay updates from the network
|
||||
|
||||
func connect() {
|
||||
self.load()
|
||||
func connect() async {
|
||||
await self.load()
|
||||
|
||||
self.relayListObserverTask?.cancel()
|
||||
self.relayListObserverTask = Task { await self.listenAndHandleRelayUpdates() }
|
||||
self.walletUpdatesObserverTask?.cancel()
|
||||
self.walletUpdatesObserverTask = handle_notify(.attached_wallet).sink { _ in self.load() }
|
||||
self.walletUpdatesObserverTask = handle_notify(.attached_wallet).sink { _ in Task { await self.load() } }
|
||||
}
|
||||
|
||||
func listenAndHandleRelayUpdates() async {
|
||||
let filter = NostrFilter(kinds: [.relay_list], authors: [delegate.keypair.pubkey])
|
||||
for await item in self.reader.subscribe(filters: [filter]) {
|
||||
switch item {
|
||||
case .event(borrow: let borrow): // Signature validity already ensured at this point
|
||||
let currentRelayListCreationDate = self.getUserCurrentRelayListCreationDate()
|
||||
try? borrow { note in
|
||||
guard note.pubkey == self.delegate.keypair.pubkey else { return } // Ensure this new list was ours
|
||||
guard note.createdAt > (currentRelayListCreationDate ?? 0) else { return } // Ensure this is a newer list
|
||||
guard let relayList = try? NIP65.RelayList(event: note) else { return } // Ensure it is a valid NIP-65 list
|
||||
|
||||
try? self.set(userRelayList: relayList) // Set the validated list
|
||||
}
|
||||
case .eose: continue
|
||||
}
|
||||
for await noteLender in self.reader.streamIndefinitely(filters: [filter]) {
|
||||
let currentRelayListCreationDate = await self.getUserCurrentRelayListCreationDate()
|
||||
guard let note = noteLender.justGetACopy() else { continue }
|
||||
guard note.pubkey == self.delegate.keypair.pubkey else { continue } // Ensure this new list was ours
|
||||
guard note.created_at > (currentRelayListCreationDate ?? 0) else { continue } // Ensure this is a newer list
|
||||
guard let relayList = try? NIP65.RelayList(event: note) else { continue } // Ensure it is a valid NIP-65 list
|
||||
|
||||
try? await self.set(userRelayList: relayList) // Set the validated list
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Editing the user's relay list
|
||||
|
||||
func upsert(relay: NIP65.RelayList.RelayItem, force: Bool = false, overwriteExisting: Bool = false) throws(UpdateError) {
|
||||
guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
|
||||
func upsert(relay: NIP65.RelayList.RelayItem, force: Bool = false, overwriteExisting: Bool = false) async throws(UpdateError) {
|
||||
guard let currentUserRelayList = await force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
|
||||
guard !currentUserRelayList.relays.keys.contains(relay.url) || overwriteExisting else { throw .relayAlreadyExists }
|
||||
var newList = currentUserRelayList.relays
|
||||
newList[relay.url] = relay
|
||||
try self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values)))
|
||||
try await self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values)))
|
||||
}
|
||||
|
||||
func insert(relay: NIP65.RelayList.RelayItem, force: Bool = false) throws(UpdateError) {
|
||||
guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
|
||||
func insert(relay: NIP65.RelayList.RelayItem, force: Bool = false) async throws(UpdateError) {
|
||||
guard let currentUserRelayList = await force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
|
||||
guard currentUserRelayList.relays[relay.url] == nil else { throw .relayAlreadyExists }
|
||||
try self.upsert(relay: relay, force: force)
|
||||
try await self.upsert(relay: relay, force: force)
|
||||
}
|
||||
|
||||
func remove(relayURL: RelayURL, force: Bool = false) throws(UpdateError) {
|
||||
guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
|
||||
func remove(relayURL: RelayURL, force: Bool = false) async throws(UpdateError) {
|
||||
guard let currentUserRelayList = await force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
|
||||
guard currentUserRelayList.relays.keys.contains(relayURL) || force else { throw .noSuchRelay }
|
||||
var newList = currentUserRelayList.relays
|
||||
newList[relayURL] = nil
|
||||
try self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values)))
|
||||
try await self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values)))
|
||||
}
|
||||
|
||||
func set(userRelayList: NIP65.RelayList) throws(UpdateError) {
|
||||
func set(userRelayList: NIP65.RelayList) async throws(UpdateError) {
|
||||
guard let fullKeypair = delegate.keypair.to_full() else { throw .notAuthorizedToChangeRelayList }
|
||||
guard let relayListEvent = userRelayList.toNostrEvent(keypair: fullKeypair) else { throw .cannotFormRelayListEvent }
|
||||
|
||||
self.apply(newRelayList: self.computeRelaysToConnectTo(with: userRelayList))
|
||||
await self.apply(newRelayList: self.computeRelaysToConnectTo(with: userRelayList))
|
||||
|
||||
self.pool.send(.event(relayListEvent)) // This will send to NostrDB as well, which will locally save that NIP-65 event
|
||||
await self.pool.send(.event(relayListEvent)) // This will send to NostrDB as well, which will locally save that NIP-65 event
|
||||
self.delegate.latestRelayListEventIdHex = relayListEvent.id.hex() // Make sure we are able to recall this event from NostrDB
|
||||
}
|
||||
|
||||
// MARK: - Syncing our saved user relay list with the active `RelayPool`
|
||||
|
||||
/// Loads the current user relay list
|
||||
func load() {
|
||||
self.apply(newRelayList: self.relaysToConnectTo())
|
||||
func load() async {
|
||||
await self.apply(newRelayList: self.relaysToConnectTo())
|
||||
}
|
||||
|
||||
/// Loads a new relay list into the active relay pool, making sure it matches the specified relay list.
|
||||
@@ -201,7 +201,8 @@ extension NostrNetworkManager {
|
||||
///
|
||||
/// - This is `private` because syncing the user's saved relay list with the relay pool is `NostrNetworkManager`'s responsibility,
|
||||
/// so we do not want other classes to forcibly load this.
|
||||
private func apply(newRelayList: [RelayPool.RelayDescriptor]) {
|
||||
@MainActor
|
||||
private func apply(newRelayList: [RelayPool.RelayDescriptor]) async {
|
||||
let currentRelayList = self.pool.relays.map({ $0.descriptor })
|
||||
|
||||
var changed = false
|
||||
@@ -221,28 +222,39 @@ extension NostrNetworkManager {
|
||||
let relaysToRemove = currentRelayURLs.subtracting(newRelayURLs)
|
||||
let relaysToAdd = newRelayURLs.subtracting(currentRelayURLs)
|
||||
|
||||
// Remove relays not in the new list
|
||||
relaysToRemove.forEach { url in
|
||||
pool.remove_relay(url)
|
||||
changed = true
|
||||
}
|
||||
await withTaskGroup { taskGroup in
|
||||
// Remove relays not in the new list
|
||||
relaysToRemove.forEach { url in
|
||||
taskGroup.addTask(operation: { await self.pool.remove_relay(url) })
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Add new relays from the new list
|
||||
relaysToAdd.forEach { url in
|
||||
guard let descriptor = newRelayList.first(where: { $0.url == url }) else { return }
|
||||
add_new_relay(
|
||||
model_cache: delegate.relayModelCache,
|
||||
relay_filters: delegate.relayFilters,
|
||||
pool: pool,
|
||||
descriptor: descriptor,
|
||||
new_relay_filters: new_relay_filters,
|
||||
logging_enabled: delegate.developerMode
|
||||
)
|
||||
changed = true
|
||||
// Add new relays from the new list
|
||||
relaysToAdd.forEach { url in
|
||||
guard let descriptor = newRelayList.first(where: { $0.url == url }) else { return }
|
||||
taskGroup.addTask(operation: {
|
||||
await add_new_relay(
|
||||
model_cache: self.delegate.relayModelCache,
|
||||
relay_filters: self.delegate.relayFilters,
|
||||
pool: self.pool,
|
||||
descriptor: descriptor,
|
||||
new_relay_filters: new_relay_filters,
|
||||
logging_enabled: self.delegate.developerMode
|
||||
)
|
||||
})
|
||||
changed = true
|
||||
}
|
||||
|
||||
for await value in taskGroup { continue }
|
||||
}
|
||||
|
||||
// Always tell RelayPool to connect whether or not we are already connected.
|
||||
// This is because:
|
||||
// 1. Internally it won't redo the connection because of internal checks
|
||||
// 2. Even if the relay list has not changed, relays may have been disconnected from app lifecycle or other events
|
||||
await pool.connect()
|
||||
|
||||
if changed {
|
||||
pool.connect()
|
||||
notify(.relays_changed)
|
||||
}
|
||||
}
|
||||
@@ -280,8 +292,8 @@ fileprivate extension NIP65.RelayList {
|
||||
/// - descriptor: The description of the relay being added
|
||||
/// - new_relay_filters: Whether to insert new relay filters
|
||||
/// - logging_enabled: Whether logging is enabled
|
||||
fileprivate func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayPool.RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) {
|
||||
try? pool.add_relay(descriptor)
|
||||
fileprivate func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayPool.RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) async {
|
||||
try? await pool.add_relay(descriptor)
|
||||
let url = descriptor.url
|
||||
|
||||
let relay_id = url
|
||||
@@ -299,7 +311,7 @@ fileprivate func add_new_relay(model_cache: RelayModelCache, relay_filters: Rela
|
||||
model_cache.insert(model: model)
|
||||
|
||||
if logging_enabled {
|
||||
pool.setLog(model.log, for: relay_id)
|
||||
Task { await pool.setLog(model.log, for: relay_id) }
|
||||
}
|
||||
|
||||
// if this is the first time adding filters, we should filter non-paid relays
|
||||
|
||||
@@ -49,22 +49,22 @@ protocol TagItemConvertible {
|
||||
|
||||
struct QuoteId: IdType, TagKey, TagConvertible {
|
||||
let id: Data
|
||||
|
||||
|
||||
init(_ data: Data) {
|
||||
self.id = data
|
||||
}
|
||||
|
||||
|
||||
/// The note id being quoted
|
||||
var note_id: NoteId {
|
||||
NoteId(self.id)
|
||||
}
|
||||
|
||||
var keychar: AsciiCharacter { "q" }
|
||||
|
||||
|
||||
var tag: [String] {
|
||||
["q", self.hex()]
|
||||
}
|
||||
|
||||
|
||||
static func from_tag(tag: TagSequence) -> QuoteId? {
|
||||
var i = tag.makeIterator()
|
||||
|
||||
@@ -80,6 +80,52 @@ struct QuoteId: IdType, TagKey, TagConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
/// A quote reference with optional relay hints for fetching.
|
||||
///
|
||||
/// Per NIP-10/NIP-18, `q` tags include a relay URL at position 2 where the quoted
|
||||
/// event can be found.
|
||||
///
|
||||
/// Note: The NIPs allow `q` tags to contain either event IDs (hex) or event addresses
|
||||
/// (`<kind>:<pubkey>:<d>` for replaceable events). This implementation currently only
|
||||
/// supports hex event IDs; quotes of addressable events are not yet handled.
|
||||
struct QuoteRef: TagConvertible {
|
||||
let quote_id: QuoteId
|
||||
let relayHints: [RelayURL]
|
||||
|
||||
/// The note ID being quoted
|
||||
var note_id: NoteId {
|
||||
quote_id.note_id
|
||||
}
|
||||
|
||||
var tag: [String] {
|
||||
var tagBuilder = ["q", quote_id.hex()]
|
||||
if let relay = relayHints.first {
|
||||
tagBuilder.append(relay.absoluteString)
|
||||
}
|
||||
return tagBuilder
|
||||
}
|
||||
|
||||
/// Parses a `q` tag into a QuoteRef, preserving relay hints from position 2.
|
||||
///
|
||||
/// Only parses `q` tags containing hex event IDs. Tags with event addresses
|
||||
/// (`<kind>:<pubkey>:<d>`) are not currently supported and will return nil.
|
||||
static func from_tag(tag: TagSequence) -> QuoteRef? {
|
||||
var i = tag.makeIterator()
|
||||
|
||||
guard tag.count >= 2,
|
||||
let t0 = i.next(),
|
||||
let key = t0.single_char,
|
||||
key == "q",
|
||||
let t1 = i.next(),
|
||||
let data = t1.id()
|
||||
else { return nil }
|
||||
|
||||
let quoteId = QuoteId(data)
|
||||
let relayHints = tag.relayHints
|
||||
return QuoteRef(quote_id: quoteId, relayHints: relayHints)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct Privkey: IdType {
|
||||
let id: Data
|
||||
|
||||
@@ -122,6 +122,11 @@ struct MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a tag sequence into a MentionRef, preserving relay hints.
|
||||
///
|
||||
/// Per NIP-01/NIP-10, position 2 in `e`, `p`, and `a` tags contains an optional relay URL.
|
||||
/// When present, this method creates `nevent`/`nprofile`/`naddr` variants that preserve
|
||||
/// the relay hint for later use in event fetching.
|
||||
static func from_tag(tag: TagSequence) -> MentionRef? {
|
||||
guard tag.count >= 2 else { return nil }
|
||||
|
||||
@@ -135,23 +140,35 @@ struct MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
|
||||
return nil
|
||||
}
|
||||
|
||||
let relayHints = tag.relayHints
|
||||
|
||||
switch mention_type {
|
||||
case .p:
|
||||
guard let data = element.id() else { return nil }
|
||||
return .init(nip19: .npub(Pubkey(data)))
|
||||
let pubkey = Pubkey(data)
|
||||
if relayHints.isEmpty {
|
||||
return .init(nip19: .npub(pubkey))
|
||||
}
|
||||
return .init(nip19: .nprofile(NProfile(author: pubkey, relays: relayHints)))
|
||||
case .e:
|
||||
guard let data = element.id() else { return nil }
|
||||
return .init(nip19: .note(NoteId(data)))
|
||||
let noteId = NoteId(data)
|
||||
if relayHints.isEmpty {
|
||||
return .init(nip19: .note(noteId))
|
||||
}
|
||||
#if DEBUG
|
||||
print("[relay-hints] e tag: Found \(relayHints.count) hint(s) for \(noteId.hex().prefix(8))...: \(relayHints.map { $0.absoluteString })")
|
||||
#endif
|
||||
return .init(nip19: .nevent(NEvent(noteid: noteId, relays: relayHints)))
|
||||
case .a:
|
||||
let str = element.string()
|
||||
let data = str.split(separator: ":")
|
||||
if(data.count != 3) { return nil }
|
||||
|
||||
guard data.count == 3 else { return nil }
|
||||
guard let pubkey = Pubkey(hex: String(data[1])) else { return nil }
|
||||
guard let kind = UInt32(data[0]) else { return nil }
|
||||
|
||||
return .init(nip19: .naddr(NAddr(identifier: String(data[2]), author: pubkey, relays: [], kind: kind)))
|
||||
case .r: return .init(nip19: .nrelay(element.string()))
|
||||
return .init(nip19: .naddr(NAddr(identifier: String(data[2]), author: pubkey, relays: relayHints, kind: kind)))
|
||||
case .r:
|
||||
return .init(nip19: .nrelay(element.string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,16 +311,19 @@ func format_msats(_ msat: Int64, locale: Locale = Locale.current) -> String {
|
||||
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats)
|
||||
}
|
||||
|
||||
func convert_invoice_description(b11: ndb_invoice) -> InvoiceDescription? {
|
||||
/// Extracts the description from a BOLT11 invoice.
|
||||
/// Returns empty description if invoice has neither description nor description_hash,
|
||||
/// as both fields are optional per BOLT11 spec.
|
||||
func convert_invoice_description(b11: ndb_invoice) -> InvoiceDescription {
|
||||
if let desc = b11.description {
|
||||
return .description(String(cString: desc))
|
||||
}
|
||||
|
||||
|
||||
if var deschash = maybe_pointee(b11.description_hash) {
|
||||
return .description_hash(Data(bytes: &deschash, count: 32))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
return .description("")
|
||||
}
|
||||
|
||||
func find_tag_ref(type: String, id: String, tags: [[String]]) -> Int? {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,50 @@ enum ValidationResult: Decodable {
|
||||
case bad_sig
|
||||
}
|
||||
|
||||
/// Represents metadata from a NIP-89 client tag (`["client", name, address?, relay?]`).
|
||||
/// Used to identify which application published a nostr event.
|
||||
struct ClientTagMetadata: Equatable {
|
||||
/// The client application name (e.g., "Damus").
|
||||
let name: String
|
||||
/// Optional NIP-89 handler address for the client.
|
||||
let handlerAddress: String?
|
||||
/// Optional relay hint where the handler can be found.
|
||||
let relayHint: String?
|
||||
|
||||
init(name: String, handlerAddress: String? = nil, relayHint: String? = nil) {
|
||||
self.name = name
|
||||
self.handlerAddress = handlerAddress
|
||||
self.relayHint = relayHint
|
||||
}
|
||||
|
||||
/// Parses client tag metadata from tag components array.
|
||||
/// - Parameter tagComponents: Array where index 0 is "client", index 1 is name, etc.
|
||||
/// - Returns: nil if the tag is not a valid client tag.
|
||||
init?(tagComponents: [String]) {
|
||||
guard tagComponents.first == "client", let clientName = tagComponents[safe: 1], !clientName.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
self.name = clientName
|
||||
self.handlerAddress = tagComponents[safe: 2]
|
||||
self.relayHint = tagComponents[safe: 3]
|
||||
}
|
||||
|
||||
/// Converts this metadata back into a tag array suitable for inclusion in an event.
|
||||
var tagValues: [String] {
|
||||
var components = ["client", name]
|
||||
if let handlerAddress, !handlerAddress.isEmpty {
|
||||
components.append(handlerAddress)
|
||||
if let relayHint, !relayHint.isEmpty {
|
||||
components.append(relayHint)
|
||||
}
|
||||
}
|
||||
return components
|
||||
}
|
||||
|
||||
/// The default Damus client tag.
|
||||
static let damus = ClientTagMetadata(name: "Damus")
|
||||
}
|
||||
|
||||
/*
|
||||
class NostrEventOld: Codable, Identifiable, CustomStringConvertible, Equatable, Hashable, Comparable {
|
||||
// TODO: memory mapped db events
|
||||
@@ -321,7 +365,7 @@ func sign_id(privkey: String, id: String) -> String {
|
||||
|
||||
// Extra params for custom signing
|
||||
|
||||
var aux_rand = random_bytes(count: 64).bytes
|
||||
var aux_rand = random_bytes(count: 64).byteArray
|
||||
var digest = try! id.bytes
|
||||
|
||||
// API allows for signing variable length messages
|
||||
@@ -331,11 +375,11 @@ func sign_id(privkey: String, id: String) -> String {
|
||||
}
|
||||
|
||||
func decode_nostr_event(txt: String) -> NostrResponse? {
|
||||
return NostrResponse.owned_from_json(json: txt)
|
||||
return NostrResponse.decode(from: txt)
|
||||
}
|
||||
|
||||
func decode_and_verify_nostr_response(txt: String) -> NostrResponse? {
|
||||
guard let response = NostrResponse.owned_from_json(json: txt) else { return nil }
|
||||
guard let response = NostrResponse.decode(from: txt) else { return nil }
|
||||
guard verify_nostr_response(response: response) == true else { return nil }
|
||||
return response
|
||||
}
|
||||
@@ -352,6 +396,10 @@ func verify_nostr_response(response: borrowing NostrResponse) -> Bool {
|
||||
return true
|
||||
case .auth(_):
|
||||
return true
|
||||
case .negentropyError(subscriptionId: let subscriptionId, reasonCodeString: let reasonCodeString):
|
||||
return true
|
||||
case .negentropyMessage(subscriptionId: let subscriptionId, hexEncodedData: let hexEncodedData):
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,6 +560,15 @@ func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String =
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 7, tags: tags)
|
||||
}
|
||||
|
||||
func make_live_chat_event(keypair: FullKeypair, content: String, root: String, dtag: String, relayURL: RelayURL?) -> NostrEvent? {
|
||||
//var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag })
|
||||
var aTagBuilder = ["a", "30311:\(root):\(dtag)"]
|
||||
|
||||
var tags: [[String]] = [aTagBuilder]
|
||||
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 1311, tags: tags)
|
||||
}
|
||||
|
||||
func generate_private_keypair(our_privkey: Privkey, id: NoteId, created_at: UInt32) -> FullKeypair? {
|
||||
let to_hash = our_privkey.hex() + id.hex() + String(created_at)
|
||||
guard let dat = to_hash.data(using: .utf8) else {
|
||||
@@ -786,57 +843,116 @@ func validate_event(ev: NostrEvent) -> ValidationResult {
|
||||
let ctx = secp256k1.Context.raw
|
||||
var xonly_pubkey = secp256k1_xonly_pubkey.init()
|
||||
|
||||
var ev_pubkey = ev.pubkey.id.bytes
|
||||
var ev_pubkey = ev.pubkey.id.byteArray
|
||||
|
||||
var ok = secp256k1_xonly_pubkey_parse(ctx, &xonly_pubkey, &ev_pubkey) != 0
|
||||
if !ok {
|
||||
return .bad_sig
|
||||
}
|
||||
|
||||
var sig = ev.sig.data.bytes
|
||||
var idbytes = id.id.bytes
|
||||
var sig = ev.sig.data.byteArray
|
||||
var idbytes = id.id.byteArray
|
||||
|
||||
ok = secp256k1_schnorrsig_verify(ctx, &sig, &idbytes, 32, &xonly_pubkey) > 0
|
||||
return ok ? .ok : .bad_sig
|
||||
}
|
||||
|
||||
func first_eref_mention(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> Mention<NoteId>? {
|
||||
guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else { return nil }
|
||||
|
||||
return blockGroup.forEachBlock({ index, block in
|
||||
switch block {
|
||||
case .mention(let mention):
|
||||
guard let mention = MentionRef(block: mention) else { return .loopContinue }
|
||||
switch mention.nip19 {
|
||||
case .note(let noteId):
|
||||
return .loopReturn(Mention<NoteId>.note(noteId, index: index))
|
||||
case .nevent(let nEvent):
|
||||
return .loopReturn(Mention<NoteId>.note(nEvent.noteid, index: index))
|
||||
return try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
|
||||
return blockGroup.forEachBlock({ index, block in
|
||||
switch block {
|
||||
case .mention(let mention):
|
||||
guard let mention = MentionRef(block: mention) else { return .loopContinue }
|
||||
switch mention.nip19 {
|
||||
case .note(let noteId):
|
||||
return .loopReturn(Mention<NoteId>.note(noteId, index: index))
|
||||
case .nevent(let nEvent):
|
||||
return .loopReturn(Mention<NoteId>.note(nEvent.noteid, index: index))
|
||||
default:
|
||||
return .loopContinue
|
||||
}
|
||||
default:
|
||||
return .loopContinue
|
||||
}
|
||||
default:
|
||||
return .loopContinue
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func separate_invoices(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [Invoice]? {
|
||||
guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else {
|
||||
/// Represents a note mention with optional relay hints for fetching.
|
||||
struct NoteMentionWithHints {
|
||||
let noteId: NoteId
|
||||
let relayHints: [RelayURL]
|
||||
let index: Int?
|
||||
}
|
||||
|
||||
/// Finds the first event reference mention in a note's content, preserving relay hints.
|
||||
///
|
||||
/// Per NIP-19, `nevent` bech32 entities may include relay hints. This function extracts
|
||||
/// those hints so they can be used when fetching the referenced event.
|
||||
///
|
||||
/// If no inline mention is found in the content, falls back to checking `q` tags (NIP-10/NIP-18)
|
||||
/// to support quote reposts that don't embed the quoted note inline.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - ndb: The nostrdb instance.
|
||||
/// - ev: The event to search.
|
||||
/// - keypair: The keypair for decryption if needed.
|
||||
/// - Returns: A `NoteMentionWithHints` containing the note ID and relay hints, or nil if not found.
|
||||
func first_eref_mention_with_hints(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> NoteMentionWithHints? {
|
||||
// First check content blocks for inline mentions
|
||||
let inlineMention: NoteMentionWithHints? = try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
|
||||
return blockGroup.forEachBlock({ index, block in
|
||||
switch block {
|
||||
case .mention(let mention):
|
||||
guard let mentionRef = MentionRef(block: mention) else { return .loopContinue }
|
||||
switch mentionRef.nip19 {
|
||||
case .note(let noteId):
|
||||
return .loopReturn(NoteMentionWithHints(noteId: noteId, relayHints: [], index: index))
|
||||
case .nevent(let nEvent):
|
||||
#if DEBUG
|
||||
if !nEvent.relays.isEmpty {
|
||||
print("[relay-hints] Inline nevent: Found \(nEvent.relays.count) hint(s) for \(nEvent.noteid.hex().prefix(8))...: \(nEvent.relays.map { $0.absoluteString })")
|
||||
}
|
||||
#endif
|
||||
return .loopReturn(NoteMentionWithHints(noteId: nEvent.noteid, relayHints: nEvent.relays, index: index))
|
||||
default:
|
||||
return .loopContinue
|
||||
}
|
||||
default:
|
||||
return .loopContinue
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if let inlineMention {
|
||||
return inlineMention
|
||||
}
|
||||
|
||||
// Fall back to q tags (NIP-10/NIP-18 quote reposts)
|
||||
guard let quoteRef = ev.referenced_quote_refs.first else {
|
||||
return nil
|
||||
}
|
||||
let invoiceBlocks: [Invoice] = (try? blockGroup.reduce(initialResult: [Invoice](), { index, invoices, block in
|
||||
switch block {
|
||||
case .invoice(let invoice):
|
||||
if let invoice = invoice.as_invoice() {
|
||||
return .loopReturn(invoices + [invoice])
|
||||
#if DEBUG
|
||||
if !quoteRef.relayHints.isEmpty {
|
||||
print("[relay-hints] Quote: Found q tag with \(quoteRef.relayHints.count) hint(s) for \(quoteRef.note_id.hex().prefix(8))...: \(quoteRef.relayHints.map { $0.absoluteString })")
|
||||
}
|
||||
#endif
|
||||
return NoteMentionWithHints(noteId: quoteRef.note_id, relayHints: quoteRef.relayHints, index: nil)
|
||||
}
|
||||
|
||||
func separate_invoices(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [Invoice]? {
|
||||
return try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
|
||||
let invoiceBlocks: [Invoice] = (try? blockGroup.reduce(initialResult: [Invoice](), { index, invoices, block in
|
||||
switch block {
|
||||
case .invoice(let invoice):
|
||||
return .loopReturn(invoices + [invoice.as_invoice()])
|
||||
default:
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
return .loopContinue
|
||||
})) ?? []
|
||||
return invoiceBlocks.isEmpty ? nil : invoiceBlocks
|
||||
return .loopContinue
|
||||
})) ?? []
|
||||
return invoiceBlocks.isEmpty ? nil : invoiceBlocks
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -872,4 +988,31 @@ extension NostrEvent {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
var debugDescription: String {
|
||||
var output = "🔍 NostrEvent Debug Info\n"
|
||||
output += "═══════════════════════════\n"
|
||||
output += "📝 ID: \(id)\n"
|
||||
output += "👤 Pubkey: \(pubkey)\n"
|
||||
output += "📅 Created: \(Date(timeIntervalSince1970: TimeInterval(created_at))) (\(created_at))\n"
|
||||
output += "🏷️ Kind: \(kind) (\(String(describing: known_kind))\n"
|
||||
output += "✍️ Signature: \(sig)\n"
|
||||
output += "📄 Content (\(content.count) chars):\n"
|
||||
output += " \"\(content.prefix(100))\(content.count > 100 ? "..." : "")\"\n"
|
||||
|
||||
output += "\n🏷️ Tags (\(tags.count) total):\n"
|
||||
for (index, tag) in tags.enumerated() {
|
||||
output += " [\(index)]: ["
|
||||
for (tagIndex, tagElem) in tag.enumerated() {
|
||||
if tagIndex > 0 { output += ", " }
|
||||
output += "\"\(tagElem.string())\""
|
||||
}
|
||||
output += "]\n"
|
||||
}
|
||||
|
||||
output += "═══════════════════════════\n"
|
||||
return output
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ enum NostrKind: UInt32, Codable {
|
||||
case boost = 6
|
||||
case like = 7
|
||||
case chat = 42
|
||||
case voice_message = 1222
|
||||
case live_chat = 1311
|
||||
case mute_list = 10000
|
||||
case relay_list = 10002
|
||||
case interest_list = 10015
|
||||
@@ -31,6 +31,8 @@ enum NostrKind: UInt32, Codable {
|
||||
case nwc_request = 23194
|
||||
case nwc_response = 23195
|
||||
case http_auth = 27235
|
||||
case live = 30311
|
||||
case status = 30315
|
||||
case contact_card = 30_382
|
||||
case follow_list = 39089
|
||||
}
|
||||
|
||||
@@ -48,6 +48,12 @@ enum NostrRequest {
|
||||
case event(NostrEvent)
|
||||
/// Authenticate with the relay
|
||||
case auth(NostrEvent)
|
||||
/// Negentropy open
|
||||
case negentropyOpen(subscriptionId: String, filter: NostrFilter, initialMessage: [UInt8])
|
||||
/// Negentropy message
|
||||
case negentropyMessage(subscriptionId: String, message: [UInt8])
|
||||
/// Close negentropy communication
|
||||
case negentropyClose(subscriptionId: String)
|
||||
|
||||
/// Whether this request is meant to write data to a relay
|
||||
var is_write: Bool {
|
||||
@@ -60,6 +66,12 @@ enum NostrRequest {
|
||||
return true
|
||||
case .auth:
|
||||
return false
|
||||
case .negentropyOpen:
|
||||
return false
|
||||
case .negentropyMessage:
|
||||
return false
|
||||
case .negentropyClose:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,3 +81,85 @@ enum NostrRequest {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func make_nostr_req(_ req: NostrRequest) -> String? {
|
||||
switch req {
|
||||
case .subscribe(let sub):
|
||||
return make_nostr_subscription_req(sub.filters, sub_id: sub.sub_id)
|
||||
case .unsubscribe(let sub_id):
|
||||
return make_nostr_unsubscribe_req(sub_id)
|
||||
case .event(let ev):
|
||||
return make_nostr_push_event(ev: ev)
|
||||
case .auth(let ev):
|
||||
return make_nostr_auth_event(ev: ev)
|
||||
case .negentropyOpen(subscriptionId: let subscriptionId, filter: let filter, initialMessage: let initialMessage):
|
||||
return make_nostr_negentropy_open_req(subscriptionId: subscriptionId, filter: filter, initialMessage: initialMessage)
|
||||
case .negentropyMessage(subscriptionId: let subscriptionId, message: let message):
|
||||
return make_nostr_negentropy_message_req(subscriptionId: subscriptionId, message: message)
|
||||
case .negentropyClose(subscriptionId: let subscriptionId):
|
||||
return make_nostr_negentropy_close_req(subscriptionId: subscriptionId)
|
||||
}
|
||||
}
|
||||
|
||||
func make_nostr_auth_event(ev: NostrEvent) -> String? {
|
||||
guard let event = encode_json(ev) else {
|
||||
return nil
|
||||
}
|
||||
let encoded = "[\"AUTH\",\(event)]"
|
||||
print(encoded)
|
||||
return encoded
|
||||
}
|
||||
|
||||
func make_nostr_push_event(ev: NostrEvent) -> String? {
|
||||
guard let event = encode_json(ev) else {
|
||||
return nil
|
||||
}
|
||||
let encoded = "[\"EVENT\",\(event)]"
|
||||
print(encoded)
|
||||
return encoded
|
||||
}
|
||||
|
||||
func make_nostr_unsubscribe_req(_ sub_id: String) -> String? {
|
||||
"[\"CLOSE\",\"\(sub_id)\"]"
|
||||
}
|
||||
|
||||
func make_nostr_subscription_req(_ filters: [NostrFilter], sub_id: String) -> String? {
|
||||
let encoder = JSONEncoder()
|
||||
var req = "[\"REQ\",\"\(sub_id)\""
|
||||
for filter in filters {
|
||||
req += ","
|
||||
guard let filter_json = try? encoder.encode(filter) else {
|
||||
return nil
|
||||
}
|
||||
let filter_json_str = String(decoding: filter_json, as: UTF8.self)
|
||||
req += filter_json_str
|
||||
}
|
||||
req += "]"
|
||||
return req
|
||||
}
|
||||
|
||||
func make_nostr_negentropy_open_req(subscriptionId: String, filter: NostrFilter, initialMessage: [UInt8]) -> String? {
|
||||
let encoder = JSONEncoder()
|
||||
let messageData = Data(initialMessage)
|
||||
let messageHex = hex_encode(messageData)
|
||||
var req = "[\"NEG-OPEN\",\"\(subscriptionId)\","
|
||||
guard let filter_json = try? encoder.encode(filter) else {
|
||||
return nil
|
||||
}
|
||||
let filter_json_str = String(decoding: filter_json, as: UTF8.self)
|
||||
req += filter_json_str
|
||||
req += ",\"\(messageHex)\""
|
||||
req += "]"
|
||||
return req
|
||||
}
|
||||
|
||||
func make_nostr_negentropy_message_req(subscriptionId: String, message: [UInt8]) -> String? {
|
||||
let messageData = Data(message)
|
||||
let messageHex = hex_encode(messageData)
|
||||
return "[\"NEG-MSG\",\"\(subscriptionId)\",\"\(messageHex)\"]"
|
||||
}
|
||||
|
||||
func make_nostr_negentropy_close_req(subscriptionId: String) -> String? {
|
||||
return "[\"NEG-CLOSE\",\"\(subscriptionId)\"]"
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,23 @@ enum MaybeResponse {
|
||||
case ok(NostrResponse)
|
||||
}
|
||||
|
||||
enum NegentropyResponse {
|
||||
/// Negentropy error
|
||||
case error(subscriptionId: String, reasonCodeString: String)
|
||||
/// Negentropy message
|
||||
case message(subscriptionId: String, data: [UInt8])
|
||||
/// Invalid negentropy message
|
||||
case invalidResponse(subscriptionId: String)
|
||||
|
||||
var subscriptionId: String {
|
||||
switch self {
|
||||
case .error(subscriptionId: let subscriptionId, reasonCodeString: let reasonCodeString): subscriptionId
|
||||
case .message(subscriptionId: let subscriptionId, data: let data): subscriptionId
|
||||
case .invalidResponse(subscriptionId: let subscriptionId): subscriptionId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NostrResponse {
|
||||
case event(String, NostrEvent)
|
||||
case notice(String)
|
||||
@@ -27,6 +44,10 @@ enum NostrResponse {
|
||||
///
|
||||
/// The associated type of this case is the challenge string sent by the server.
|
||||
case auth(String)
|
||||
/// Negentropy error
|
||||
case negentropyError(subscriptionId: String, reasonCodeString: String)
|
||||
/// Negentropy message
|
||||
case negentropyMessage(subscriptionId: String, hexEncodedData: String)
|
||||
|
||||
var subid: String? {
|
||||
switch self {
|
||||
@@ -36,14 +57,84 @@ enum NostrResponse {
|
||||
return sub_id
|
||||
case .eose(let sub_id):
|
||||
return sub_id
|
||||
case .notice:
|
||||
case .notice(_):
|
||||
return nil
|
||||
case .auth(let challenge_string):
|
||||
return challenge_string
|
||||
case .negentropyError(subscriptionId: let subscriptionId, reasonCodeString: _):
|
||||
return subscriptionId
|
||||
case .negentropyMessage(subscriptionId: let subscriptionId, hexEncodedData: _):
|
||||
return subscriptionId
|
||||
}
|
||||
}
|
||||
|
||||
var negentropyResponse: NegentropyResponse? {
|
||||
switch self {
|
||||
case .event(_, _): return nil
|
||||
case .notice(_): return nil
|
||||
case .eose(_): return nil
|
||||
case .ok(_): return nil
|
||||
case .auth(_): return nil
|
||||
case .negentropyError(subscriptionId: let subscriptionId, reasonCodeString: let reasonCodeString):
|
||||
return .error(subscriptionId: subscriptionId, reasonCodeString: reasonCodeString)
|
||||
case .negentropyMessage(subscriptionId: let subscriptionId, hexEncodedData: let hexData):
|
||||
if let bytes = hex_decode(hexData) {
|
||||
return .message(subscriptionId: subscriptionId, data: bytes)
|
||||
}
|
||||
return .invalidResponse(subscriptionId: subscriptionId)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a Nostr response from JSON using idiomatic Swift parsing
|
||||
/// Supports NEG-MSG and NEG-ERR formats, falling back to C parsing for other message types
|
||||
static func decode(from json: String) -> NostrResponse? {
|
||||
// Try Swift-based parsing first for negentropy messages
|
||||
if let response = try? decodeNegentropyMessage(from: json) {
|
||||
return response
|
||||
}
|
||||
|
||||
// Fall back to C-based parsing for standard Nostr messages
|
||||
return owned_from_json(json: json)
|
||||
}
|
||||
|
||||
/// Decode negentropy messages using idiomatic Swift
|
||||
private static func decodeNegentropyMessage(from json: String) throws -> NostrResponse? {
|
||||
guard let jsonData = json.data(using: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let jsonArray = try JSONSerialization.jsonObject(with: jsonData) as? [Any],
|
||||
jsonArray.count >= 2,
|
||||
let messageType = jsonArray[0] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch messageType {
|
||||
case "NEG-MSG":
|
||||
// Format: ["NEG-MSG", "subscription-id", "hex-encoded-data"]
|
||||
guard jsonArray.count == 3,
|
||||
let subscriptionId = jsonArray[1] as? String,
|
||||
let hexData = jsonArray[2] as? String else {
|
||||
return nil
|
||||
}
|
||||
return .negentropyMessage(subscriptionId: subscriptionId, hexEncodedData: hexData)
|
||||
|
||||
case "NEG-ERR":
|
||||
// Format: ["NEG-ERR", "subscription-id", "reason-code"]
|
||||
guard jsonArray.count == 3,
|
||||
let subscriptionId = jsonArray[1] as? String,
|
||||
let reasonCode = jsonArray[2] as? String else {
|
||||
return nil
|
||||
}
|
||||
return .negentropyError(subscriptionId: subscriptionId, reasonCodeString: reasonCode)
|
||||
|
||||
default:
|
||||
// Not a negentropy message
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func owned_from_json(json: String) -> NostrResponse? {
|
||||
private static func owned_from_json(json: String) -> NostrResponse? {
|
||||
return json.withCString{ cstr in
|
||||
let bufsize: Int = max(Int(Double(json.utf8.count) * 8.0), Int(getpagesize()))
|
||||
let data = malloc(bufsize)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// ProfileObserver.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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()
|
||||
}
|
||||
}
|
||||
@@ -74,31 +74,45 @@ class Profiles {
|
||||
profile_data(pubkey).zapper
|
||||
}
|
||||
|
||||
func lookup_with_timestamp(_ pubkey: Pubkey) -> NdbTxn<ProfileRecord?>? {
|
||||
ndb.lookup_profile(pubkey)
|
||||
func lookup_with_timestamp<T>(_ pubkey: Pubkey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T {
|
||||
return try ndb.lookup_profile(pubkey, borrow: lendingFunction)
|
||||
}
|
||||
|
||||
func lookup_lnurl(_ pubkey: Pubkey) throws -> String? {
|
||||
return try lookup_with_timestamp(pubkey, borrow: { pr in
|
||||
switch pr {
|
||||
case .some(let pr): return pr.lnurl
|
||||
case .none: return nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func lookup_by_key(key: ProfileKey) -> NdbTxn<ProfileRecord?>? {
|
||||
ndb.lookup_profile_by_key(key: key)
|
||||
func lookup_by_key<T>(key: ProfileKey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T {
|
||||
return try ndb.lookup_profile_by_key(key: key, borrow: lendingFunction)
|
||||
}
|
||||
|
||||
func search<Y>(_ query: String, limit: Int, txn: NdbTxn<Y>) -> [Pubkey] {
|
||||
ndb.search_profile(query, limit: limit, txn: txn)
|
||||
func search(_ query: String, limit: Int) throws -> [Pubkey] {
|
||||
try ndb.search_profile(query, limit: limit)
|
||||
}
|
||||
|
||||
func lookup(id: Pubkey, txn_name: String? = nil) -> NdbTxn<Profile?>? {
|
||||
guard let txn = ndb.lookup_profile(id, txn_name: txn_name) else {
|
||||
return nil
|
||||
}
|
||||
return txn.map({ pr in pr?.profile })
|
||||
func lookup(id: Pubkey) throws -> Profile? {
|
||||
return try ndb.lookup_profile(id, borrow: { pr in
|
||||
switch pr {
|
||||
case .none:
|
||||
return nil
|
||||
case .some(let profileRecord):
|
||||
// This will clone the value to make it owned and safe to return.
|
||||
return profileRecord.profile
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func lookup_key_by_pubkey(_ pubkey: Pubkey) -> ProfileKey? {
|
||||
ndb.lookup_profile_key(pubkey)
|
||||
func lookup_key_by_pubkey(_ pubkey: Pubkey) throws -> ProfileKey? {
|
||||
try ndb.lookup_profile_key(pubkey)
|
||||
}
|
||||
|
||||
func has_fresh_profile<Y>(id: Pubkey, txn: NdbTxn<Y>) -> Bool {
|
||||
guard let fetched_at = ndb.read_profile_last_fetched(txn: txn, pubkey: id)
|
||||
func has_fresh_profile(id: Pubkey) throws -> Bool {
|
||||
guard let fetched_at = try ndb.read_profile_last_fetched(pubkey: id)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -139,6 +139,11 @@ struct RelayMetadata: Codable {
|
||||
var is_paid: Bool {
|
||||
return limitation?.payment_required ?? false
|
||||
}
|
||||
|
||||
var supports_negentropy: Bool? {
|
||||
// Supports negentropy if NIP-77 is in the list of supported NIPs
|
||||
supported_nips?.contains(where: { $0 == 77 })
|
||||
}
|
||||
}
|
||||
|
||||
extension RelayPool {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Negentropy
|
||||
|
||||
enum NostrConnectionEvent {
|
||||
/// Other non-message websocket events
|
||||
@@ -35,6 +36,15 @@ enum NostrConnectionEvent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var subId: String? {
|
||||
switch self {
|
||||
case .ws_connection_event(_):
|
||||
return nil
|
||||
case .nostr_event(let event):
|
||||
return event.subid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class RelayConnection: ObservableObject {
|
||||
@@ -48,18 +58,55 @@ final class RelayConnection: ObservableObject {
|
||||
private lazy var socket = WebSocket(relay_url.url)
|
||||
private var subscriptionToken: AnyCancellable?
|
||||
|
||||
private var handleEvent: (NostrConnectionEvent) -> ()
|
||||
private var handleEvent: (NostrConnectionEvent) async -> ()
|
||||
private var processEvent: (WebSocketEvent) -> ()
|
||||
private let relay_url: RelayURL
|
||||
var log: RelayLog?
|
||||
|
||||
/// The queue of WebSocket events to be processed
|
||||
/// We need this queue to ensure events are processed and sent to RelayPool in the exact order in which they arrive.
|
||||
/// See `processEventsTask()` for more information
|
||||
var wsEventQueue: QueueableNotify<WebSocketEvent>
|
||||
/// The task which will process WebSocket events in the order in which we receive them from the wire
|
||||
var wsEventProcessTask: Task<Void, any Error>?
|
||||
|
||||
@RelayPoolActor // Isolate this to a specific actor to avoid thread-satefy issues.
|
||||
var negentropyStreams: [String: AsyncStream<NegentropyResponse>.Continuation] = [:]
|
||||
|
||||
init(url: RelayURL,
|
||||
handleEvent: @escaping (NostrConnectionEvent) -> (),
|
||||
handleEvent: @escaping (NostrConnectionEvent) async -> (),
|
||||
processUnverifiedWSEvent: @escaping (WebSocketEvent) -> ())
|
||||
{
|
||||
self.relay_url = url
|
||||
self.handleEvent = handleEvent
|
||||
self.processEvent = processUnverifiedWSEvent
|
||||
self.wsEventQueue = .init(maxQueueItems: 1000)
|
||||
self.wsEventProcessTask = nil
|
||||
self.wsEventProcessTask = Task {
|
||||
try await self.processEventsTask()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.wsEventProcessTask?.cancel()
|
||||
}
|
||||
|
||||
/// The task that will stream the queue of WebSocket events to be processed
|
||||
/// We need this in order to ensure events are processed and sent to RelayPool in the exact order in which they arrive.
|
||||
///
|
||||
/// We need this (or some equivalent syncing mechanism) because without it, two WebSocket events can be processed concurrently,
|
||||
/// and sometimes sent in the wrong order due to difference in processing timing.
|
||||
///
|
||||
/// For example, streaming a filter that yields 1 event can cause the EOSE signal to arrive in RelayPool before the event, simply because the event
|
||||
/// takes longer to process compared to the EOSE signal.
|
||||
///
|
||||
/// To prevent this, we send raw WebSocket events to this queue BEFORE any processing (to ensure equal timing),
|
||||
/// and then process the queue in the order in which they appear
|
||||
func processEventsTask() async throws {
|
||||
for await item in await self.wsEventQueue.stream {
|
||||
try Task.checkCancellation()
|
||||
await self.receive(event: item)
|
||||
}
|
||||
}
|
||||
|
||||
func ping() {
|
||||
@@ -95,12 +142,12 @@ final class RelayConnection: ObservableObject {
|
||||
.sink { [weak self] completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
self?.receive(event: .error(error))
|
||||
Task { await self?.wsEventQueue.add(item: .error(error)) }
|
||||
case .finished:
|
||||
self?.receive(event: .disconnected(.normalClosure, nil))
|
||||
Task { await self?.wsEventQueue.add(item: .disconnected(.normalClosure, nil)) }
|
||||
}
|
||||
} receiveValue: { [weak self] event in
|
||||
self?.receive(event: event)
|
||||
Task { await self?.wsEventQueue.add(item: event) }
|
||||
}
|
||||
|
||||
socket.connect()
|
||||
@@ -138,7 +185,7 @@ final class RelayConnection: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func receive(event: WebSocketEvent) {
|
||||
private func receive(event: WebSocketEvent) async {
|
||||
assert(!Thread.isMainThread, "This code must not be executed on the main thread")
|
||||
processEvent(event)
|
||||
switch event {
|
||||
@@ -149,7 +196,7 @@ final class RelayConnection: ObservableObject {
|
||||
self.isConnecting = false
|
||||
}
|
||||
case .message(let message):
|
||||
self.receive(message: message)
|
||||
await self.receive(message: message)
|
||||
case .disconnected(let closeCode, let reason):
|
||||
if closeCode != .normalClosure {
|
||||
Log.error("⚠️ Warning: RelayConnection (%d) closed with code: %s", for: .networking, String(describing: closeCode), String(describing: reason))
|
||||
@@ -176,10 +223,8 @@ final class RelayConnection: ObservableObject {
|
||||
self.reconnect_with_backoff()
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
guard let ws_connection_event = NostrConnectionEvent.WSConnectionEvent.from(full_ws_event: event) else { return }
|
||||
self.handleEvent(.ws_connection_event(ws_connection_event))
|
||||
}
|
||||
guard let ws_connection_event = NostrConnectionEvent.WSConnectionEvent.from(full_ws_event: event) else { return }
|
||||
await self.handleEvent(.ws_connection_event(ws_connection_event))
|
||||
|
||||
if let description = event.description {
|
||||
log?.add(description)
|
||||
@@ -213,74 +258,113 @@ final class RelayConnection: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func receive(message: URLSessionWebSocketTask.Message) {
|
||||
private func receive(message: URLSessionWebSocketTask.Message) async {
|
||||
switch message {
|
||||
case .string(let messageString):
|
||||
// NOTE: Once we switch to the local relay model,
|
||||
// we will not need to verify nostr events at this point.
|
||||
if let ev = decode_and_verify_nostr_response(txt: messageString) {
|
||||
DispatchQueue.main.async {
|
||||
self.handleEvent(.nostr_event(ev))
|
||||
await self.handleEvent(.nostr_event(ev))
|
||||
if let negentropyResponse = ev.negentropyResponse {
|
||||
await self.negentropyStreams[negentropyResponse.subscriptionId]?.yield(negentropyResponse)
|
||||
}
|
||||
return
|
||||
}
|
||||
print("failed to decode event \(messageString)")
|
||||
print("\(self.relay_url): failed to decode event \(messageString)")
|
||||
case .data(let messageData):
|
||||
if let messageString = String(data: messageData, encoding: .utf8) {
|
||||
receive(message: .string(messageString))
|
||||
await receive(message: .string(messageString))
|
||||
}
|
||||
@unknown default:
|
||||
print("An unexpected URLSessionWebSocketTask.Message was received.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func make_nostr_req(_ req: NostrRequest) -> String? {
|
||||
switch req {
|
||||
case .subscribe(let sub):
|
||||
return make_nostr_subscription_req(sub.filters, sub_id: sub.sub_id)
|
||||
case .unsubscribe(let sub_id):
|
||||
return make_nostr_unsubscribe_req(sub_id)
|
||||
case .event(let ev):
|
||||
return make_nostr_push_event(ev: ev)
|
||||
case .auth(let ev):
|
||||
return make_nostr_auth_event(ev: ev)
|
||||
}
|
||||
}
|
||||
|
||||
func make_nostr_auth_event(ev: NostrEvent) -> String? {
|
||||
guard let event = encode_json(ev) else {
|
||||
return nil
|
||||
}
|
||||
let encoded = "[\"AUTH\",\(event)]"
|
||||
print(encoded)
|
||||
return encoded
|
||||
}
|
||||
|
||||
func make_nostr_push_event(ev: NostrEvent) -> String? {
|
||||
guard let event = encode_json(ev) else {
|
||||
return nil
|
||||
}
|
||||
let encoded = "[\"EVENT\",\(event)]"
|
||||
print(encoded)
|
||||
return encoded
|
||||
}
|
||||
|
||||
func make_nostr_unsubscribe_req(_ sub_id: String) -> String? {
|
||||
"[\"CLOSE\",\"\(sub_id)\"]"
|
||||
}
|
||||
|
||||
func make_nostr_subscription_req(_ filters: [NostrFilter], sub_id: String) -> String? {
|
||||
let encoder = JSONEncoder()
|
||||
var req = "[\"REQ\",\"\(sub_id)\""
|
||||
for filter in filters {
|
||||
req += ","
|
||||
guard let filter_json = try? encoder.encode(filter) else {
|
||||
return nil
|
||||
|
||||
// MARK: - Negentropy logic
|
||||
|
||||
/// Retrieves the IDs of events missing locally compared to the relay using negentropy protocol.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - filter: The Nostr filter to scope the sync
|
||||
/// - negentropyVector: The local storage vector for comparison
|
||||
/// - timeout: Optional timeout for the operation
|
||||
/// - Returns: Array of IDs that the relay has but we don't
|
||||
/// - Throws: NegentropySyncError on failure
|
||||
@RelayPoolActor
|
||||
func getMissingIds(filter: NostrFilter, negentropyVector: NegentropyStorageVector, timeout: Duration?) async throws -> [Id] {
|
||||
if let relayMetadata = try? await fetch_relay_metadata(relay_id: self.relay_url),
|
||||
let supportsNegentropy = relayMetadata.supports_negentropy {
|
||||
if !supportsNegentropy {
|
||||
// Throw an error if the relay specifically advertises that there is no support for negentropy
|
||||
throw NegentropySyncError.notSupported
|
||||
}
|
||||
}
|
||||
let filter_json_str = String(decoding: filter_json, as: UTF8.self)
|
||||
req += filter_json_str
|
||||
let timeout = timeout ?? .seconds(3)
|
||||
let frameSizeLimit = 60_000 // Copied from rust-nostr project: Default frame limit is 128k. Halve that (hex encoding) and subtract a bit (JSON msg overhead)
|
||||
try? negentropyVector.seal() // Error handling note: We do not care if it throws an `alreadySealed` error. As long as it is sealed in the end it is fine
|
||||
let negentropyClient = try Negentropy(storage: negentropyVector, frameSizeLimit: frameSizeLimit)
|
||||
let initialMessage = try negentropyClient.initiate()
|
||||
let subscriptionId = UUID().uuidString
|
||||
var allNeedIds: [Id] = []
|
||||
for await response in negentropyStream(subscriptionId: subscriptionId, filter: filter, initialMessage: initialMessage, timeoutDuration: timeout) {
|
||||
switch response {
|
||||
case .error(subscriptionId: _, reasonCodeString: let reasonCodeString):
|
||||
throw NegentropySyncError.genericError(reasonCodeString)
|
||||
case .message(subscriptionId: _, data: let data):
|
||||
var haveIds: [Id] = []
|
||||
var needIds: [Id] = []
|
||||
let nextMessage = try negentropyClient.reconcile(data, haveIds: &haveIds, needIds: &needIds)
|
||||
allNeedIds.append(contentsOf: needIds)
|
||||
if let nextMessage {
|
||||
self.send(.typical(.negentropyMessage(subscriptionId: subscriptionId, message: nextMessage)))
|
||||
}
|
||||
else {
|
||||
// Reconciliation is complete
|
||||
return allNeedIds
|
||||
}
|
||||
case .invalidResponse(subscriptionId: _):
|
||||
throw NegentropySyncError.relayError
|
||||
}
|
||||
}
|
||||
// If the stream completes without a response, throw a timeout/relay error
|
||||
throw NegentropySyncError.relayError
|
||||
}
|
||||
|
||||
enum NegentropySyncError: Error {
|
||||
/// Fallback generic error
|
||||
case genericError(String)
|
||||
/// Negentropy is not supported by the relay
|
||||
case notSupported
|
||||
/// Something went wrong with the relay communication during negentropy sync
|
||||
case relayError
|
||||
}
|
||||
|
||||
@RelayPoolActor
|
||||
private func negentropyStream(subscriptionId: String, filter: NostrFilter, initialMessage: [UInt8], timeoutDuration: Duration? = nil) -> AsyncStream<NegentropyResponse> {
|
||||
return AsyncStream<NegentropyResponse> { continuation in
|
||||
self.negentropyStreams[subscriptionId] = continuation
|
||||
let nostrRequest: NostrRequest = .negentropyOpen(subscriptionId: subscriptionId, filter: filter, initialMessage: initialMessage)
|
||||
self.send(.typical(nostrRequest))
|
||||
let timeoutTask = Task {
|
||||
if let timeoutDuration {
|
||||
try Task.checkCancellation()
|
||||
try await Task.sleep(for: timeoutDuration)
|
||||
try Task.checkCancellation()
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task {
|
||||
await self.removeNegentropyStream(id: subscriptionId)
|
||||
self.send(.typical(.negentropyClose(subscriptionId: subscriptionId)))
|
||||
}
|
||||
timeoutTask.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RelayPoolActor
|
||||
private func removeNegentropyStream(id: String) {
|
||||
self.negentropyStreams[id] = nil
|
||||
}
|
||||
req += "]"
|
||||
return req
|
||||
}
|
||||
|
||||
+615
-109
@@ -7,10 +7,14 @@
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import Negentropy
|
||||
|
||||
struct RelayHandler {
|
||||
let sub_id: String
|
||||
let callback: (RelayURL, NostrConnectionEvent) -> ()
|
||||
/// The filters that this handler will handle. Set this to `nil` if you want your handler to receive all events coming from the relays.
|
||||
let filters: [NostrFilter]?
|
||||
let to: [RelayURL]?
|
||||
var handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation
|
||||
}
|
||||
|
||||
struct QueuedRequest {
|
||||
@@ -19,135 +23,367 @@ struct QueuedRequest {
|
||||
let skip_ephemeral: Bool
|
||||
}
|
||||
|
||||
struct SeenEvent: Hashable {
|
||||
let relay_id: RelayURL
|
||||
let evid: NoteId
|
||||
}
|
||||
|
||||
@globalActor
|
||||
actor RelayPoolActor {
|
||||
static let shared = RelayPoolActor()
|
||||
private init() {}
|
||||
}
|
||||
|
||||
/// Establishes and manages connections and subscriptions to a list of relays.
|
||||
@RelayPoolActor
|
||||
class RelayPool {
|
||||
@MainActor
|
||||
private(set) var relays: [Relay] = []
|
||||
var open: Bool = false
|
||||
var handlers: [RelayHandler] = []
|
||||
var request_queue: [QueuedRequest] = []
|
||||
var seen: [NoteId: Set<RelayURL>] = [:]
|
||||
var counts: [RelayURL: UInt64] = [:]
|
||||
var ndb: Ndb
|
||||
var ndb: Ndb?
|
||||
/// The keypair used to authenticate with relays
|
||||
var keypair: Keypair?
|
||||
var message_received_function: (((String, RelayDescriptor)) -> Void)?
|
||||
var message_sent_function: (((String, Relay)) -> Void)?
|
||||
var delegate: Delegate?
|
||||
private(set) var signal: SignalModel = SignalModel()
|
||||
|
||||
private let network_monitor = NWPathMonitor()
|
||||
/// Tracks active leases on ephemeral relays to prevent premature cleanup.
|
||||
/// Each lookup that uses an ephemeral relay acquires a lease; cleanup only
|
||||
/// happens when the last lease is released.
|
||||
private var ephemeralLeases: [RelayURL: Int] = [:]
|
||||
|
||||
let network_monitor = NWPathMonitor()
|
||||
private let network_monitor_queue = DispatchQueue(label: "io.damus.network_monitor")
|
||||
private var last_network_status: NWPath.Status = .unsatisfied
|
||||
|
||||
/// The limit of maximum concurrent subscriptions. Any subscriptions beyond this limit will be paused until subscriptions clear
|
||||
/// This is to avoid error states and undefined behaviour related to hitting subscription limits on the relays, by letting those wait instead — with the principle that although slower is not ideal, it is better than completely broken.
|
||||
static let MAX_CONCURRENT_SUBSCRIPTION_LIMIT = 14 // This number is only an educated guess based on some local experiments.
|
||||
|
||||
func close() {
|
||||
disconnect()
|
||||
relays = []
|
||||
func close() async {
|
||||
await disconnect()
|
||||
await clearRelays()
|
||||
open = false
|
||||
handlers = []
|
||||
request_queue = []
|
||||
seen.removeAll()
|
||||
await clearSeen()
|
||||
counts = [:]
|
||||
keypair = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func clearRelays() {
|
||||
relays = []
|
||||
}
|
||||
|
||||
private func clearSeen() {
|
||||
seen.removeAll()
|
||||
}
|
||||
|
||||
init(ndb: Ndb, keypair: Keypair? = nil) {
|
||||
nonisolated init(ndb: Ndb?, keypair: Keypair? = nil) {
|
||||
self.ndb = ndb
|
||||
self.keypair = keypair
|
||||
|
||||
network_monitor.pathUpdateHandler = { [weak self] path in
|
||||
if (path.status == .satisfied || path.status == .requiresConnection) && self?.last_network_status != path.status {
|
||||
DispatchQueue.main.async {
|
||||
self?.connect_to_disconnected()
|
||||
}
|
||||
}
|
||||
|
||||
if let self, path.status != self.last_network_status {
|
||||
for relay in self.relays {
|
||||
relay.connection.log?.add("Network state: \(path.status)")
|
||||
}
|
||||
}
|
||||
|
||||
self?.last_network_status = path.status
|
||||
Task { await self?.pathUpdateHandler(path: path) }
|
||||
}
|
||||
network_monitor.start(queue: network_monitor_queue)
|
||||
}
|
||||
|
||||
private func pathUpdateHandler(path: NWPath) async {
|
||||
if (path.status == .satisfied || path.status == .requiresConnection) && self.last_network_status != path.status {
|
||||
await self.connect_to_disconnected()
|
||||
}
|
||||
|
||||
if path.status != self.last_network_status {
|
||||
for relay in await self.relays {
|
||||
relay.connection.log?.add("Network state: \(path.status)")
|
||||
}
|
||||
}
|
||||
|
||||
self.last_network_status = path.status
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var our_descriptors: [RelayDescriptor] {
|
||||
return all_descriptors.filter { d in !d.ephemeral }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var all_descriptors: [RelayDescriptor] {
|
||||
relays.map { r in r.descriptor }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var num_connected: Int {
|
||||
return relays.reduce(0) { n, r in n + (r.connection.isConnected ? 1 : 0) }
|
||||
}
|
||||
|
||||
func remove_handler(sub_id: String) {
|
||||
self.handlers = handlers.filter { $0.sub_id != sub_id }
|
||||
print("removing \(sub_id) handler, current: \(handlers.count)")
|
||||
self.handlers = handlers.filter {
|
||||
if $0.sub_id != sub_id {
|
||||
return true
|
||||
}
|
||||
else {
|
||||
$0.handler.finish()
|
||||
return false
|
||||
}
|
||||
}
|
||||
Log.debug("Removing %s handler, current: %d", for: .networking, sub_id, handlers.count)
|
||||
}
|
||||
|
||||
func ping() {
|
||||
Log.info("Pinging %d relays", for: .networking, relays.count)
|
||||
for relay in relays {
|
||||
func ping() async {
|
||||
Log.info("Pinging %d relays", for: .networking, await relays.count)
|
||||
for relay in await relays {
|
||||
relay.connection.ping()
|
||||
}
|
||||
}
|
||||
|
||||
func register_handler(sub_id: String, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) {
|
||||
for handler in handlers {
|
||||
// don't add duplicate handlers
|
||||
if handler.sub_id == sub_id {
|
||||
return
|
||||
}
|
||||
func register_handler(sub_id: String, filters: [NostrFilter]?, to relays: [RelayURL]? = nil, handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation) async {
|
||||
while handlers.count > Self.MAX_CONCURRENT_SUBSCRIPTION_LIMIT {
|
||||
Log.debug("%s: Too many subscriptions, waiting for subscription pool to clear", for: .networking, sub_id)
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
}
|
||||
self.handlers.append(RelayHandler(sub_id: sub_id, callback: handler))
|
||||
print("registering \(sub_id) handler, current: \(self.handlers.count)")
|
||||
Log.debug("%s: Subscription pool cleared", for: .networking, sub_id)
|
||||
handlers = handlers.filter({ handler in
|
||||
if handler.sub_id == sub_id {
|
||||
Log.error("Duplicate handler detected for the same subscription ID. Overriding.", for: .networking)
|
||||
handler.handler.finish()
|
||||
return false
|
||||
}
|
||||
else {
|
||||
return true
|
||||
}
|
||||
})
|
||||
self.handlers.append(RelayHandler(sub_id: sub_id, filters: filters, to: relays, handler: handler))
|
||||
Log.debug("Registering %s handler, current: %d", for: .networking, sub_id, self.handlers.count)
|
||||
}
|
||||
|
||||
func remove_relay(_ relay_id: RelayURL) {
|
||||
/// Removes the relay with the given URL from the pool, permanently disables its connection, and ensures it is disconnected.
|
||||
/// - Parameters:
|
||||
/// - relay_id: The RelayURL identifying the relay to disable and remove.
|
||||
@MainActor
|
||||
func remove_relay(_ relay_id: RelayURL) async {
|
||||
var i: Int = 0
|
||||
|
||||
self.disconnect(to: [relay_id])
|
||||
|
||||
await self.disconnect(to: [relay_id])
|
||||
|
||||
for relay in relays {
|
||||
if relay.id == relay_id {
|
||||
relay.connection.disablePermanently()
|
||||
relays.remove(at: i)
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
func add_relay(_ desc: RelayDescriptor) throws(RelayError) {
|
||||
/// Acquires a lease on ephemeral relays to prevent them from being cleaned up
|
||||
/// Increment lease counts for the given ephemeral relay URLs to prevent their removal while leased.
|
||||
/// - Parameters:
|
||||
/// - relayURLs: The relay URLs whose ephemeral lease counts will be incremented; each URL's lease count is increased by one.
|
||||
func acquireEphemeralRelays(_ relayURLs: [RelayURL]) {
|
||||
for url in relayURLs {
|
||||
ephemeralLeases[url, default: 0] += 1
|
||||
#if DEBUG
|
||||
print("[RelayPool] Acquired lease on ephemeral relay \(url.absoluteString), count: \(ephemeralLeases[url] ?? 0)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Releases leases on ephemeral relays. When the last lease is released,
|
||||
/// Releases one lease for each specified relay and removes any ephemeral relay when its last lease is released.
|
||||
/// - Parameters:
|
||||
/// - relayURLs: Relay URLs whose leases should be decremented. If a relay's lease count reaches zero and the relay is marked ephemeral, the relay will be removed. Relays not present in the lease table are ignored.
|
||||
func releaseEphemeralRelays(_ relayURLs: [RelayURL]) async {
|
||||
for url in relayURLs {
|
||||
guard let count = ephemeralLeases[url], count > 0 else { continue }
|
||||
|
||||
// Decrement immediately (atomic with respect to this actor, before any suspension)
|
||||
let newCount = count - 1
|
||||
ephemeralLeases[url] = newCount == 0 ? nil : newCount
|
||||
|
||||
#if DEBUG
|
||||
print("[RelayPool] Released lease on ephemeral relay \(url.absoluteString), count: \(newCount)")
|
||||
#endif
|
||||
|
||||
if newCount == 0 {
|
||||
// Check if relay exists and is ephemeral
|
||||
if let relay = await get_relay(url), relay.descriptor.ephemeral {
|
||||
// Re-check: only remove if lease is still nil (not re-acquired during await)
|
||||
guard ephemeralLeases[url] == nil else {
|
||||
#if DEBUG
|
||||
print("[RelayPool] Lease re-acquired during check, skipping removal: \(url.absoluteString)")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
#if DEBUG
|
||||
print("[RelayPool] Removing ephemeral relay: \(url.absoluteString)")
|
||||
#endif
|
||||
await remove_relay(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds and registers a new relay in the pool using the provided descriptor.
|
||||
/// - Parameter desc: Descriptor for the relay to add (includes its URL, metadata, and whether it is ephemeral).
|
||||
/// - Throws: `RelayError.RelayAlreadyExists` if a relay with the same URL is already present in the pool.
|
||||
func add_relay(_ desc: RelayDescriptor) async throws(RelayError) {
|
||||
let relay_id = desc.url
|
||||
if get_relay(relay_id) != nil {
|
||||
if await get_relay(relay_id) != nil {
|
||||
throw RelayError.RelayAlreadyExists
|
||||
}
|
||||
let conn = RelayConnection(url: desc.url, handleEvent: { event in
|
||||
self.handle_event(relay_id: relay_id, event: event)
|
||||
await self.handle_event(relay_id: relay_id, event: event)
|
||||
}, processUnverifiedWSEvent: { wsev in
|
||||
guard case .message(let msg) = wsev,
|
||||
case .string(let str) = msg
|
||||
else { return }
|
||||
|
||||
let _ = self.ndb.process_event(str)
|
||||
#if DEBUG
|
||||
if desc.ephemeral {
|
||||
if str.hasPrefix("[\"EVENT\"") {
|
||||
print("[RelayPool] Received EVENT from ephemeral relay \(relay_id.absoluteString): \(str.prefix(200))...")
|
||||
} else if str.hasPrefix("[\"EOSE\"") {
|
||||
print("[RelayPool] Received EOSE from ephemeral relay \(relay_id.absoluteString)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
let _ = self.ndb?.processEvent(str, originRelayURL: relay_id)
|
||||
self.message_received_function?((str, desc))
|
||||
})
|
||||
let relay = Relay(descriptor: desc, connection: conn)
|
||||
await self.appendRelayToList(relay: relay)
|
||||
}
|
||||
|
||||
/// Appends the given Relay to the pool's internal list of relays.
|
||||
@MainActor
|
||||
private func appendRelayToList(relay: Relay) {
|
||||
self.relays.append(relay)
|
||||
}
|
||||
|
||||
func setLog(_ log: RelayLog, for relay_id: RelayURL) {
|
||||
/// Ensures the given relay URLs are connected, adding them as ephemeral relays if not already in the pool.
|
||||
/// Returns the list of relay URLs that are actually connected (ready for subscriptions).
|
||||
///
|
||||
/// Callers should use `acquireEphemeralRelays` before the lookup and `releaseEphemeralRelays` after.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - relayURLs: The relay URLs to ensure are connected
|
||||
/// - timeout: Maximum time to wait for pending connections (default 2s). Returns early when first relay connects.
|
||||
/// Ensure the given relays are present in the pool and return those that are connected.
|
||||
///
|
||||
/// This will add missing URLs as ephemeral relays, initiate connections for relays that are not connected, and wait up to `timeout` for connections to establish. Once any relay connects, the method allows a short grace period for additional relays to connect before returning.
|
||||
/// - Parameters:
|
||||
/// - relayURLs: The relay URLs to ensure connectivity for. Missing URLs will be added as ephemeral relays.
|
||||
/// - timeout: Maximum time to wait for connections (default: 2 seconds). A short grace period (≈300 ms) is applied after the first relay connects.
|
||||
/// - Returns: The subset of `relayURLs` that are currently connected (includes relays that were already connected and those that became connected during the wait).
|
||||
func ensureConnected(to relayURLs: [RelayURL], timeout: Duration = .seconds(2)) async -> [RelayURL] {
|
||||
var toConnect: [RelayURL] = []
|
||||
var alreadyConnected: [RelayURL] = []
|
||||
|
||||
for url in relayURLs {
|
||||
if let existing = await get_relay(url) {
|
||||
if existing.connection.isConnected {
|
||||
alreadyConnected.append(url)
|
||||
#if DEBUG
|
||||
print("[RelayPool] Relay \(url.absoluteString) already connected")
|
||||
#endif
|
||||
} else {
|
||||
toConnect.append(url)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let descriptor = RelayDescriptor(url: url, info: .readWrite, variant: .ephemeral)
|
||||
do {
|
||||
try await add_relay(descriptor)
|
||||
toConnect.append(url)
|
||||
#if DEBUG
|
||||
print("[RelayPool] Added ephemeral relay: \(url.absoluteString)")
|
||||
#endif
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RelayPool] Failed to add relay \(url.absoluteString): \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
guard !toConnect.isEmpty else { return alreadyConnected }
|
||||
|
||||
await connect(to: toConnect)
|
||||
|
||||
let checkInterval: Duration = .milliseconds(50)
|
||||
let overallDeadline = ContinuousClock.now + timeout
|
||||
var graceDeadline: ContinuousClock.Instant? = alreadyConnected.isEmpty ? nil : ContinuousClock.now + .milliseconds(300)
|
||||
|
||||
// Wait for relays to connect. Once the first connects, start a grace period for others.
|
||||
waitLoop: while ContinuousClock.now < overallDeadline {
|
||||
do {
|
||||
try await Task.sleep(for: checkInterval)
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
|
||||
// Check if any relay has connected
|
||||
var anyConnected = false
|
||||
for url in toConnect {
|
||||
if let relay = await get_relay(url), relay.connection.isConnected {
|
||||
anyConnected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if anyConnected && graceDeadline == nil {
|
||||
// Start grace period on first connection
|
||||
graceDeadline = ContinuousClock.now + .milliseconds(300)
|
||||
}
|
||||
|
||||
// Exit once grace period expires (check every iteration if deadline is set)
|
||||
if let deadline = graceDeadline, ContinuousClock.now >= deadline {
|
||||
break waitLoop
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all connected relays
|
||||
var connected = alreadyConnected
|
||||
for url in toConnect {
|
||||
if let relay = await get_relay(url), relay.connection.isConnected {
|
||||
connected.append(url)
|
||||
#if DEBUG
|
||||
print("[RelayPool] Relay \(url.absoluteString) connected: true")
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("[RelayPool] Relay \(url.absoluteString) connected: false (excluded)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
return connected
|
||||
}
|
||||
|
||||
/// Attaches a `RelayLog` to the connection for the specified relay and records the current network status in the log.
|
||||
/// - Parameters:
|
||||
/// - log: The `RelayLog` instance to attach to the relay's connection.
|
||||
/// - relay_id: The `RelayURL` identifying the relay whose connection will receive the log.
|
||||
func setLog(_ log: RelayLog, for relay_id: RelayURL) async {
|
||||
// add the current network state to the log
|
||||
log.add("Network state: \(network_monitor.currentPath.status)")
|
||||
|
||||
get_relay(relay_id)?.connection.log = log
|
||||
await get_relay(relay_id)?.connection.log = log
|
||||
}
|
||||
|
||||
/// This is used to retry dead connections
|
||||
func connect_to_disconnected() {
|
||||
for relay in relays {
|
||||
func connect_to_disconnected() async {
|
||||
for relay in await relays {
|
||||
let c = relay.connection
|
||||
|
||||
let is_connecting = c.isConnecting
|
||||
@@ -164,38 +400,96 @@ class RelayPool {
|
||||
}
|
||||
}
|
||||
|
||||
func reconnect(to: [RelayURL]? = nil) {
|
||||
let relays = to.map{ get_relays($0) } ?? self.relays
|
||||
func reconnect(to targetRelays: [RelayURL]? = nil) async {
|
||||
let relays = await getRelays(targetRelays: targetRelays)
|
||||
for relay in relays {
|
||||
// don't try to reconnect to broken relays
|
||||
relay.connection.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
func connect(to: [RelayURL]? = nil) {
|
||||
let relays = to.map{ get_relays($0) } ?? self.relays
|
||||
func connect(to targetRelays: [RelayURL]? = nil) async {
|
||||
let relays = await getRelays(targetRelays: targetRelays)
|
||||
for relay in relays {
|
||||
relay.connection.connect()
|
||||
}
|
||||
// Mark as open last, to prevent other classes from pulling data before the relays are actually connected
|
||||
// Only mark as open when connecting ALL relays (not specific ones)
|
||||
if targetRelays == nil {
|
||||
open = true
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect(to: [RelayURL]? = nil) {
|
||||
let relays = to.map{ get_relays($0) } ?? self.relays
|
||||
func disconnect(to targetRelays: [RelayURL]? = nil) async {
|
||||
// Mark as closed first, to prevent other classes from pulling data while the relays are being disconnected
|
||||
// Only mark as closed when disconnecting ALL relays (not specific ones)
|
||||
if targetRelays == nil {
|
||||
open = false
|
||||
}
|
||||
let relays = await getRelays(targetRelays: targetRelays)
|
||||
for relay in relays {
|
||||
relay.connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets relays matching the provided relay URLs, or all relays when no targets are specified.
|
||||
/// - Parameter targetRelays: Optional list of relay URLs to filter by. If `nil`, the pool's full relay list is returned.
|
||||
/// - Returns: An array of `Relay` instances corresponding to the requested URLs; any requested URL not present in the pool is omitted from the result.
|
||||
@MainActor
|
||||
func getRelays(targetRelays: [RelayURL]? = nil) -> [Relay] {
|
||||
let result = targetRelays.map{ get_relays($0) } ?? self.relays
|
||||
#if DEBUG
|
||||
if let targets = targetRelays {
|
||||
let found = result.map { $0.descriptor.url.absoluteString }
|
||||
let requested = targets.map { $0.absoluteString }
|
||||
if found.count != targets.count {
|
||||
print("[RelayPool] getRelays: MISMATCH! requested=\(requested) but found=\(found)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return result
|
||||
}
|
||||
|
||||
/// Deletes queued up requests that should not persist between app sessions (i.e. when the app goes to background then back to foreground)
|
||||
func cleanQueuedRequestForSessionEnd() {
|
||||
request_queue = request_queue.filter { request in
|
||||
guard case .typical(let typicalRequest) = request.req else { return true }
|
||||
switch typicalRequest {
|
||||
case .subscribe(_):
|
||||
return true
|
||||
case .unsubscribe(_):
|
||||
return false // Do not persist unsubscribe requests to prevent them to race against subscribe requests when we come back to the foreground.
|
||||
case .event(_):
|
||||
return true
|
||||
case .auth(_):
|
||||
return true
|
||||
case .negentropyOpen(subscriptionId: _, filter: _, initialMessage: _):
|
||||
return false // Do not persist negentropy requests across sessions
|
||||
case .negentropyMessage(subscriptionId: _, message: _):
|
||||
return false // Do not persist negentropy requests across sessions
|
||||
case .negentropyClose(subscriptionId: _):
|
||||
return false // Do not persist negentropy requests across sessions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func unsubscribe(sub_id: String, to: [RelayURL]? = nil) {
|
||||
func unsubscribe(sub_id: String, to: [RelayURL]? = nil) async {
|
||||
if to == nil {
|
||||
self.remove_handler(sub_id: sub_id)
|
||||
}
|
||||
self.send(.unsubscribe(sub_id), to: to)
|
||||
await self.send(.unsubscribe(sub_id), to: to)
|
||||
}
|
||||
|
||||
func subscribe(sub_id: String, filters: [NostrFilter], handler: @escaping (RelayURL, NostrConnectionEvent) -> (), to: [RelayURL]? = nil) {
|
||||
register_handler(sub_id: sub_id, handler: handler)
|
||||
send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
|
||||
func subscribe(sub_id: String, filters: [NostrFilter], handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation, to: [RelayURL]? = nil) {
|
||||
Task {
|
||||
await register_handler(sub_id: sub_id, filters: filters, to: to, handler: handler)
|
||||
|
||||
// When the caller specifies no relays, it is implied that the user wants to use the ones in the user relay list. Skip ephemeral relays in that case.
|
||||
// When the caller specifies specific relays, do not skip ephemeral relays to respect the exact list given by the caller.
|
||||
let shouldSkipEphemeralRelays = to == nil ? true : false
|
||||
|
||||
await send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to, skip_ephemeral: shouldSkipEphemeralRelays)
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribes to data from the `RelayPool` based on a filter and a list of desired relays.
|
||||
@@ -203,52 +497,104 @@ class RelayPool {
|
||||
/// - Parameters:
|
||||
/// - filters: The filters specifying the desired content.
|
||||
/// - desiredRelays: The desired relays which to subsctibe to. If `nil`, it defaults to the `RelayPool`'s default list
|
||||
/// - eoseTimeout: The maximum timeout which to give up waiting for the eoseSignal, in seconds
|
||||
/// - Returns: Returns an async stream that callers can easily consume via a for-loop
|
||||
func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: TimeInterval = 10) -> AsyncStream<StreamItem> {
|
||||
let desiredRelays = desiredRelays ?? self.relays.map({ $0.descriptor.url })
|
||||
/// - eoseTimeout: The maximum timeout which to give up waiting for the eoseSignal
|
||||
/// Open a subscription for the given filters and provide a stream of matching items and EOSE notifications.
|
||||
/// - Parameters:
|
||||
/// - filters: The list of NostrFilter objects that define which events to receive.
|
||||
/// - desiredRelays: Optional list of RelayURL to subscribe to; when `nil` the pool's relays are used.
|
||||
/// - eoseTimeout: Optional timeout to wait before emitting an EOSE if not all relays have reported EOSE; defaults to 5 seconds.
|
||||
/// - id: Optional UUID to use as the subscription identifier; a new UUID is generated when `nil`.
|
||||
/// - Returns: An AsyncStream that yields StreamItem values representing matched events and end-of-stream (EOSE) notifications for this subscription. The stream deduplicates events by their NoteId. When the stream terminates it will unsubscribe from the chosen relays and remove the internal handler.
|
||||
func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: Duration? = nil, id: UUID? = nil) async -> AsyncStream<StreamItem> {
|
||||
let eoseTimeout = eoseTimeout ?? .seconds(5)
|
||||
let desiredRelays = await getRelays(targetRelays: desiredRelays)
|
||||
#if DEBUG
|
||||
print("[RelayPool] subscribe: requested=\(desiredRelays.map { $0.descriptor.url.absoluteString }), pool has \(await relays.count) relays")
|
||||
if let ids = filters.first?.ids {
|
||||
print("[RelayPool] subscribe: filter ids=\(ids.map { $0.hex() })")
|
||||
}
|
||||
#endif
|
||||
let startTime = CFAbsoluteTimeGetCurrent()
|
||||
return AsyncStream<StreamItem> { continuation in
|
||||
let sub_id = UUID().uuidString
|
||||
let id = id ?? UUID()
|
||||
let sub_id = id.uuidString
|
||||
var seenEvents: Set<NoteId> = []
|
||||
var relaysWhoFinishedInitialResults: Set<RelayURL> = []
|
||||
var eoseSent = false
|
||||
self.subscribe(sub_id: sub_id, filters: filters, handler: { (relayUrl, connectionEvent) in
|
||||
switch connectionEvent {
|
||||
case .ws_connection_event(let ev):
|
||||
// Websocket events such as connect/disconnect/error are already handled in `RelayConnection`. Do not perform any handling here.
|
||||
// For the future, perhaps we should abstract away `.ws_connection_event` in `RelayPool`? Seems like something to be handled on the `RelayConnection` layer.
|
||||
break
|
||||
case .nostr_event(let nostrResponse):
|
||||
guard nostrResponse.subid == sub_id else { return } // Do not stream items that do not belong in this subscription
|
||||
switch nostrResponse {
|
||||
case .event(_, let nostrEvent):
|
||||
if seenEvents.contains(nostrEvent.id) { break } // Don't send two of the same events.
|
||||
continuation.yield(with: .success(.event(nostrEvent)))
|
||||
seenEvents.insert(nostrEvent.id)
|
||||
case .notice(let note):
|
||||
break // We do not support handling these yet
|
||||
case .eose(_):
|
||||
relaysWhoFinishedInitialResults.insert(relayUrl)
|
||||
if relaysWhoFinishedInitialResults == Set(desiredRelays) {
|
||||
continuation.yield(with: .success(.eose))
|
||||
eoseSent = true
|
||||
let upstreamStream = AsyncStream<(RelayURL, NostrConnectionEvent)> { upstreamContinuation in
|
||||
self.subscribe(sub_id: sub_id, filters: filters, handler: upstreamContinuation, to: desiredRelays.map({ $0.descriptor.url }))
|
||||
}
|
||||
let upstreamStreamingTask = Task {
|
||||
for await (relayUrl, connectionEvent) in upstreamStream {
|
||||
try Task.checkCancellation()
|
||||
switch connectionEvent {
|
||||
case .ws_connection_event(let ev):
|
||||
// Websocket events such as connect/disconnect/error are already handled in `RelayConnection`. Do not perform any handling here.
|
||||
// For the future, perhaps we should abstract away `.ws_connection_event` in `RelayPool`? Seems like something to be handled on the `RelayConnection` layer.
|
||||
break
|
||||
case .nostr_event(let nostrResponse):
|
||||
guard nostrResponse.subid == sub_id else { return } // Do not stream items that do not belong in this subscription
|
||||
switch nostrResponse {
|
||||
case .event(_, let nostrEvent):
|
||||
if seenEvents.contains(nostrEvent.id) { break } // Don't send two of the same events.
|
||||
continuation.yield(with: .success(.event(nostrEvent)))
|
||||
seenEvents.insert(nostrEvent.id)
|
||||
case .notice(let note):
|
||||
break // We do not support handling these yet
|
||||
case .eose(_):
|
||||
relaysWhoFinishedInitialResults.insert(relayUrl)
|
||||
let desiredAndConnectedRelays = desiredRelays.filter({ $0.connection.isConnected }).map({ $0.descriptor.url })
|
||||
Log.debug("RelayPool subscription %s: EOSE from %s. EOSE count: %d/%d. Elapsed: %.2f seconds.", for: .networking, id.uuidString, relayUrl.absoluteString, relaysWhoFinishedInitialResults.count, Set(desiredAndConnectedRelays).count, CFAbsoluteTimeGetCurrent() - startTime)
|
||||
if relaysWhoFinishedInitialResults == Set(desiredAndConnectedRelays) {
|
||||
continuation.yield(with: .success(.eose))
|
||||
eoseSent = true
|
||||
}
|
||||
case .ok(_): break // No need to handle this, we are not sending an event to the relay
|
||||
case .auth(_): break // Handled in a separate function in RelayPool
|
||||
case .negentropyError(subscriptionId: _, reasonCodeString: _): break // Not handled in regular subscriptions
|
||||
case .negentropyMessage(subscriptionId: _, hexEncodedData: _): break // Not handled in regular subscriptions
|
||||
}
|
||||
case .ok(_): break // No need to handle this, we are not sending an event to the relay
|
||||
case .auth(_): break // Handled in a separate function in RelayPool
|
||||
}
|
||||
}
|
||||
}, to: desiredRelays)
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000 * UInt64(eoseTimeout))
|
||||
}
|
||||
let timeoutTask = Task {
|
||||
try? await Task.sleep(for: eoseTimeout)
|
||||
if !eoseSent { continuation.yield(with: .success(.eose)) }
|
||||
}
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
self.unsubscribe(sub_id: sub_id, to: desiredRelays)
|
||||
self.remove_handler(sub_id: sub_id)
|
||||
continuation.onTermination = { @Sendable termination in
|
||||
switch termination {
|
||||
case .finished:
|
||||
Log.debug("RelayPool subscription %s finished. Closing.", for: .networking, sub_id)
|
||||
case .cancelled:
|
||||
Log.debug("RelayPool subscription %s cancelled. Closing.", for: .networking, sub_id)
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
Task {
|
||||
await self.unsubscribe(sub_id: sub_id, to: desiredRelays.map({ $0.descriptor.url }))
|
||||
await self.remove_handler(sub_id: sub_id)
|
||||
}
|
||||
timeoutTask.cancel()
|
||||
upstreamStreamingTask.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This streams events that are pre-existing on the relay, and stops streaming as soon as it receives the EOSE signal.
|
||||
func subscribeExistingItems(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: Duration? = nil, id: UUID? = nil) -> AsyncStream<NostrEvent> {
|
||||
return AsyncStream<NostrEvent>.with(task: { continuation in
|
||||
outerLoop: for await item in await self.subscribe(filters: filters, to: desiredRelays, eoseTimeout: eoseTimeout, id: id) {
|
||||
if Task.isCancelled { return }
|
||||
switch item {
|
||||
case .event(let event):
|
||||
continuation.yield(event)
|
||||
case .eose:
|
||||
break outerLoop
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
enum StreamItem {
|
||||
/// A Nostr event
|
||||
case event(NostrEvent)
|
||||
@@ -256,9 +602,12 @@ class RelayPool {
|
||||
case eose
|
||||
}
|
||||
|
||||
func subscribe_to(sub_id: String, filters: [NostrFilter], to: [RelayURL]?, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) {
|
||||
register_handler(sub_id: sub_id, handler: handler)
|
||||
send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
|
||||
func subscribe_to(sub_id: String, filters: [NostrFilter], to: [RelayURL]?, handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation) {
|
||||
Task {
|
||||
await register_handler(sub_id: sub_id, filters: filters, to: to, handler: handler)
|
||||
|
||||
await send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
|
||||
}
|
||||
}
|
||||
|
||||
func count_queued(relay: RelayURL) -> Int {
|
||||
@@ -271,7 +620,7 @@ class RelayPool {
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
func queue_req(r: NostrRequestType, relay: RelayURL, skip_ephemeral: Bool) {
|
||||
let count = count_queued(relay: relay)
|
||||
guard count <= 10 else {
|
||||
@@ -288,15 +637,22 @@ class RelayPool {
|
||||
switch req {
|
||||
case .typical(let r):
|
||||
if case .event = r, let rstr = make_nostr_req(r) {
|
||||
let _ = ndb.process_client_event(rstr)
|
||||
let _ = ndb?.process_client_event(rstr)
|
||||
}
|
||||
case .custom(let string):
|
||||
let _ = ndb.process_client_event(string)
|
||||
let _ = ndb?.process_client_event(string)
|
||||
}
|
||||
}
|
||||
|
||||
func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) {
|
||||
let relays = to.map{ get_relays($0) } ?? self.relays
|
||||
/// Dispatches a Nostr request to the pool's matching relays, writing a local copy to the NostrDB and queuing the request for any relay that is not currently connected.
|
||||
///
|
||||
/// Filters target relays by their read/write capabilities and, optionally, by ephemeral status; connected relays receive the request immediately and disconnected relays have the request queued for later delivery. Sent messages are reported via `message_sent_function` when available.
|
||||
/// - Parameters:
|
||||
/// - req: The Nostr request to send.
|
||||
/// - to: Optional list of relay URLs to restrict delivery to; `nil` targets the pool's default set of relays.
|
||||
/// - skip_ephemeral: If `true`, skip ephemeral relays when sending the request.
|
||||
func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) async {
|
||||
let relays = await getRelays(targetRelays: to)
|
||||
|
||||
self.send_raw_to_local_ndb(req) // Always send Nostr events and data to NostrDB for a local copy
|
||||
|
||||
@@ -314,25 +670,32 @@ class RelayPool {
|
||||
}
|
||||
|
||||
guard relay.connection.isConnected else {
|
||||
queue_req(r: req, relay: relay.id, skip_ephemeral: skip_ephemeral)
|
||||
Task { await queue_req(r: req, relay: relay.id, skip_ephemeral: skip_ephemeral) }
|
||||
continue
|
||||
}
|
||||
|
||||
relay.connection.send(req, callback: { str in
|
||||
#if DEBUG
|
||||
if relay.descriptor.ephemeral && str.hasPrefix("[\"REQ\"") {
|
||||
print("[RelayPool] Sending REQ to ephemeral relay \(relay.id.absoluteString): \(str)")
|
||||
}
|
||||
#endif
|
||||
self.message_sent_function?((str, relay))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func send(_ req: NostrRequest, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) {
|
||||
send_raw(.typical(req), to: to, skip_ephemeral: skip_ephemeral)
|
||||
func send(_ req: NostrRequest, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) async {
|
||||
await send_raw(.typical(req), to: to, skip_ephemeral: skip_ephemeral)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func get_relays(_ ids: [RelayURL]) -> [Relay] {
|
||||
// don't include ephemeral relays in the default list to query
|
||||
relays.filter { ids.contains($0.id) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func get_relay(_ id: RelayURL) -> Relay? {
|
||||
relays.first(where: { $0.id == id })
|
||||
}
|
||||
@@ -345,7 +708,7 @@ class RelayPool {
|
||||
}
|
||||
|
||||
print("running queueing request: \(req.req) for \(relay_id)")
|
||||
self.send_raw(req.req, to: [relay_id], skip_ephemeral: false)
|
||||
Task { await self.send_raw(req.req, to: [relay_id], skip_ephemeral: false) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,27 +724,48 @@ class RelayPool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resubscribeAll(relayId: RelayURL) async {
|
||||
for handler in self.handlers {
|
||||
guard let filters = handler.filters else { continue }
|
||||
// When the caller specifies no relays, it is implied that the user wants to use the ones in the user relay list. Skip ephemeral relays in that case.
|
||||
// When the caller specifies specific relays, do not skip ephemeral relays to respect the exact list given by the caller.
|
||||
let shouldSkipEphemeralRelays = handler.to == nil ? true : false
|
||||
|
||||
if let handlerTargetRelays = handler.to,
|
||||
!handlerTargetRelays.contains(where: { $0 == relayId }) {
|
||||
// Not part of the target relays, skip
|
||||
continue
|
||||
}
|
||||
|
||||
Log.debug("%s: Sending resubscribe request to %s", for: .networking, handler.sub_id, relayId.absoluteString)
|
||||
await send(.subscribe(.init(filters: filters, sub_id: handler.sub_id)), to: [relayId], skip_ephemeral: shouldSkipEphemeralRelays)
|
||||
}
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, event: NostrConnectionEvent) {
|
||||
func handle_event(relay_id: RelayURL, event: NostrConnectionEvent) async {
|
||||
record_seen(relay_id: relay_id, event: event)
|
||||
|
||||
// run req queue when we reconnect
|
||||
// When we reconnect, do two things
|
||||
// - Send messages that were stored in the queue
|
||||
// - Re-subscribe to filters we had subscribed before
|
||||
if case .ws_connection_event(let ws) = event {
|
||||
if case .connected = ws {
|
||||
run_queue(relay_id)
|
||||
await self.resubscribeAll(relayId: relay_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle auth
|
||||
if case let .nostr_event(nostrResponse) = event,
|
||||
case let .auth(challenge_string) = nostrResponse {
|
||||
if let relay = get_relay(relay_id) {
|
||||
if let relay = await get_relay(relay_id) {
|
||||
print("received auth request from \(relay.descriptor.url.id)")
|
||||
relay.authentication_state = .pending
|
||||
if let keypair {
|
||||
if let fullKeypair = keypair.to_full() {
|
||||
if let authRequest = make_auth_request(keypair: fullKeypair, challenge_string: challenge_string, relay: relay) {
|
||||
send(.auth(authRequest), to: [relay_id], skip_ephemeral: false)
|
||||
await send(.auth(authRequest), to: [relay_id], skip_ephemeral: false)
|
||||
relay.authentication_state = .verified
|
||||
} else {
|
||||
print("failed to make auth request")
|
||||
@@ -400,13 +784,135 @@ class RelayPool {
|
||||
}
|
||||
|
||||
for handler in handlers {
|
||||
handler.callback(relay_id, event)
|
||||
// We send data to the handlers if:
|
||||
// - the subscription ID matches, or
|
||||
// - the handler filters is `nil`, which is used in some cases as a blanket "give me all notes" (e.g. during signup)
|
||||
guard handler.sub_id == event.subId || handler.filters == nil else { continue }
|
||||
logStreamPipelineStats("RelayPool_\(relay_id.absoluteString)", "RelayPool_Handler_\(handler.sub_id)")
|
||||
handler.handler.yield((relay_id, event))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Negentropy
|
||||
|
||||
/// This streams items in the following fashion:
|
||||
/// 1. Performs a negentropy sync, sending missing notes to the stream
|
||||
/// 2. Send EOSE to signal end of syncing
|
||||
/// 3. Stream new notes
|
||||
func negentropySubscribe(
|
||||
filters: [NostrFilter],
|
||||
to desiredRelayURLs: [RelayURL]? = nil,
|
||||
negentropyVector: NegentropyStorageVector,
|
||||
eoseTimeout: Duration? = nil,
|
||||
id: UUID? = nil,
|
||||
ignoreUnsupportedRelays: Bool
|
||||
) async throws -> AsyncThrowingStream<StreamItem, any Error> {
|
||||
return AsyncThrowingStream<StreamItem, any Error>.with(task: { continuation in
|
||||
// 1. Mark the time when we begin negentropy syncing
|
||||
let negentropyStartTimestamp = UInt32(Date().timeIntervalSince1970)
|
||||
// 2. Negentropy sync missing notes and send the missing notes over
|
||||
for try await event in try await self.negentropySync(filters: filters, to: desiredRelayURLs, negentropyVector: negentropyVector, ignoreUnsupportedRelays: ignoreUnsupportedRelays) {
|
||||
continuation.yield(.event(event))
|
||||
}
|
||||
// 3. When syncing is done, send the EOSE signal
|
||||
continuation.yield(.eose)
|
||||
// 3. Stream new notes that match the filter
|
||||
let updatedFilters = filters.map({ filter in
|
||||
var newFilter = filter
|
||||
newFilter.since = negentropyStartTimestamp
|
||||
return newFilter
|
||||
})
|
||||
for await item in await self.subscribe(filters: updatedFilters, to: desiredRelayURLs, eoseTimeout: eoseTimeout, id: id) {
|
||||
try Task.checkCancellation()
|
||||
switch item {
|
||||
case .event(let nostrEvent):
|
||||
continuation.yield(.event(nostrEvent))
|
||||
case .eose:
|
||||
continue // We already sent the EOSE signal after negentropy sync, ignore this redundant EOSE
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// This performs a negentropy syncing with various relays and various filters and sends missing notes over an async stream
|
||||
func negentropySync(
|
||||
filters: [NostrFilter],
|
||||
to desiredRelayURLs: [RelayURL]? = nil,
|
||||
negentropyVector: NegentropyStorageVector,
|
||||
eoseTimeout: Duration? = nil,
|
||||
ignoreUnsupportedRelays: Bool
|
||||
) async throws -> AsyncThrowingStream<NostrEvent, any Error> {
|
||||
return AsyncThrowingStream<NostrEvent, any Error>.with(task: { continuation in
|
||||
for filter in filters {
|
||||
try Task.checkCancellation()
|
||||
for try await event in try await self.negentropySync(filter: filter, to: desiredRelayURLs, negentropyVector: negentropyVector, eoseTimeout: eoseTimeout, ignoreUnsupportedRelays: ignoreUnsupportedRelays) {
|
||||
try Task.checkCancellation()
|
||||
continuation.yield(event)
|
||||
// Note: Negentropy vector already updated by the underlying stream, since it is a reference type
|
||||
try Task.checkCancellation()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// This performs a negentropy syncing with various relays and sends missing notes over an async stream
|
||||
func negentropySync(
|
||||
filter: NostrFilter,
|
||||
to desiredRelayURLs: [RelayURL]? = nil,
|
||||
negentropyVector: NegentropyStorageVector,
|
||||
eoseTimeout: Duration? = nil,
|
||||
ignoreUnsupportedRelays: Bool
|
||||
) async throws -> AsyncThrowingStream<NostrEvent, any Error> {
|
||||
return AsyncThrowingStream<NostrEvent, any Error>.with(task: { continuation in
|
||||
let desiredRelays = await self.getRelays(targetRelays: desiredRelayURLs)
|
||||
for desiredRelay in desiredRelays {
|
||||
try Task.checkCancellation()
|
||||
do {
|
||||
for try await event in try await self.negentropySync(filter: filter, to: desiredRelay, negentropyVector: negentropyVector, eoseTimeout: eoseTimeout) {
|
||||
try Task.checkCancellation()
|
||||
continuation.yield(event)
|
||||
// Add to our negentropy vector so that we don't need to receive it from the next relay!
|
||||
negentropyVector.unseal()
|
||||
try negentropyVector.insert(nostrEvent: event)
|
||||
try Task.checkCancellation()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ignoreUnsupportedRelays {
|
||||
// Do not throw error, ignore the relays that do not support negentropy
|
||||
// Note: Some relays such as wss://nos.lol/v2 advertise negentropy but throw an error such as `["NOTICE","ERROR: bad msg: negentropy disabled"]`
|
||||
// Therefore, realistically, we cannot rely on what the relay advertises and
|
||||
// we have to suppress those errors if we want to ignore unsupported relays to avoid the whole multi-relay negentropy syncing operation to fail
|
||||
Log.error("Error while negentropy streaming: %s", for: .networking, error.localizedDescription)
|
||||
}
|
||||
else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// This performs a negentropy syncing with one relay and sends missing notes over an async stream
|
||||
func negentropySync(filter: NostrFilter, to desiredRelay: Relay, negentropyVector: NegentropyStorageVector, eoseTimeout: Duration? = nil) async throws -> AsyncThrowingStream<NostrEvent, any Error> {
|
||||
return AsyncThrowingStream<NostrEvent, any Error>.with(task: { streamContinuation in
|
||||
let missingIds = try await desiredRelay.connection.getMissingIds(filter: filter, negentropyVector: negentropyVector, timeout: eoseTimeout)
|
||||
let missingIdsFilter = NostrFilter(ids: missingIds.map { NoteId($0.toData()) })
|
||||
for await event in self.subscribeExistingItems(filters: [missingIdsFilter], to: [desiredRelay.descriptor.url], eoseTimeout: eoseTimeout) {
|
||||
streamContinuation.yield(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) {
|
||||
try? pool.add_relay(RelayPool.RelayDescriptor(url: url, info: .readWrite))
|
||||
func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) async {
|
||||
try? await pool.add_relay(RelayPool.RelayDescriptor(url: url, info: .readWrite))
|
||||
}
|
||||
|
||||
|
||||
extension RelayPool {
|
||||
protocol Delegate {
|
||||
func latestRelayListChanged(_ newEvent: NdbNote)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,12 +9,13 @@ import Foundation
|
||||
import LinkPresentation
|
||||
import EmojiPicker
|
||||
|
||||
class DamusState: HeadlessDamusState {
|
||||
class DamusState: HeadlessDamusState, ObservableObject {
|
||||
let keypair: Keypair
|
||||
let likes: EventCounter
|
||||
let boosts: EventCounter
|
||||
let quote_reposts: EventCounter
|
||||
let contacts: Contacts
|
||||
let contactCards: ContactCard
|
||||
let mutelist_manager: MutelistManager
|
||||
let profiles: Profiles
|
||||
let dms: DirectMessagesModel
|
||||
@@ -38,12 +39,14 @@ class DamusState: HeadlessDamusState {
|
||||
let emoji_provider: EmojiProvider
|
||||
let favicon_cache: FaviconCache
|
||||
private(set) var nostrNetwork: NostrNetworkManager
|
||||
var snapshotManager: DatabaseSnapshotManager
|
||||
|
||||
init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider, favicon_cache: FaviconCache) {
|
||||
init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, contactCards: ContactCard, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider, favicon_cache: FaviconCache, addNdbToRelayPool: Bool = true) {
|
||||
self.keypair = keypair
|
||||
self.likes = likes
|
||||
self.boosts = boosts
|
||||
self.contacts = contacts
|
||||
self.contactCards = contactCards
|
||||
self.mutelist_manager = mutelist_manager
|
||||
self.profiles = profiles
|
||||
self.dms = dms
|
||||
@@ -72,13 +75,16 @@ class DamusState: HeadlessDamusState {
|
||||
self.favicon_cache = FaviconCache()
|
||||
|
||||
let networkManagerDelegate = NostrNetworkManagerDelegate(settings: settings, contacts: contacts, ndb: ndb, keypair: keypair, relayModelCache: relay_model_cache, relayFilters: relay_filters)
|
||||
self.nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate)
|
||||
let nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate, addNdbToRelayPool: addNdbToRelayPool)
|
||||
self.nostrNetwork = nostrNetwork
|
||||
self.wallet.nostrNetwork = nostrNetwork
|
||||
self.snapshotManager = .init(ndb: ndb)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
convenience init?(keypair: Keypair) {
|
||||
convenience init?(keypair: Keypair, owns_db_file: Bool) {
|
||||
// nostrdb
|
||||
var mndb = Ndb()
|
||||
var mndb = Ndb(owns_db_file: owns_db_file)
|
||||
if mndb == nil {
|
||||
// try recovery
|
||||
print("DB ISSUE! RECOVERING")
|
||||
@@ -109,6 +115,7 @@ class DamusState: HeadlessDamusState {
|
||||
likes: EventCounter(our_pubkey: pubkey),
|
||||
boosts: EventCounter(our_pubkey: pubkey),
|
||||
contacts: Contacts(our_pubkey: pubkey),
|
||||
contactCards: ContactCardManager(),
|
||||
mutelist_manager: MutelistManager(user_keypair: keypair),
|
||||
profiles: Profiles(ndb: ndb),
|
||||
dms: home.dms,
|
||||
@@ -122,7 +129,7 @@ class DamusState: HeadlessDamusState {
|
||||
events: EventCache(ndb: ndb),
|
||||
bookmarks: BookmarksManager(pubkey: pubkey),
|
||||
replies: ReplyCounter(our_pubkey: pubkey),
|
||||
wallet: WalletModel(settings: settings),
|
||||
wallet: WalletModel(settings: settings), // nostrNetwork is connected after initialization
|
||||
nav: navigationCoordinator,
|
||||
music: MusicController(onChange: { _ in }),
|
||||
video: DamusVideoCoordinator(),
|
||||
@@ -158,16 +165,27 @@ class DamusState: HeadlessDamusState {
|
||||
keypair.privkey != nil
|
||||
}
|
||||
|
||||
/// Returns the Damus client tag array if the user has enabled client tag publishing, nil otherwise.
|
||||
var clientTagComponents: [String]? {
|
||||
guard settings.publish_client_tag else {
|
||||
return nil
|
||||
}
|
||||
return ClientTagMetadata.damus.tagValues
|
||||
}
|
||||
|
||||
func close() {
|
||||
print("txn: damus close")
|
||||
Task {
|
||||
try await self.push_notification_client.revoke_token()
|
||||
}
|
||||
wallet.disconnect()
|
||||
nostrNetwork.pool.close()
|
||||
ndb.close()
|
||||
Task {
|
||||
await nostrNetwork.close() // Close ndb streaming tasks before closing ndb to avoid memory errors
|
||||
ndb.close()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static var empty: DamusState {
|
||||
let empty_pub: Pubkey = .empty
|
||||
let empty_sec: Privkey = .empty
|
||||
@@ -178,6 +196,7 @@ class DamusState: HeadlessDamusState {
|
||||
likes: EventCounter(our_pubkey: empty_pub),
|
||||
boosts: EventCounter(our_pubkey: empty_pub),
|
||||
contacts: Contacts(our_pubkey: empty_pub),
|
||||
contactCards: ContactCardManagerMock(),
|
||||
mutelist_manager: MutelistManager(user_keypair: kp),
|
||||
profiles: Profiles(ndb: .empty),
|
||||
dms: DirectMessagesModel(our_pubkey: empty_pub),
|
||||
@@ -216,9 +235,11 @@ fileprivate extension DamusState {
|
||||
set { self.settings.latestRelayListEventIdHex = newValue }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var latestContactListEvent: NostrEvent? { self.contacts.event }
|
||||
var bootstrapRelays: [RelayURL] { get_default_bootstrap_relays() }
|
||||
var developerMode: Bool { self.settings.developer_mode }
|
||||
var experimentalLocalRelayModelSupport: Bool { self.settings.enable_experimental_local_relay_model }
|
||||
var relayModelCache: RelayModelCache
|
||||
var relayFilters: RelayFilters
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//
|
||||
// DatabaseSnapshotManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created on 2025-01-20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// Manages periodic snapshots of the main NostrDB database to a shared container location.
|
||||
///
|
||||
/// This allows app extensions (like notification service extensions) to access a recent
|
||||
/// read-only copy of the database for enhanced UX, while the main database resides in
|
||||
/// the private container to avoid 0xdead10cc crashes and issues related to holding file locks on shared containers.
|
||||
///
|
||||
/// Snapshots are created periodically while the app is in the foreground, since the database
|
||||
/// only gets updated when the app is active.
|
||||
actor DatabaseSnapshotManager {
|
||||
|
||||
/// Minimum interval between snapshots (in seconds)
|
||||
private static let minimumSnapshotInterval: TimeInterval = 60 * 60 // 1 hour
|
||||
|
||||
/// Key for storing last snapshot timestamp in UserDefaults
|
||||
private static let lastSnapshotDateKey = "lastDatabaseSnapshotDate"
|
||||
|
||||
private let ndb: Ndb
|
||||
private var snapshotTimerTask: Task<Void, Never>? = nil
|
||||
var snapshotTimerTickCount: Int = 0
|
||||
var snapshotCount: Int = 0
|
||||
|
||||
/// Initialize the snapshot manager with a NostrDB instance
|
||||
/// - Parameter ndb: The NostrDB instance to snapshot
|
||||
init(ndb: Ndb) {
|
||||
self.ndb = ndb
|
||||
}
|
||||
|
||||
// MARK: - Periodic tasks management
|
||||
|
||||
/// Start the periodic snapshot timer.
|
||||
///
|
||||
/// This should be called when the app enters the foreground.
|
||||
/// The timer will fire periodically to check if a snapshot is needed.
|
||||
func startPeriodicSnapshots() {
|
||||
// Don't start if already running
|
||||
guard snapshotTimerTask == nil else {
|
||||
Log.debug("Snapshot timer already running", for: .storage)
|
||||
return
|
||||
}
|
||||
|
||||
Log.info("Starting periodic database snapshot timer", for: .storage)
|
||||
|
||||
snapshotTimerTask = Task(priority: .utility) { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
Log.debug("Snapshot timer - tick", for: .storage)
|
||||
await self.increaseSnapshotTimerTickCount()
|
||||
do {
|
||||
try await self.createSnapshotIfNeeded()
|
||||
}
|
||||
catch {
|
||||
Log.error("Failed to create snapshot: %{public}@", for: .storage, error.localizedDescription)
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(60 * 5), tolerance: .seconds(10))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the periodic snapshot timer.
|
||||
///
|
||||
/// This should be called when the app enters the background.
|
||||
func stopPeriodicSnapshots() async {
|
||||
guard snapshotTimerTask != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
Log.info("Stopping periodic database snapshot timer", for: .storage)
|
||||
snapshotTimerTask?.cancel()
|
||||
await snapshotTimerTask?.value
|
||||
snapshotTimerTask = nil
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Snapshotting
|
||||
|
||||
/// Perform a database snapshot if needed.
|
||||
///
|
||||
/// This method checks if enough time has passed since the last snapshot and creates a new one if necessary.
|
||||
@discardableResult
|
||||
func createSnapshotIfNeeded() async throws -> Bool {
|
||||
guard shouldCreateSnapshot() else {
|
||||
Log.debug("Skipping snapshot - minimum interval not yet elapsed", for: .storage)
|
||||
return false
|
||||
}
|
||||
|
||||
try await self.performSnapshot()
|
||||
return true
|
||||
}
|
||||
|
||||
/// Check if a snapshot should be created based on the last snapshot time.
|
||||
private func shouldCreateSnapshot() -> Bool {
|
||||
guard let lastSnapshotDate = UserDefaults.standard.object(forKey: Self.lastSnapshotDateKey) as? Date else {
|
||||
return true // No snapshot has been created yet
|
||||
}
|
||||
|
||||
let timeSinceLastSnapshot = Date().timeIntervalSince(lastSnapshotDate)
|
||||
return timeSinceLastSnapshot >= Self.minimumSnapshotInterval
|
||||
}
|
||||
|
||||
/// Perform the actual snapshot operation.
|
||||
///
|
||||
/// Creates a storage-efficient snapshot by creating a new temporary Ndb instance
|
||||
/// and selectively copying only the necessary notes (profiles, mute lists, contact lists).
|
||||
func performSnapshot() async throws {
|
||||
guard let snapshotPath = Ndb.snapshot_db_path else {
|
||||
throw SnapshotError.pathsUnavailable
|
||||
}
|
||||
|
||||
Log.info("Starting nostrdb snapshot to %{public}@", for: .storage, snapshotPath)
|
||||
|
||||
try await createSelectiveSnapshot(to: snapshotPath)
|
||||
|
||||
// Update the last snapshot date
|
||||
UserDefaults.standard.set(Date(), forKey: Self.lastSnapshotDateKey)
|
||||
|
||||
Log.info("Database snapshot completed successfully", for: .storage)
|
||||
self.snapshotCount += 1
|
||||
}
|
||||
|
||||
/// Creates a selective snapshot containing only profiles, mute lists, and contact lists.
|
||||
///
|
||||
/// This method:
|
||||
/// 1. Creates a temporary Ndb instance in a temp directory
|
||||
/// 2. Queries the source database for relevant notes
|
||||
/// 3. Writes each note to the temporary database
|
||||
/// 4. Atomically moves the temporary database to the final destination
|
||||
private func createSelectiveSnapshot(to snapshotPath: String) async throws {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
// Create a temporary directory for the snapshot
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let tempSnapshotPath = tempDir.appendingPathComponent("snapshot_temp_\(UUID().uuidString)")
|
||||
|
||||
do {
|
||||
try fileManager.createDirectory(atPath: tempSnapshotPath.path, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
throw SnapshotError.directoryCreationFailed(error)
|
||||
}
|
||||
|
||||
// Ensure cleanup on error
|
||||
defer {
|
||||
try? fileManager.removeItem(atPath: tempSnapshotPath.path)
|
||||
}
|
||||
|
||||
Log.debug("Created temporary snapshot directory at %{public}@", for: .storage, tempSnapshotPath.path)
|
||||
|
||||
// Create a new Ndb instance in the temporary directory
|
||||
guard let snapshotNdb = Ndb(path: tempSnapshotPath.path, owns_db_file: true) else {
|
||||
throw SnapshotError.failedToCreateSnapshotDatabase
|
||||
}
|
||||
|
||||
defer {
|
||||
snapshotNdb.close()
|
||||
}
|
||||
|
||||
Log.debug("Created temporary Ndb instance for snapshot", for: .storage)
|
||||
|
||||
// Query and copy notes to snapshot database
|
||||
try await copyNotesToSnapshot(snapshotNdb: snapshotNdb)
|
||||
|
||||
Log.debug("Copied notes to snapshot database", for: .storage)
|
||||
|
||||
// Close the snapshot database before moving files
|
||||
snapshotNdb.close()
|
||||
|
||||
// Atomically move the temporary database to the final destination
|
||||
try await moveSnapshotToFinalDestination(from: tempSnapshotPath.path, to: snapshotPath)
|
||||
|
||||
Log.debug("Moved snapshot to final destination", for: .storage)
|
||||
}
|
||||
|
||||
/// Queries the source database and copies relevant notes to the snapshot database.
|
||||
private func copyNotesToSnapshot(snapshotNdb: Ndb) async throws {
|
||||
let filters = try createSnapshotFilters()
|
||||
|
||||
Log.debug("Querying source database with %d filters", for: .storage, filters.count)
|
||||
|
||||
var totalNotesCopied = 0
|
||||
|
||||
for filter in filters {
|
||||
let noteKeys = try ndb.query(filters: [filter], maxResults: 100_000)
|
||||
|
||||
Log.debug("Found %d notes for filter", for: .storage, noteKeys.count)
|
||||
|
||||
for noteKey in noteKeys {
|
||||
// Get the note from source database and copy to snapshot
|
||||
try ndb.lookup_note_by_key(noteKey, borrow: { unownedNote in
|
||||
// Convert the note to owned, encode to JSON, and process into snapshot database
|
||||
guard let ownedNote = unownedNote?.toOwned() else {
|
||||
Log.error("Failed to get unowned note", for: .storage)
|
||||
return
|
||||
}
|
||||
|
||||
// Process the note into the snapshot database
|
||||
|
||||
// Implementation note: This does not _immediately_ add the event to the new Ndb.
|
||||
// It goes into the ingester queue first for later processing.
|
||||
// This raises the question: How to guarantee that all notes will be saved to the new
|
||||
// snapshot Ndb before we close it?
|
||||
//
|
||||
// The answer is that when `Ndb.close` is called, it actually waits for the ingester task
|
||||
// to finish processing its queue — unless the queue is full (an edge case).
|
||||
try snapshotNdb.add(event: ownedNote)
|
||||
totalNotesCopied += 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Log.info("Copied %d notes to snapshot database", for: .storage, totalNotesCopied)
|
||||
}
|
||||
|
||||
/// Creates filters for querying profiles, mute lists, and contact lists.
|
||||
private func createSnapshotFilters() throws -> [NdbFilter] {
|
||||
// Filter for profile metadata (kind 0)
|
||||
let profileFilter = try NdbFilter(from: NostrFilter(kinds: [.metadata]))
|
||||
|
||||
// Filter for contact lists (kind 3)
|
||||
let contactsFilter = try NdbFilter(from: NostrFilter(kinds: [.contacts]))
|
||||
|
||||
// Filter for mute lists (kind 10000)
|
||||
let muteListFilter = try NdbFilter(from: NostrFilter(kinds: [.mute_list]))
|
||||
|
||||
return [profileFilter, contactsFilter, muteListFilter]
|
||||
}
|
||||
|
||||
/// Atomically moves the snapshot from temporary location to final destination.
|
||||
private func moveSnapshotToFinalDestination(from tempPath: String, to finalPath: String) async throws {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
// Remove existing snapshot if it exists
|
||||
if fileManager.fileExists(atPath: finalPath) {
|
||||
do {
|
||||
try fileManager.removeItem(atPath: finalPath)
|
||||
Log.debug("Removed existing snapshot at %{public}@", for: .storage, finalPath)
|
||||
} catch {
|
||||
throw SnapshotError.removeFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Create parent directory if needed
|
||||
let parentDir = URL(fileURLWithPath: finalPath).deletingLastPathComponent().path
|
||||
if !fileManager.fileExists(atPath: parentDir) {
|
||||
do {
|
||||
try fileManager.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
throw SnapshotError.directoryCreationFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Atomically move the temp snapshot to final destination
|
||||
do {
|
||||
try fileManager.moveItem(atPath: tempPath, toPath: finalPath)
|
||||
Log.debug("Moved snapshot from %{public}@ to %{public}@", for: .storage, tempPath, finalPath)
|
||||
} catch {
|
||||
throw SnapshotError.moveFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stats functions
|
||||
|
||||
private func increaseSnapshotTimerTickCount() async {
|
||||
self.snapshotTimerTickCount += 1
|
||||
}
|
||||
|
||||
func resetStats() async {
|
||||
self.snapshotTimerTickCount = 0
|
||||
self.snapshotCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Types
|
||||
|
||||
enum SnapshotError: Error, LocalizedError {
|
||||
case pathsUnavailable
|
||||
case copyFailed(any Error)
|
||||
case removeFailed(Error)
|
||||
case directoryCreationFailed(Error)
|
||||
case failedToCreateSnapshotDatabase
|
||||
case moveFailed(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .pathsUnavailable:
|
||||
return "Database paths are not available"
|
||||
case .copyFailed(let code):
|
||||
return "Failed to copy database (error code: \(code))"
|
||||
case .removeFailed(let error):
|
||||
return "Failed to remove existing snapshot: \(error.localizedDescription)"
|
||||
case .directoryCreationFailed(let error):
|
||||
return "Failed to create snapshot directory: \(error.localizedDescription)"
|
||||
case .failedToCreateSnapshotDatabase:
|
||||
return "Failed to create temporary snapshot database"
|
||||
case .moveFailed(let error):
|
||||
return "Failed to move snapshot to final destination: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// StorageStatsManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2026-02-20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Kingfisher
|
||||
|
||||
/// Storage statistics for various Damus data stores
|
||||
struct StorageStats: Hashable {
|
||||
/// Detailed breakdown of NostrDB storage by kind, indices, and other
|
||||
let nostrdbDetails: NdbStats?
|
||||
|
||||
/// Size of the main NostrDB database file in bytes (total)
|
||||
let nostrdbSize: UInt64
|
||||
|
||||
/// Size of the snapshot NostrDB database file in bytes
|
||||
let snapshotSize: UInt64
|
||||
|
||||
/// Size of the Kingfisher image cache in bytes
|
||||
let imageCacheSize: UInt64
|
||||
|
||||
/// Total storage used across all data stores
|
||||
var totalSize: UInt64 {
|
||||
return nostrdbSize + snapshotSize + imageCacheSize
|
||||
}
|
||||
|
||||
/// Calculate the percentage of total storage used by a specific size
|
||||
/// - Parameter size: The size to calculate percentage for
|
||||
/// - Returns: Percentage value between 0.0 and 100.0
|
||||
func percentage(for size: UInt64) -> Double {
|
||||
guard totalSize > 0 else { return 0.0 }
|
||||
return Double(size) / Double(totalSize) * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Manager for calculating storage statistics across Damus data stores
|
||||
struct StorageStatsManager {
|
||||
static let shared = StorageStatsManager()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Calculate storage statistics for all Damus data stores
|
||||
///
|
||||
/// This method runs all file operations on a background thread to avoid blocking
|
||||
/// the main thread. It calculates:
|
||||
/// - NostrDB database file size
|
||||
/// - Detailed NostrDB breakdown (if ndb instance provided)
|
||||
/// - Snapshot database file size
|
||||
/// - Kingfisher image cache size
|
||||
///
|
||||
/// - Parameter ndb: Optional Ndb instance to get detailed storage breakdown
|
||||
/// - Returns: StorageStats containing all calculated sizes
|
||||
/// - Throws: Error if critical file operations fail
|
||||
func calculateStorageStats(ndb: Ndb? = nil) async throws -> StorageStats {
|
||||
// Run all file operations on background thread
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
let nostrdbSize = self.getNostrDBSize()
|
||||
let snapshotSize = self.getSnapshotDBSize()
|
||||
|
||||
// Get detailed NostrDB stats if ndb instance provided
|
||||
let nostrdbDetails: NdbStats? = ndb?.getStats(physicalSize: nostrdbSize)
|
||||
|
||||
// Kingfisher cache size requires async callback
|
||||
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
|
||||
let imageCacheSize: UInt64
|
||||
switch result {
|
||||
case .success(let size):
|
||||
imageCacheSize = UInt64(size)
|
||||
case .failure(let error):
|
||||
Log.error("Failed to calculate Kingfisher cache size: %@", for: .storage, error.localizedDescription)
|
||||
imageCacheSize = 0
|
||||
}
|
||||
|
||||
let stats = StorageStats(
|
||||
nostrdbDetails: nostrdbDetails,
|
||||
nostrdbSize: nostrdbSize,
|
||||
snapshotSize: snapshotSize,
|
||||
imageCacheSize: imageCacheSize
|
||||
)
|
||||
|
||||
continuation.resume(returning: stats)
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the size of the main NostrDB database file
|
||||
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
|
||||
private func getNostrDBSize() -> UInt64 {
|
||||
guard let dbPath = Ndb.db_path else {
|
||||
Log.error("Failed to get NostrDB path", for: .storage)
|
||||
return 0
|
||||
}
|
||||
|
||||
let dataFilePath = "\(dbPath)/\(Ndb.main_db_file_name)"
|
||||
return getFileSize(at: dataFilePath, description: "NostrDB")
|
||||
}
|
||||
|
||||
/// Get the size of the snapshot NostrDB database file
|
||||
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
|
||||
private func getSnapshotDBSize() -> UInt64 {
|
||||
guard let snapshotPath = Ndb.snapshot_db_path else {
|
||||
Log.error("Failed to get snapshot DB path", for: .storage)
|
||||
return 0
|
||||
}
|
||||
|
||||
let dataFilePath = "\(snapshotPath)/\(Ndb.main_db_file_name)"
|
||||
return getFileSize(at: dataFilePath, description: "Snapshot DB")
|
||||
}
|
||||
|
||||
/// Get the size of a file at the specified path
|
||||
/// - Parameters:
|
||||
/// - path: Full path to the file
|
||||
/// - description: Human-readable description for logging
|
||||
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
|
||||
private func getFileSize(at path: String, description: String) -> UInt64 {
|
||||
guard FileManager.default.fileExists(atPath: path) else {
|
||||
Log.info("%@ file does not exist at path: %@", for: .storage, description, path)
|
||||
return 0
|
||||
}
|
||||
|
||||
do {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: path)
|
||||
guard let fileSize = attributes[.size] as? UInt64 else {
|
||||
Log.error("Failed to get size attribute for %@", for: .storage, description)
|
||||
return 0
|
||||
}
|
||||
return fileSize
|
||||
} catch {
|
||||
Log.error("Failed to get file size for %@: %@", for: .storage, description, error.localizedDescription)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Format bytes into a human-readable string
|
||||
/// - Parameter bytes: Number of bytes
|
||||
/// - Returns: Formatted string (e.g., "45.3 MB", "1.2 GB")
|
||||
static func formatBytes(_ bytes: UInt64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.allowedUnits = [.useAll]
|
||||
formatter.countStyle = .file
|
||||
formatter.includesUnit = true
|
||||
formatter.isAdaptive = true
|
||||
return formatter.string(fromByteCount: Int64(bytes))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//
|
||||
// StorageStatsViewHelper.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D'Aquino on 2026-02-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Shared helper functions for storage statistics views
|
||||
/// Consolidates common logic between StorageSettingsView and NostrDBDetailView
|
||||
enum StorageStatsViewHelper {
|
||||
|
||||
// MARK: - Category Ranges
|
||||
|
||||
/// Computes cumulative ranges for angle selection in pie charts (iOS 17+)
|
||||
/// - Parameter categories: Array of storage categories
|
||||
/// - Returns: Array of tuples containing category ID and cumulative range
|
||||
static func computeCategoryRanges(for categories: [StorageCategory]) -> [(category: String, range: Range<Double>)] {
|
||||
var total: UInt64 = 0
|
||||
return categories.map { category in
|
||||
let newTotal = total + category.size
|
||||
let result = (category: category.id, range: Double(total)..<Double(newTotal))
|
||||
total = newTotal
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Storage Stats Loading
|
||||
|
||||
/// Load storage statistics asynchronously
|
||||
/// - Parameter ndb: The NostrDB instance
|
||||
/// - Returns: Calculated storage statistics
|
||||
/// - Throws: Error if storage calculation fails
|
||||
@concurrent
|
||||
static func loadStorageStatsAsync(ndb: Ndb) async throws -> StorageStats {
|
||||
return try await StorageStatsManager.shared.calculateStorageStats(ndb: ndb)
|
||||
}
|
||||
|
||||
// MARK: - Export Preparation
|
||||
|
||||
/// Prepare export text for storage statistics on background thread
|
||||
/// - Parameters:
|
||||
/// - stats: The storage statistics to export
|
||||
/// - formatter: Closure that formats the stats into text
|
||||
/// - Returns: Formatted text ready for export
|
||||
@concurrent
|
||||
static func prepareExportText(
|
||||
stats: StorageStats,
|
||||
formatter: @escaping @concurrent (StorageStats) async -> String
|
||||
) async -> String {
|
||||
return await formatter(stats)
|
||||
}
|
||||
|
||||
// MARK: - Text Formatting
|
||||
|
||||
/// Format storage statistics as exportable text
|
||||
/// - Parameter stats: The storage statistics to format
|
||||
/// - Returns: Formatted text representation of storage stats
|
||||
@concurrent
|
||||
static func formatStorageStatsAsText(_ stats: StorageStats) async -> String {
|
||||
// Build categories list
|
||||
let categories = [
|
||||
StorageCategory(
|
||||
id: "nostrdb",
|
||||
title: NSLocalizedString("NostrDB", comment: "Label for main NostrDB database"),
|
||||
icon: "internaldrive.fill",
|
||||
color: .blue,
|
||||
size: stats.nostrdbSize
|
||||
),
|
||||
StorageCategory(
|
||||
id: "snapshot",
|
||||
title: NSLocalizedString("Snapshot Database", comment: "Label for snapshot database"),
|
||||
icon: "doc.on.doc.fill",
|
||||
color: .purple,
|
||||
size: stats.snapshotSize
|
||||
),
|
||||
StorageCategory(
|
||||
id: "cache",
|
||||
title: NSLocalizedString("Image Cache", comment: "Label for Kingfisher image cache"),
|
||||
icon: "photo.fill",
|
||||
color: .orange,
|
||||
size: stats.imageCacheSize
|
||||
)
|
||||
]
|
||||
|
||||
var text = "Damus Storage Statistics\n"
|
||||
text += "Generated: \(Date().formatted(date: .abbreviated, time: .shortened))\n"
|
||||
text += String(repeating: "=", count: 50) + "\n\n"
|
||||
|
||||
// Top-level Categories
|
||||
text += "Storage Breakdown:\n"
|
||||
text += String(repeating: "-", count: 50) + "\n"
|
||||
|
||||
for category in categories {
|
||||
let percentage = stats.percentage(for: category.size)
|
||||
let titlePadded = category.title.padding(toLength: 25, withPad: " ", startingAt: 0)
|
||||
let sizePadded = StorageStatsManager.formatBytes(category.size).padding(toLength: 10, withPad: " ", startingAt: 0)
|
||||
text += "\(titlePadded) \(sizePadded) (\(String(format: "%.1f", percentage))%)\n"
|
||||
}
|
||||
|
||||
text += String(repeating: "-", count: 50) + "\n"
|
||||
let totalTitlePadded = "Total Storage".padding(toLength: 25, withPad: " ", startingAt: 0)
|
||||
let totalSizePadded = StorageStatsManager.formatBytes(stats.totalSize).padding(toLength: 10, withPad: " ", startingAt: 0)
|
||||
text += "\(totalTitlePadded) \(totalSizePadded)\n\n"
|
||||
|
||||
// Add NostrDB detailed breakdown if available
|
||||
if let details = stats.nostrdbDetails {
|
||||
text += await formatNostrDBDetails(details: details)
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
/// Format NostrDB statistics as exportable text
|
||||
/// - Parameter stats: The storage statistics containing NostrDB details
|
||||
/// - Returns: Formatted text representation of NostrDB stats breakdown
|
||||
@concurrent
|
||||
static func formatNostrDBStatsAsText(_ stats: StorageStats) async -> String {
|
||||
guard let details = stats.nostrdbDetails else {
|
||||
return "NostrDB details not available"
|
||||
}
|
||||
|
||||
var text = "Damus NostrDB Detailed Statistics\n"
|
||||
text += "Generated: \(Date().formatted(date: .abbreviated, time: .shortened))\n"
|
||||
text += String(repeating: "=", count: 50) + "\n\n"
|
||||
|
||||
text += await formatNostrDBDetails(details: details)
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Format NostrDB details section
|
||||
/// - Parameter details: The NostrDB statistics details
|
||||
/// - Returns: Formatted text representation of NostrDB details
|
||||
@concurrent
|
||||
private static func formatNostrDBDetails(details: NdbStats) async -> String {
|
||||
var text = String(repeating: "=", count: 50) + "\n\n"
|
||||
text += "NostrDB Detailed Breakdown:\n"
|
||||
text += String(repeating: "-", count: 50) + "\n"
|
||||
|
||||
// Per-database breakdown (sorted by size, already done in getStats)
|
||||
if !details.databaseStats.isEmpty {
|
||||
text += "\nDatabases:\n"
|
||||
|
||||
for dbStat in details.databaseStats {
|
||||
let percentage = details.totalSize > 0 ? Double(dbStat.totalSize) / Double(details.totalSize) * 100.0 : 0.0
|
||||
let dbNamePadded = dbStat.database.displayName.padding(toLength: 30, withPad: " ", startingAt: 0)
|
||||
let sizePadded = StorageStatsManager.formatBytes(dbStat.totalSize).padding(toLength: 12, withPad: " ", startingAt: 0)
|
||||
text += "\(dbNamePadded) \(sizePadded) (\(String(format: "%.1f", percentage))%)\n"
|
||||
|
||||
// Only show keys/values breakdown if both exist
|
||||
if dbStat.keySize > 0 && dbStat.valueSize > 0 {
|
||||
text += " Keys: \(StorageStatsManager.formatBytes(dbStat.keySize)), Values: \(StorageStatsManager.formatBytes(dbStat.valueSize))\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text += "\n" + String(repeating: "-", count: 50) + "\n"
|
||||
let nostrdbTitlePadded = "NostrDB Total".padding(toLength: 30, withPad: " ", startingAt: 0)
|
||||
let nostrdbSizePadded = StorageStatsManager.formatBytes(details.totalSize).padding(toLength: 12, withPad: " ", startingAt: 0)
|
||||
text += "\(nostrdbTitlePadded) \(nostrdbSizePadded)\n"
|
||||
|
||||
return text
|
||||
}
|
||||
}
|
||||
@@ -79,8 +79,7 @@ extension Block {
|
||||
guard let url = URL(string: block.as_str()) else { return nil }
|
||||
self = .url(url)
|
||||
case BLOCK_INVOICE:
|
||||
guard let b = Block(invoice: block.block.invoice) else { return nil }
|
||||
self = b
|
||||
self = Block(invoice: block.block.invoice)
|
||||
case BLOCK_MENTION_BECH32:
|
||||
guard let b = Block(bech32: block.block.mention_bech32) else { return nil }
|
||||
self = b
|
||||
@@ -113,26 +112,20 @@ fileprivate extension Block {
|
||||
}
|
||||
|
||||
fileprivate extension Block {
|
||||
/// Failable initializer for the C-backed type `invoice_block_t`.
|
||||
init?(invoice: ndb_invoice_block) {
|
||||
|
||||
guard let invoice = invoice_block_as_invoice(invoice) else { return nil }
|
||||
self = .invoice(invoice)
|
||||
/// Initializer for the C-backed type `invoice_block_t`.
|
||||
init(invoice: ndb_invoice_block) {
|
||||
self = .invoice(invoice_block_as_invoice(invoice))
|
||||
}
|
||||
}
|
||||
|
||||
func invoice_block_as_invoice(_ invoice: ndb_invoice_block) -> Invoice? {
|
||||
/// Converts a C-backed invoice block to a Swift Invoice.
|
||||
func invoice_block_as_invoice(_ invoice: ndb_invoice_block) -> Invoice {
|
||||
let invstr = invoice.invstr.as_str()
|
||||
let b11 = invoice.invoice
|
||||
|
||||
guard let description = convert_invoice_description(b11: b11) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let description = convert_invoice_description(b11: b11)
|
||||
let amount: Amount = b11.amount == 0 ? .any : .specific(Int64(b11.amount))
|
||||
|
||||
return Invoice(description: description, amount: amount, string: invstr, expiry: b11.expiry, created_at: b11.timestamp)
|
||||
|
||||
}
|
||||
|
||||
fileprivate extension Block {
|
||||
|
||||
@@ -27,19 +27,23 @@ enum Marker: String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to a note event, with optional relay hint, marker, and author pubkey.
|
||||
/// Per NIP-10: `["e", <event-id>, <relay-url>, <marker>, <pubkey>]`
|
||||
struct NoteRef: IdType, TagConvertible, Equatable {
|
||||
let note_id: NoteId
|
||||
let relay: String?
|
||||
let marker: Marker?
|
||||
let pubkey: Pubkey?
|
||||
|
||||
var id: Data {
|
||||
self.note_id.id
|
||||
}
|
||||
|
||||
init(note_id: NoteId, relay: String? = nil, marker: Marker? = nil) {
|
||||
init(note_id: NoteId, relay: String? = nil, marker: Marker? = nil, pubkey: Pubkey? = nil) {
|
||||
self.note_id = note_id
|
||||
self.relay = relay
|
||||
self.marker = marker
|
||||
self.pubkey = pubkey
|
||||
}
|
||||
|
||||
static func note_id(_ note_id: NoteId) -> NoteRef {
|
||||
@@ -50,19 +54,26 @@ struct NoteRef: IdType, TagConvertible, Equatable {
|
||||
self.note_id = NoteId(data)
|
||||
self.relay = nil
|
||||
self.marker = nil
|
||||
self.pubkey = nil
|
||||
}
|
||||
|
||||
/// Generates a tag array per NIP-10: `["e", <event-id>, <relay-url>, <marker>, <pubkey>]`
|
||||
var tag: [String] {
|
||||
var t = ["e", self.hex()]
|
||||
if let marker {
|
||||
t.append(relay ?? "")
|
||||
t.append(marker.rawValue)
|
||||
if let pubkey {
|
||||
t.append(pubkey.hex())
|
||||
}
|
||||
} else if let relay {
|
||||
t.append(relay)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/// Parses a NoteRef from a tag per NIP-10: `["e", <event-id>, <relay-url>, <marker>, <pubkey>]`
|
||||
/// Only parses pubkey from position 4 when a valid marker is present in position 3.
|
||||
static func from_tag(tag: TagSequence) -> NoteRef? {
|
||||
guard tag.count >= 2 else { return nil }
|
||||
|
||||
@@ -78,14 +89,19 @@ struct NoteRef: IdType, TagConvertible, Equatable {
|
||||
|
||||
var relay: String? = nil
|
||||
var marker: Marker? = nil
|
||||
var pubkey: Pubkey? = nil
|
||||
|
||||
if tag.count >= 3, let r = i.next() {
|
||||
relay = r.string()
|
||||
if tag.count >= 4, let m = i.next() {
|
||||
marker = Marker(m)
|
||||
// Only parse pubkey when marker is recognized per NIP-10
|
||||
if marker != nil, tag.count >= 5, let pk = i.next(), let pubkeyData = pk.id() {
|
||||
pubkey = Pubkey(pubkeyData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NoteRef(note_id: note_id, relay: relay, marker: marker)
|
||||
return NoteRef(note_id: note_id, relay: relay, marker: marker, pubkey: pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,8 @@ class ActionBarModel: ObservableObject {
|
||||
self.relays = relays
|
||||
}
|
||||
|
||||
func update(damus: DamusState, evid: NoteId) {
|
||||
@MainActor
|
||||
func update(damus: DamusState, evid: NoteId) async {
|
||||
self.likes = damus.likes.counts[evid] ?? 0
|
||||
self.boosts = damus.boosts.counts[evid] ?? 0
|
||||
self.zaps = damus.zaps.event_counts[evid] ?? 0
|
||||
@@ -58,7 +59,7 @@ class ActionBarModel: ObservableObject {
|
||||
self.our_zap = damus.zaps.our_zaps[evid]?.first
|
||||
self.our_reply = damus.replies.our_reply(evid)
|
||||
self.our_quote_repost = damus.quote_reposts.our_events[evid]
|
||||
self.relays = (damus.nostrNetwork.pool.seen[evid] ?? []).count
|
||||
self.relays = (await damus.nostrNetwork.relayURLsThatSawNote(id: evid) ?? []).count
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
|
||||
|
||||
@@ -36,10 +36,15 @@ struct EventActionBar: View {
|
||||
self.swipe_context = swipe_context
|
||||
}
|
||||
|
||||
var lnurl: String? {
|
||||
damus_state.profiles.lookup_with_timestamp(event.pubkey)?.map({ pr in
|
||||
pr?.lnurl
|
||||
}).value
|
||||
@State var lnurl: String? = nil
|
||||
|
||||
// Fetching an LNURL is expensive enough that it can cause a hitch. Use a special backgroundable function to fetch the value.
|
||||
// Fetch on `.onAppear`
|
||||
nonisolated func fetchLNURL() {
|
||||
let lnurl = try? damus_state.profiles.lookup_lnurl(event.pubkey)
|
||||
DispatchQueue.main.async {
|
||||
self.lnurl = lnurl
|
||||
}
|
||||
}
|
||||
|
||||
var show_like: Bool {
|
||||
@@ -82,8 +87,10 @@ struct EventActionBar: View {
|
||||
|
||||
var like_swipe_button: some View {
|
||||
SwipeAction(image: "shaka", backgroundColor: DamusColors.adaptableGrey) {
|
||||
send_like(emoji: damus_state.settings.default_emoji_reaction)
|
||||
self.swipe_context?.state.wrappedValue = .closed
|
||||
Task {
|
||||
await send_like(emoji: damus_state.settings.default_emoji_reaction)
|
||||
self.swipe_context?.state.wrappedValue = .closed
|
||||
}
|
||||
}
|
||||
.swipeButtonStyle()
|
||||
.accessibilityLabel(NSLocalizedString("React with default reaction emoji", comment: "Accessibility label for react button"))
|
||||
@@ -131,7 +138,7 @@ struct EventActionBar: View {
|
||||
if bar.liked {
|
||||
//notify(.delete, bar.our_like)
|
||||
} else {
|
||||
send_like(emoji: emoji)
|
||||
Task { await send_like(emoji: emoji) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,32 +183,42 @@ struct EventActionBar: View {
|
||||
let should_hide_repost = hide_items_without_activity && bar.boosts == 0
|
||||
let should_hide_reactions = hide_items_without_activity && bar.likes == 0
|
||||
let zap_model = self.damus_state.events.get_cache_data(self.event.id).zaps_model
|
||||
let should_hide_zap = hide_items_without_activity && zap_model.zap_total > 0
|
||||
let should_hide_zap = hide_items_without_activity && zap_model.zap_total == 0
|
||||
let should_hide_share_button = hide_items_without_activity
|
||||
// Only render the bar if at least one action is visible; avoids empty overlays/dots.
|
||||
let has_any_action = (!should_hide_chat_bubble && damus_state.keypair.privkey != nil)
|
||||
|| !should_hide_repost
|
||||
|| (show_like && !should_hide_reactions)
|
||||
|| (!should_hide_zap && self.lnurl != nil)
|
||||
|| !should_hide_share_button
|
||||
|
||||
return HStack(spacing: options.contains(.no_spread) ? 10 : 0) {
|
||||
if damus_state.keypair.privkey != nil && !should_hide_chat_bubble {
|
||||
self.reply_button
|
||||
}
|
||||
|
||||
if !should_hide_repost {
|
||||
self.space_if_spread
|
||||
self.repost_button
|
||||
}
|
||||
|
||||
if show_like && !should_hide_reactions {
|
||||
self.space_if_spread
|
||||
self.like_button
|
||||
}
|
||||
|
||||
if let lnurl = self.lnurl, !should_hide_zap {
|
||||
self.space_if_spread
|
||||
NoteZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: zap_model)
|
||||
}
|
||||
|
||||
if !should_hide_share_button {
|
||||
self.space_if_spread
|
||||
self.share_button
|
||||
return Group {
|
||||
if has_any_action {
|
||||
HStack(spacing: options.contains(.no_spread) ? 10 : 0) {
|
||||
if damus_state.keypair.privkey != nil && !should_hide_chat_bubble {
|
||||
self.reply_button
|
||||
}
|
||||
|
||||
if !should_hide_repost {
|
||||
self.space_if_spread
|
||||
self.repost_button
|
||||
}
|
||||
|
||||
if show_like && !should_hide_reactions {
|
||||
self.space_if_spread
|
||||
self.like_button
|
||||
}
|
||||
|
||||
if let lnurl = self.lnurl, !should_hide_zap {
|
||||
self.space_if_spread
|
||||
NoteZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: zap_model)
|
||||
}
|
||||
|
||||
if !should_hide_share_button {
|
||||
self.space_if_spread
|
||||
self.share_button
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,8 +235,15 @@ struct EventActionBar: View {
|
||||
}
|
||||
}
|
||||
|
||||
var event_relay_url_strings: [RelayURL] {
|
||||
let relays = damus_state.nostrNetwork.relaysForEvent(event: event)
|
||||
@State var event_relay_url_strings: [RelayURL] = []
|
||||
|
||||
func updateEventRelayURLStrings() async {
|
||||
let newValue = await fetchEventRelayURLStrings()
|
||||
self.event_relay_url_strings = newValue
|
||||
}
|
||||
|
||||
func fetchEventRelayURLStrings() async -> [RelayURL] {
|
||||
let relays = await damus_state.nostrNetwork.relaysForEvent(event: event)
|
||||
if !relays.isEmpty {
|
||||
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0 }
|
||||
}
|
||||
@@ -230,7 +254,11 @@ struct EventActionBar: View {
|
||||
var body: some View {
|
||||
self.content
|
||||
.onAppear {
|
||||
self.bar.update(damus: damus_state, evid: self.event.id)
|
||||
Task.detached(priority: .background, operation: {
|
||||
await self.bar.update(damus: damus_state, evid: self.event.id)
|
||||
self.fetchLNURL()
|
||||
await self.updateEventRelayURLStrings()
|
||||
})
|
||||
}
|
||||
.sheet(isPresented: $show_share_action, onDismiss: { self.show_share_action = false }) {
|
||||
if #available(iOS 16.0, *) {
|
||||
@@ -258,7 +286,10 @@ struct EventActionBar: View {
|
||||
}
|
||||
.onReceive(handle_notify(.update_stats)) { target in
|
||||
guard target == self.event.id else { return }
|
||||
self.bar.update(damus: self.damus_state, evid: target)
|
||||
Task {
|
||||
await self.bar.update(damus: self.damus_state, evid: target)
|
||||
await self.updateEventRelayURLStrings()
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.liked)) { liked in
|
||||
if liked.id != event.id {
|
||||
@@ -271,9 +302,9 @@ struct EventActionBar: View {
|
||||
}
|
||||
}
|
||||
|
||||
func send_like(emoji: String) {
|
||||
func send_like(emoji: String) async {
|
||||
guard let keypair = damus_state.keypair.to_full(),
|
||||
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
|
||||
let like_ev = await make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -281,7 +312,7 @@ struct EventActionBar: View {
|
||||
|
||||
generator.impactOccurred()
|
||||
|
||||
damus_state.nostrNetwork.postbox.send(like_ev)
|
||||
await damus_state.nostrNetwork.postbox.send(like_ev)
|
||||
}
|
||||
|
||||
// MARK: Helper structures
|
||||
|
||||
@@ -13,6 +13,7 @@ struct EventDetailBar: View {
|
||||
let target_pk: Pubkey
|
||||
|
||||
@ObservedObject var bar: ActionBarModel
|
||||
@State var relays: [RelayURL] = []
|
||||
|
||||
init(state: DamusState, target: NoteId, target_pk: Pubkey) {
|
||||
self.state = state
|
||||
@@ -61,7 +62,6 @@ struct EventDetailBar: View {
|
||||
}
|
||||
|
||||
if bar.relays > 0 {
|
||||
let relays = Array(state.nostrNetwork.pool.seen[target] ?? [])
|
||||
NavigationLink(value: Route.UserRelays(relays: relays)) {
|
||||
let nounString = pluralizedString(key: "relays_count", count: bar.relays)
|
||||
let noun = Text(nounString).foregroundColor(.gray)
|
||||
@@ -70,6 +70,18 @@ struct EventDetailBar: View {
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task { await self.updateSeenRelays() }
|
||||
}
|
||||
.onReceive(handle_notify(.update_stats)) { noteId in
|
||||
guard noteId == target else { return }
|
||||
Task { await self.updateSeenRelays() }
|
||||
}
|
||||
}
|
||||
|
||||
func updateSeenRelays() async {
|
||||
let relays = await Array(state.nostrNetwork.relayURLsThatSawNote(id: target) ?? [])
|
||||
self.relays = relays
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,15 @@ struct ShareAction: View {
|
||||
self._show_share = show_share
|
||||
}
|
||||
|
||||
var event_relay_url_strings: [RelayURL] {
|
||||
let relays = userProfile.damus.nostrNetwork.relaysForEvent(event: event)
|
||||
@State var event_relay_url_strings: [RelayURL] = []
|
||||
|
||||
func updateEventRelayURLStrings() async {
|
||||
let newValue = await fetchEventRelayURLStrings()
|
||||
self.event_relay_url_strings = newValue
|
||||
}
|
||||
|
||||
func fetchEventRelayURLStrings() async -> [RelayURL] {
|
||||
let relays = await userProfile.damus.nostrNetwork.relaysForEvent(event: event)
|
||||
if !relays.isEmpty {
|
||||
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0 }
|
||||
}
|
||||
@@ -80,8 +87,13 @@ struct ShareAction: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.update_stats), perform: { noteId in
|
||||
guard noteId == event.id else { return }
|
||||
Task { await self.updateEventRelayURLStrings() }
|
||||
})
|
||||
.onAppear() {
|
||||
userProfile.subscribeToFindRelays()
|
||||
Task { await self.updateEventRelayURLStrings() }
|
||||
}
|
||||
.onDisappear() {
|
||||
userProfile.unsubscribeFindRelays()
|
||||
|
||||
@@ -57,13 +57,13 @@ struct ReportView: View {
|
||||
.padding()
|
||||
}
|
||||
|
||||
func do_send_report() {
|
||||
func do_send_report() async {
|
||||
guard let selected_report_type,
|
||||
let ev = NostrEvent(content: report_message, keypair: keypair.to_keypair(), kind: 1984, tags: target.reportTags(type: selected_report_type)) else {
|
||||
return
|
||||
}
|
||||
|
||||
postbox.send(ev)
|
||||
await postbox.send(ev)
|
||||
|
||||
report_sent = true
|
||||
report_id = bech32_note_id(ev.id)
|
||||
@@ -116,7 +116,7 @@ struct ReportView: View {
|
||||
|
||||
Section(content: {
|
||||
Button(send_report_button_text) {
|
||||
do_send_report()
|
||||
Task { await do_send_report() }
|
||||
}
|
||||
.disabled(selected_report_type == nil)
|
||||
}, footer: {
|
||||
|
||||
@@ -19,13 +19,15 @@ struct RepostAction: View {
|
||||
|
||||
Button {
|
||||
dismiss()
|
||||
|
||||
guard let keypair = self.damus_state.keypair.to_full(),
|
||||
let boost = make_boost_event(keypair: keypair, boosted: self.event, relayURL: damus_state.nostrNetwork.relaysForEvent(event: self.event).first) else {
|
||||
return
|
||||
|
||||
Task {
|
||||
guard let keypair = self.damus_state.keypair.to_full(),
|
||||
let boost = await make_boost_event(keypair: keypair, boosted: self.event, relayURL: damus_state.nostrNetwork.relaysForEvent(event: self.event).first) else {
|
||||
return
|
||||
}
|
||||
|
||||
await damus_state.nostrNetwork.postbox.send(boost)
|
||||
}
|
||||
|
||||
damus_state.nostrNetwork.postbox.send(boost)
|
||||
} label: {
|
||||
Label(NSLocalizedString("Repost", comment: "Button to repost a note"), image: "repost")
|
||||
.frame(maxWidth: .infinity, minHeight: 50, maxHeight: 50, alignment: .leading)
|
||||
|
||||
@@ -27,7 +27,7 @@ struct Reposted: View {
|
||||
|
||||
// Show profile picture of the reposter only if the reposter is not the author of the reposted note.
|
||||
if pubkey != target.pubkey {
|
||||
ProfilePicView(pubkey: pubkey, size: eventview_pfp_size(.small), highlight: .none, profiles: damus.profiles, disable_animation: damus.settings.disable_animation)
|
||||
ProfilePicView(pubkey: pubkey, size: eventview_pfp_size(.small), highlight: .none, profiles: damus.profiles, disable_animation: damus.settings.disable_animation, damusState: damus)
|
||||
.onTapGesture {
|
||||
show_profile_action_sheet_if_enabled(damus_state: damus, pubkey: pubkey)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ struct Reposted: View {
|
||||
}
|
||||
|
||||
NavigationLink(value: Route.Reposts(reposts: .reposts(state: damus, target: target.id))) {
|
||||
Text(people_reposted_text(profiles: damus.profiles, pubkey: pubkey, reposts: reposts))
|
||||
Text(verbatim: people_reposted_text(profiles: damus.profiles, pubkey: pubkey, reposts: reposts))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
@@ -36,9 +36,24 @@ struct ChatEventView: View {
|
||||
@State var selected_emoji: Emoji?
|
||||
|
||||
@State private var isOnTopHalfOfScreen: Bool = false
|
||||
@ObservedObject var bar: ActionBarModel
|
||||
@StateObject private var bar: ActionBarModel
|
||||
@Environment(\.swipeViewGroupSelection) var swipeViewGroupSelection
|
||||
|
||||
init(event: NostrEvent, selected_event: NostrEvent, prev_ev: NostrEvent?, next_ev: NostrEvent?, damus_state: DamusState, thread: ThreadModel, scroll_to_event: ((_ id: NoteId) -> Void)?, focus_event: (() -> Void)?, highlight_bubble: Bool) {
|
||||
self.event = event
|
||||
self.selected_event = selected_event
|
||||
self.prev_ev = prev_ev
|
||||
self.next_ev = next_ev
|
||||
self.damus_state = damus_state
|
||||
self.thread = thread
|
||||
self.scroll_to_event = scroll_to_event
|
||||
self.focus_event = focus_event
|
||||
self.highlight_bubble = highlight_bubble
|
||||
|
||||
// Initialize @StateObject using wrappedValue
|
||||
_bar = StateObject(wrappedValue: make_actionbar_model(ev: event.id, damus: damus_state))
|
||||
}
|
||||
|
||||
enum PopoverState: String {
|
||||
case closed
|
||||
case open_emoji_selector
|
||||
@@ -83,7 +98,7 @@ struct ChatEventView: View {
|
||||
|
||||
var profile_picture_view: some View {
|
||||
VStack {
|
||||
ProfilePicView(pubkey: event.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation)
|
||||
ProfilePicView(pubkey: event.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation, damusState: damus_state)
|
||||
.onTapGesture {
|
||||
show_profile_action_sheet_if_enabled(damus_state: damus_state, pubkey: event.pubkey)
|
||||
}
|
||||
@@ -100,9 +115,7 @@ struct ChatEventView: View {
|
||||
// MARK: Zapping properties
|
||||
|
||||
var lnurl: String? {
|
||||
damus_state.profiles.lookup_with_timestamp(event.pubkey)?.map({ pr in
|
||||
pr?.lnurl
|
||||
}).value
|
||||
try? damus_state.profiles.lookup_lnurl(event.pubkey)
|
||||
}
|
||||
var zap_target: ZapTarget {
|
||||
ZapTarget.note(id: event.id, author: event.pubkey)
|
||||
@@ -130,22 +143,22 @@ struct ChatEventView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if let replying_to = event.direct_replies(),
|
||||
replying_to != selected_event.id {
|
||||
ReplyQuoteView(keypair: damus_state.keypair, quoter: event, event_id: replying_to, state: damus_state, thread: thread, options: reply_quote_options)
|
||||
if let reply_ref = event.direct_reply_ref(),
|
||||
reply_ref.note_id != selected_event.id {
|
||||
ReplyQuoteView(keypair: damus_state.keypair, quoter: event, event_id: reply_ref.note_id, state: damus_state, thread: thread, options: reply_quote_options, relayHint: reply_ref.relay)
|
||||
.background(is_ours ? DamusColors.adaptablePurpleBackground2 : DamusColors.adaptableGrey2)
|
||||
.foregroundColor(is_ours ? Color.damusAdaptablePurpleForeground : Color.damusAdaptableBlack)
|
||||
.cornerRadius(5)
|
||||
.onTapGesture {
|
||||
self.scroll_to_event?(replying_to)
|
||||
self.scroll_to_event?(reply_ref.note_id)
|
||||
}
|
||||
}
|
||||
|
||||
let blur_images = should_blur_images(settings: damus_state.settings, contacts: damus_state.contacts, ev: event, our_pubkey: damus_state.pubkey)
|
||||
NoteContentView(damus_state: damus_state, event: event, blur_images: blur_images, size: .normal, options: [.truncate_content])
|
||||
.padding(2)
|
||||
if let mention = first_eref_mention(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
|
||||
MentionView(damus_state: damus_state, mention: mention)
|
||||
if let mention = first_eref_mention_with_hints(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
|
||||
MentionView(damus_state: damus_state, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
|
||||
.background(DamusColors.adaptableWhite)
|
||||
.clipShape(RoundedRectangle(cornerSize: CGSize(width: 10, height: 10)))
|
||||
}
|
||||
@@ -197,8 +210,10 @@ struct ChatEventView: View {
|
||||
}
|
||||
.onChange(of: selected_emoji) { newSelectedEmoji in
|
||||
if let newSelectedEmoji {
|
||||
send_like(emoji: newSelectedEmoji.value)
|
||||
popover_state = .closed
|
||||
Task {
|
||||
await send_like(emoji: newSelectedEmoji.value)
|
||||
popover_state = .closed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,9 +248,9 @@ struct ChatEventView: View {
|
||||
)
|
||||
}
|
||||
|
||||
func send_like(emoji: String) {
|
||||
func send_like(emoji: String) async {
|
||||
guard let keypair = damus_state.keypair.to_full(),
|
||||
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
|
||||
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: await damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -244,7 +259,7 @@ struct ChatEventView: View {
|
||||
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||
generator.impactOccurred()
|
||||
|
||||
damus_state.nostrNetwork.postbox.send(like_ev)
|
||||
await damus_state.nostrNetwork.postbox.send(like_ev)
|
||||
}
|
||||
|
||||
var action_bar: some View {
|
||||
@@ -338,21 +353,17 @@ struct ChatEventView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||
return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
|
||||
return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false)
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||
return ChatEventView(event: test_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
|
||||
return ChatEventView(event: test_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false)
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||
return ChatEventView(event: test_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: true, bar: bar)
|
||||
return ChatEventView(event: test_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: true)
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||
return ChatEventView(event: test_super_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
|
||||
return ChatEventView(event: test_super_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false)
|
||||
}
|
||||
|
||||
@@ -23,9 +23,79 @@ struct ChatroomThreadView: View {
|
||||
@State var showStickyHeader: Bool = false
|
||||
@State var untrustedSectionOffset: CGFloat = 0
|
||||
|
||||
// Add state for reading progress (longform articles)
|
||||
@State private var readingProgress: CGFloat = 0
|
||||
@State private var viewportHeight: CGFloat = 0
|
||||
@State private var contentTopY: CGFloat = 0
|
||||
@State private var contentBottomY: CGFloat = 0
|
||||
@State private var initialTopY: CGFloat? = nil
|
||||
|
||||
// Focus mode: auto-hide chrome (nav bar + tab bar) during longform reading
|
||||
@State private var chromeHidden: Bool = false
|
||||
@State private var lastScrollY: CGFloat = 0
|
||||
/// Minimum scroll distance before triggering chrome hide/show
|
||||
private let scrollThreshold: CGFloat = 15
|
||||
|
||||
private static let untrusted_network_section_id = "untrusted-network-section"
|
||||
private static let sticky_header_adjusted_anchor = UnitPoint(x: UnitPoint.top.x, y: 0.2)
|
||||
|
||||
/// Returns true if the selected event is a longform article (kind 30023).
|
||||
var isLongformEvent: Bool {
|
||||
thread.selected_event.kind == 30023
|
||||
}
|
||||
|
||||
/// Updates reading progress based on scroll position.
|
||||
private func updateReadingProgress() {
|
||||
guard thread.selected_event.kind == 30023 else { return }
|
||||
guard viewportHeight > 0 else { return }
|
||||
|
||||
// Capture initial position on first update
|
||||
if initialTopY == nil {
|
||||
initialTopY = contentTopY
|
||||
}
|
||||
guard let startY = initialTopY else { return }
|
||||
|
||||
// Content height is constant (bottom - top in global coords)
|
||||
let contentHeight = contentBottomY - contentTopY
|
||||
guard contentHeight > 0 else { return }
|
||||
|
||||
// How much we've scrolled from initial position
|
||||
// As we scroll down, contentTopY decreases, so scrolled = startY - currentTopY
|
||||
let scrolled = startY - contentTopY
|
||||
let maxScroll = max(contentHeight - viewportHeight, 1)
|
||||
|
||||
let progress = scrolled / maxScroll
|
||||
readingProgress = min(max(progress, 0), 1)
|
||||
}
|
||||
|
||||
/// Updates chrome visibility based on scroll direction (longform only).
|
||||
/// Scrolling down hides chrome; tap to restore (scroll up does not restore).
|
||||
private func updateChromeVisibility(newY: CGFloat) {
|
||||
guard isLongformEvent else { return }
|
||||
|
||||
let delta = newY - lastScrollY
|
||||
|
||||
// Only hide chrome on scroll down, don't restore on scroll up (use tap instead)
|
||||
if delta < -scrollThreshold && !chromeHidden {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
chromeHidden = true
|
||||
}
|
||||
notify(.display_tabbar(false))
|
||||
}
|
||||
|
||||
// Always update lastScrollY to prevent stale delta accumulation
|
||||
lastScrollY = newY
|
||||
}
|
||||
|
||||
/// Shows chrome (nav bar + tab bar) - called on tap or when leaving view.
|
||||
private func showChrome() {
|
||||
guard chromeHidden else { return }
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
chromeHidden = false
|
||||
}
|
||||
notify(.display_tabbar(true))
|
||||
}
|
||||
|
||||
func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) {
|
||||
let adjustedAnchor: UnitPoint = showStickyHeader ? ChatroomThreadView.sticky_header_adjusted_anchor : .top
|
||||
|
||||
@@ -46,7 +116,14 @@ struct ChatroomThreadView: View {
|
||||
}
|
||||
|
||||
func trusted_event_filter(_ event: NostrEvent) -> Bool {
|
||||
!damus.settings.show_trusted_replies_first || damus.contacts.is_in_friendosphere(event.pubkey)
|
||||
// Always trust our own replies; otherwise gate by trusted network when the setting is enabled
|
||||
if event.pubkey == damus.pubkey {
|
||||
return true
|
||||
}
|
||||
if !damus.settings.show_trusted_replies_first {
|
||||
return true
|
||||
}
|
||||
return damus.contacts.is_in_friendosphere(event.pubkey)
|
||||
}
|
||||
|
||||
func ThreadedSwipeViewGroup(scroller: ScrollViewProxy, events: [NostrEvent]) -> some View {
|
||||
@@ -64,8 +141,7 @@ struct ChatroomThreadView: View {
|
||||
focus_event: {
|
||||
self.set_active_event(scroller: scroller, ev: ev)
|
||||
},
|
||||
highlight_bubble: highlighted_note_id == ev.id,
|
||||
bar: make_actionbar_model(ev: ev.id, damus: damus)
|
||||
highlight_bubble: highlighted_note_id == ev.id
|
||||
)
|
||||
.id(ev.id)
|
||||
.matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center)
|
||||
@@ -109,6 +185,22 @@ struct ChatroomThreadView: View {
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
ScrollView(.vertical) {
|
||||
VStack(spacing: 0) {
|
||||
// Top scroll position tracker
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.onChange(of: geo.frame(in: .global).minY) { newY in
|
||||
contentTopY = newY
|
||||
updateReadingProgress()
|
||||
updateChromeVisibility(newY: newY)
|
||||
}
|
||||
.onAppear {
|
||||
contentTopY = geo.frame(in: .global).minY
|
||||
lastScrollY = geo.frame(in: .global).minY
|
||||
}
|
||||
}
|
||||
.frame(height: 1)
|
||||
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
// MARK: - Parents events view
|
||||
ForEach(thread.parent_events, id: \.id) { parent_event in
|
||||
@@ -156,7 +248,8 @@ struct ChatroomThreadView: View {
|
||||
ThreadedSwipeViewGroup(scroller: scroller, events: trusted_events)
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
// Remove top padding for longform articles with sepia to eliminate gap
|
||||
.padding(.top, isLongformEvent && damus.settings.longform_sepia_mode ? 0 : nil)
|
||||
|
||||
// MARK: - Children view - outside trusted network
|
||||
if !untrusted_events.isEmpty {
|
||||
@@ -215,11 +308,41 @@ struct ChatroomThreadView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom scroll position tracker - placed before EndBlock so we measure article content, not padding
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.onChange(of: geo.frame(in: .global).minY) { newY in
|
||||
contentBottomY = newY
|
||||
updateReadingProgress()
|
||||
}
|
||||
.onAppear {
|
||||
contentBottomY = geo.frame(in: .global).minY
|
||||
}
|
||||
}
|
||||
.frame(height: 1)
|
||||
|
||||
EndBlock()
|
||||
|
||||
HStack {}
|
||||
.frame(height: tabHeight + getSafeAreaBottom())
|
||||
} // End VStack wrapper
|
||||
}
|
||||
.background(
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
viewportHeight = geo.size.height
|
||||
}
|
||||
.onChange(of: geo.size.height) { newHeight in
|
||||
// Reset baseline on significant height change (orientation, text size)
|
||||
if abs(newHeight - viewportHeight) > 50 {
|
||||
initialTopY = nil
|
||||
}
|
||||
viewportHeight = newHeight
|
||||
updateReadingProgress()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if showStickyHeader && !untrusted_events.isEmpty {
|
||||
VStack {
|
||||
@@ -234,6 +357,15 @@ struct ChatroomThreadView: View {
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
.zIndex(1)
|
||||
}
|
||||
|
||||
// Reading progress bar - show for longform articles
|
||||
if thread.selected_event.kind == 30023 {
|
||||
VStack(spacing: 0) {
|
||||
ReadingProgressBar(progress: readingProgress)
|
||||
Spacer()
|
||||
}
|
||||
.zIndex(100)
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.post), perform: { notify in
|
||||
switch notify {
|
||||
@@ -251,11 +383,37 @@ struct ChatroomThreadView: View {
|
||||
}
|
||||
.onAppear() {
|
||||
thread.subscribe()
|
||||
scroll_to_event(scroller: scroller, id: thread.selected_event.id, delay: 0.1, animate: false)
|
||||
// Use .top anchor for longform articles so they open at the title,
|
||||
// keep .bottom for regular notes to preserve parent context visibility
|
||||
let anchor: UnitPoint = thread.selected_event.known_kind == .longform ? .top : .bottom
|
||||
scroll_to_event(scroller: scroller, id: thread.selected_event.id, delay: 0.1, animate: false, anchor: anchor)
|
||||
// Ensure chrome is visible when view appears (handles interrupted transitions)
|
||||
if isLongformEvent {
|
||||
chromeHidden = false
|
||||
notify(.display_tabbar(true))
|
||||
}
|
||||
}
|
||||
.onChange(of: thread.selected_event.id) { _ in
|
||||
// Reset reading progress when switching to a different event
|
||||
initialTopY = nil
|
||||
readingProgress = 0
|
||||
// Restore chrome when switching events (user tapped to select)
|
||||
showChrome()
|
||||
}
|
||||
.onDisappear() {
|
||||
thread.unsubscribe()
|
||||
showChrome() // Restore chrome when leaving view
|
||||
}
|
||||
.navigationBarHidden(chromeHidden && isLongformEvent)
|
||||
// Tap anywhere to show chrome when hidden (doesn't block other gestures)
|
||||
.simultaneousGesture(
|
||||
TapGesture()
|
||||
.onEnded { _ in
|
||||
if isLongformEvent && chromeHidden {
|
||||
showChrome()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,3 +437,4 @@ struct ChatroomView_Previews: PreviewProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,12 +56,7 @@ class ThreadModel: ObservableObject {
|
||||
/// The damus state, needed to access the relay pool and load the thread events
|
||||
let damus_state: DamusState
|
||||
|
||||
private let profiles_subid = UUID().description
|
||||
private let base_subid = UUID().description
|
||||
private let meta_subid = UUID().description
|
||||
private var subids: [String] {
|
||||
return [profiles_subid, base_subid, meta_subid]
|
||||
}
|
||||
private var listener: Task<Void, Never>?
|
||||
|
||||
|
||||
// MARK: Initialization
|
||||
@@ -86,17 +81,6 @@ class ThreadModel: ObservableObject {
|
||||
|
||||
// MARK: Relay pool subscription management
|
||||
|
||||
/// Unsubscribe from events in the relay pool. Call this when unloading the view
|
||||
func unsubscribe() {
|
||||
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: base_subid)
|
||||
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: meta_subid)
|
||||
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: profiles_subid)
|
||||
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid)
|
||||
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: meta_subid)
|
||||
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid)
|
||||
Log.info("unsubscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
|
||||
}
|
||||
|
||||
/// Subscribe to events in this thread. Call this when loading the view.
|
||||
func subscribe() {
|
||||
var meta_events = NostrFilter()
|
||||
@@ -127,10 +111,19 @@ class ThreadModel: ObservableObject {
|
||||
|
||||
let base_filters = [event_filter, ref_events]
|
||||
let meta_filters = [meta_events, quote_events]
|
||||
|
||||
Log.info("subscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event)
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event)
|
||||
|
||||
self.listener?.cancel()
|
||||
self.listener = Task {
|
||||
Log.info("subscribing to thread %s ", for: .render, original_event.id.hex())
|
||||
for await event in damus_state.nostrNetwork.reader.streamIndefinitely(filters: base_filters + meta_filters) {
|
||||
event.justUseACopy({ handle_event(ev: $0) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
self.listener?.cancel()
|
||||
self.listener = nil
|
||||
}
|
||||
|
||||
/// Adds an event to this thread.
|
||||
@@ -175,34 +168,25 @@ class ThreadModel: ObservableObject {
|
||||
///
|
||||
/// Marked as private because it is this class' responsibility to load events, not the view's. Simplify the interface
|
||||
@MainActor
|
||||
private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
let (sub_id, done) = handle_subid_event(pool: damus_state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sid, ev in
|
||||
guard subids.contains(sid) else {
|
||||
return
|
||||
private func handle_event(ev: NostrEvent) {
|
||||
if ev.known_kind == .zap {
|
||||
process_zap_event(state: damus_state, ev: ev) { zap in
|
||||
|
||||
}
|
||||
|
||||
if ev.known_kind == .zap {
|
||||
process_zap_event(state: damus_state, ev: ev) { zap in
|
||||
|
||||
}
|
||||
} else if ev.is_textlike {
|
||||
// handle thread quote reposts, we just count them instead of
|
||||
// adding them to the thread
|
||||
if let target = ev.is_quote_repost, target == self.selected_event.id {
|
||||
//let _ = self.damus_state.quote_reposts.add_event(ev, target: target)
|
||||
} else {
|
||||
self.add_event(ev, keypair: damus_state.keypair)
|
||||
}
|
||||
} else if ev.is_textlike {
|
||||
// handle thread quote reposts, we just count them instead of
|
||||
// adding them to the thread
|
||||
if let target = ev.is_quote_repost, target == self.selected_event.id {
|
||||
//let _ = self.damus_state.quote_reposts.add_event(ev, target: target)
|
||||
} else {
|
||||
self.add_event(ev, keypair: damus_state.keypair)
|
||||
}
|
||||
}
|
||||
|
||||
guard done, let sub_id, subids.contains(sub_id) else {
|
||||
return
|
||||
else if ev.known_kind == .boost {
|
||||
damus_state.boosts.add_event(ev, target: original_event.id)
|
||||
}
|
||||
|
||||
if sub_id == self.base_subid {
|
||||
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
|
||||
load_profiles(context: "thread", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(Array(event_map.events)), damus_state: damus_state, txn: txn)
|
||||
else if ev.known_kind == .like {
|
||||
damus_state.likes.add_event(ev, target: original_event.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Displays a compact preview of the event being replied to.
|
||||
///
|
||||
/// Supports NIP-10 relay hints to fetch events from relays not in the user's pool.
|
||||
struct ReplyQuoteView: View {
|
||||
let keypair: Keypair
|
||||
let quoter: NostrEvent
|
||||
@@ -14,13 +17,30 @@ struct ReplyQuoteView: View {
|
||||
let state: DamusState
|
||||
@ObservedObject var thread: ThreadModel
|
||||
let options: EventViewOptions
|
||||
|
||||
let relayHint: String?
|
||||
|
||||
init(keypair: Keypair, quoter: NostrEvent, event_id: NoteId, state: DamusState, thread: ThreadModel, options: EventViewOptions, relayHint: String? = nil) {
|
||||
self.keypair = keypair
|
||||
self.quoter = quoter
|
||||
self.event_id = event_id
|
||||
self.state = state
|
||||
self.thread = thread
|
||||
self.options = options
|
||||
self.relayHint = relayHint
|
||||
}
|
||||
|
||||
@State var can_show_event = true
|
||||
|
||||
func update_should_show_event(event: NdbNote) async {
|
||||
self.can_show_event = await should_show_event(event: event, damus_state: state)
|
||||
}
|
||||
|
||||
func content(event: NdbNote) -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .center) {
|
||||
if should_show_event(event: event, damus_state: state) {
|
||||
ProfilePicView(pubkey: event.pubkey, size: 14, highlight: .reply, profiles: state.profiles, disable_animation: false)
|
||||
if can_show_event {
|
||||
ProfilePicView(pubkey: event.pubkey, size: 14, highlight: .reply, profiles: state.profiles, disable_animation: false, damusState: state)
|
||||
let blur_images = should_blur_images(settings: state.settings, contacts: state.contacts, ev: event, our_pubkey: state.pubkey)
|
||||
NoteContentView(damus_state: state, event: event, blur_images: blur_images, size: .small, options: options)
|
||||
.font(.callout)
|
||||
@@ -56,6 +76,17 @@ struct ReplyQuoteView: View {
|
||||
Group {
|
||||
if let event = state.events.lookup(event_id) {
|
||||
self.content(event: event)
|
||||
.onAppear {
|
||||
Task { await self.update_should_show_event(event: event) }
|
||||
}
|
||||
} else if let relayHint, let relayURL = RelayURL(relayHint) {
|
||||
// Event not in cache - try to fetch using relay hint
|
||||
EventLoaderView(damus_state: state, event_id: event_id, relayHints: [relayURL]) { loaded_event in
|
||||
self.content(event: loaded_event)
|
||||
.onAppear {
|
||||
Task { await self.update_should_show_event(event: loaded_event) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Manages user's favorites using NIP-81 contact cards
|
||||
class ContactCardManager: ContactCard {
|
||||
private(set) var favorites: Set<Pubkey> = []
|
||||
private var latestContactCardEvents: [Pubkey: NostrEvent] = [:]
|
||||
public static let FAVORITE_TAG = "favorite"
|
||||
public static let CONTACT_SET = "n"
|
||||
public static let TARGET_PUBLIC_KEY = "d"
|
||||
|
||||
public init() {}
|
||||
|
||||
func isFavorite(_ pubkey: Pubkey) -> Bool {
|
||||
favorites.contains(pubkey)
|
||||
}
|
||||
|
||||
func toggleFavorite(_ pubkey: Pubkey, postbox: PostBox, keyPair: FullKeypair?) {
|
||||
if favorites.contains(pubkey) {
|
||||
favorites.remove(pubkey)
|
||||
handleFavorite(target: pubkey, favorite: false, postbox: postbox, keypair: keyPair)
|
||||
} else {
|
||||
favorites.insert(pubkey)
|
||||
handleFavorite(target: pubkey, favorite: true, postbox: postbox, keypair: keyPair)
|
||||
}
|
||||
}
|
||||
|
||||
func loadEvent(_ ev: NostrEvent, pubkey: Pubkey) {
|
||||
guard let kind = ev.known_kind, kind == .contact_card else {
|
||||
return
|
||||
}
|
||||
// we only care about our contact cards
|
||||
guard ev.pubkey == pubkey else {
|
||||
return
|
||||
}
|
||||
|
||||
var targetPubkey: Pubkey?
|
||||
var isFavorite = false
|
||||
for tag in ev.tags {
|
||||
guard tag.count >= 2 else { continue }
|
||||
let tagType = tag[0].string()
|
||||
let tagValue = tag[1].string()
|
||||
if tagType == Self.TARGET_PUBLIC_KEY {
|
||||
targetPubkey = Pubkey(hex: tagValue)
|
||||
} else if tagType == Self.CONTACT_SET && tagValue == Self.FAVORITE_TAG {
|
||||
isFavorite = true
|
||||
}
|
||||
}
|
||||
|
||||
guard let targetPubkey else {
|
||||
return
|
||||
}
|
||||
|
||||
// Only process if this event is new
|
||||
if let existingEvent = latestContactCardEvents[targetPubkey] {
|
||||
guard ev.created_at > existingEvent.created_at else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if isFavorite {
|
||||
favorites.insert(targetPubkey)
|
||||
} else {
|
||||
favorites.remove(targetPubkey)
|
||||
}
|
||||
|
||||
latestContactCardEvents[targetPubkey] = ev
|
||||
notify(.favoriteUpdated())
|
||||
}
|
||||
|
||||
var filter: (NostrEvent) -> Bool {
|
||||
{ [weak self] ev in
|
||||
guard let self else { return false }
|
||||
return self.isFavorite(ev.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
private func createFavoriteContactCard(keypair: FullKeypair, target: Pubkey) -> NostrEvent? {
|
||||
let kind = NostrKind.contact_card.rawValue
|
||||
let tags = [
|
||||
[Self.TARGET_PUBLIC_KEY, target.hex()],
|
||||
[Self.CONTACT_SET, Self.FAVORITE_TAG]
|
||||
]
|
||||
return NostrEvent(content: "", keypair: keypair.to_keypair(), kind: kind, tags: tags)
|
||||
}
|
||||
|
||||
private func createUnfavoriteContactCard(keypair: FullKeypair, target: Pubkey) -> NostrEvent? {
|
||||
let kind = NostrKind.contact_card.rawValue
|
||||
let tags = [
|
||||
[Self.TARGET_PUBLIC_KEY, target.hex()]
|
||||
]
|
||||
return NostrEvent(content: "", keypair: keypair.to_keypair(), kind: kind, tags: tags)
|
||||
}
|
||||
|
||||
private func handleFavorite(target: Pubkey, favorite: Bool, postbox: PostBox, keypair: FullKeypair?) {
|
||||
guard let keypair else {
|
||||
return
|
||||
}
|
||||
let ev: NostrEvent?
|
||||
if favorite {
|
||||
ev = createFavoriteContactCard(keypair: keypair, target: target)
|
||||
} else {
|
||||
ev = createUnfavoriteContactCard(keypair: keypair, target: target)
|
||||
}
|
||||
|
||||
guard let ev else {
|
||||
return
|
||||
}
|
||||
|
||||
if favorite {
|
||||
favorites.insert(target)
|
||||
} else {
|
||||
favorites.remove(target)
|
||||
}
|
||||
|
||||
Task { await postbox.send(ev) }
|
||||
latestContactCardEvents[target] = ev
|
||||
notify(.favoriteUpdated())
|
||||
}
|
||||
}
|
||||
|
||||
protocol ContactCard {
|
||||
func isFavorite(_ pubkey: Pubkey) -> Bool
|
||||
func toggleFavorite(_ pubkey: Pubkey, postbox: PostBox, keyPair: FullKeypair?)
|
||||
func loadEvent(_ ev: NostrEvent, pubkey: Pubkey)
|
||||
var filter: (NostrEvent) -> Bool { get }
|
||||
var favorites: Set<Pubkey> { get }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
class ContactCardManagerMock: ContactCard {
|
||||
var event: NostrEvent?
|
||||
var favorites: Set<Pubkey> = []
|
||||
|
||||
func isFavorite(_ pubkey: Pubkey) -> Bool {
|
||||
favorites.contains(pubkey)
|
||||
}
|
||||
|
||||
func toggleFavorite(_ pubkey: Pubkey, postbox: PostBox, keyPair: FullKeypair?) {
|
||||
if favorites.contains(pubkey) {
|
||||
favorites.remove(pubkey)
|
||||
} else {
|
||||
favorites.insert(pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
func loadEvent(_ ev: NostrEvent, pubkey: Pubkey) {
|
||||
event = ev
|
||||
}
|
||||
|
||||
var filter: ((_ ev: NostrEvent) -> Bool) {
|
||||
{ ev in self.favorites.contains(ev.pubkey) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FavoriteButtonView: View {
|
||||
let pubkey: Pubkey
|
||||
let damus_state: DamusState
|
||||
|
||||
@State private var favorite: Bool
|
||||
|
||||
init(pubkey: Pubkey, damus_state: DamusState) {
|
||||
self.pubkey = pubkey
|
||||
self.damus_state = damus_state
|
||||
self._favorite = State(initialValue: damus_state.contactCards.isFavorite(pubkey))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(
|
||||
action: {
|
||||
damus_state.contactCards.toggleFavorite(
|
||||
pubkey,
|
||||
postbox: damus_state.nostrNetwork.postbox,
|
||||
keyPair: damus_state.keypair.to_full()
|
||||
)
|
||||
favorite.toggle()
|
||||
}) {
|
||||
Image(favorite ? "heart.fill" : "heart")
|
||||
.foregroundColor(favorite ? DamusColors.purple : .primary)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
struct FavoriteButtonView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
FavoriteButtonView(
|
||||
pubkey: test_pubkey,
|
||||
damus_state: test_damus_state
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ struct DMChatView: View, KeyboardReadable {
|
||||
var Header: some View {
|
||||
return NavigationLink(value: Route.ProfileByKey(pubkey: pubkey)) {
|
||||
HStack {
|
||||
ProfilePicView(pubkey: pubkey, size: 24, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
ProfilePicView(pubkey: pubkey, size: 24, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation, damusState: damus_state)
|
||||
|
||||
ProfileName(pubkey: pubkey, damus: damus_state)
|
||||
}
|
||||
@@ -108,7 +108,7 @@ struct DMChatView: View, KeyboardReadable {
|
||||
Button(
|
||||
role: .none,
|
||||
action: {
|
||||
send_message()
|
||||
Task { await send_message() }
|
||||
}
|
||||
) {
|
||||
Label("", image: "send")
|
||||
@@ -124,7 +124,7 @@ struct DMChatView: View, KeyboardReadable {
|
||||
*/
|
||||
}
|
||||
|
||||
func send_message() {
|
||||
func send_message() async {
|
||||
let tags = [["p", pubkey.hex()]]
|
||||
guard let post_blocks = parse_post_blocks(content: dms.draft)?.blocks else {
|
||||
return
|
||||
@@ -138,7 +138,7 @@ struct DMChatView: View, KeyboardReadable {
|
||||
|
||||
dms.draft = ""
|
||||
|
||||
damus_state.nostrNetwork.postbox.send(dm)
|
||||
await damus_state.nostrNetwork.postbox.send(dm)
|
||||
|
||||
handle_incoming_dm(ev: dm, our_pubkey: damus_state.pubkey, dms: damus_state.dms, prev_events: NewEventsBits())
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ struct DMView: View {
|
||||
|
||||
var Mention: some View {
|
||||
Group {
|
||||
if let mention = first_eref_mention(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
|
||||
BuilderEventView(damus: damus_state, event_id: mention.ref)
|
||||
if let mention = first_eref_mention_with_hints(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
|
||||
BuilderEventView(damus: damus_state, event_id: mention.noteId, relayHints: mention.relayHints)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ enum DMType: Hashable {
|
||||
|
||||
struct DirectMessagesView: View {
|
||||
let damus_state: DamusState
|
||||
|
||||
let home: HomeModel
|
||||
|
||||
@State var dm_type: DMType = .friend
|
||||
@ObservedObject var model: DirectMessagesModel
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
@@ -37,6 +38,12 @@ struct DirectMessagesView: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.refreshable {
|
||||
// Fetch full DM history without the `since` optimization.
|
||||
// This allows users to manually sync older DMs that may have
|
||||
// been missed due to the optimized network filter.
|
||||
await home.fetchFullDMHistory()
|
||||
}
|
||||
.padding(.bottom, tabHeight)
|
||||
}
|
||||
|
||||
@@ -122,6 +129,7 @@ struct DirectMessagesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageModel]) -> Bool {
|
||||
for dm in dms {
|
||||
if !FriendFilter.friends_of_friends.filter(contacts: contacts, pubkey: dm.pubkey) {
|
||||
@@ -135,6 +143,6 @@ func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageMo
|
||||
struct DirectMessagesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let ds = test_damus_state
|
||||
DirectMessagesView(damus_state: ds, model: ds.dms, settings: ds.settings, subtitle: .constant(nil))
|
||||
DirectMessagesView(damus_state: ds, home: HomeModel(), model: ds.dms, settings: ds.settings, subtitle: .constant(nil))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,34 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A view that displays an embedded/quoted Nostr event.
|
||||
///
|
||||
/// Supports NIP-01/NIP-10 relay hints to fetch events from relays not in the user's pool.
|
||||
struct BuilderEventView: View {
|
||||
let damus: DamusState
|
||||
let event_id: NoteId
|
||||
let event: NostrEvent?
|
||||
|
||||
let relayHints: [RelayURL]
|
||||
|
||||
/// Creates a builder event view with a pre-loaded event.
|
||||
init(damus: DamusState, event: NostrEvent) {
|
||||
self.event = event
|
||||
self.damus = damus
|
||||
self.event_id = event.id
|
||||
self.relayHints = []
|
||||
}
|
||||
|
||||
init(damus: DamusState, event_id: NoteId) {
|
||||
|
||||
/// Creates a builder event view that will load the event by ID.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - damus: The app's shared state.
|
||||
/// - event_id: The ID of the event to load.
|
||||
/// - relayHints: Optional relay URLs where the event may be found (per NIP-01/NIP-10).
|
||||
init(damus: DamusState, event_id: NoteId, relayHints: [RelayURL] = []) {
|
||||
self.event_id = event_id
|
||||
self.damus = damus
|
||||
self.event = nil
|
||||
self.relayHints = relayHints
|
||||
}
|
||||
|
||||
func Event(event: NostrEvent) -> some View {
|
||||
@@ -39,7 +52,7 @@ struct BuilderEventView: View {
|
||||
if let event {
|
||||
self.Event(event: event)
|
||||
} else {
|
||||
EventLoaderView(damus_state: damus, event_id: self.event_id) { loaded_event in
|
||||
EventLoaderView(damus_state: damus, event_id: self.event_id, relayHints: relayHints) { loaded_event in
|
||||
self.Event(event: loaded_event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,26 +13,36 @@ struct EventTop: View {
|
||||
let event: NostrEvent
|
||||
let pubkey: Pubkey
|
||||
let is_anon: Bool
|
||||
let size: EventViewKind
|
||||
let options: EventViewOptions
|
||||
|
||||
init(state: DamusState, event: NostrEvent, pubkey: Pubkey, is_anon: Bool) {
|
||||
init(state: DamusState, event: NostrEvent, pubkey: Pubkey, is_anon: Bool, size: EventViewKind, options: EventViewOptions) {
|
||||
self.state = state
|
||||
self.event = event
|
||||
self.pubkey = pubkey
|
||||
self.is_anon = is_anon
|
||||
self.size = size
|
||||
self.options = options
|
||||
}
|
||||
|
||||
func ProfileName(is_anon: Bool) -> some View {
|
||||
let pk = is_anon ? ANON_PUBKEY : self.pubkey
|
||||
return EventProfileName(pubkey: pk, damus: state, size: .normal)
|
||||
return EventProfileName(pubkey: pk, damus: state, size: size)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
ProfileName(is_anon: is_anon)
|
||||
TimeDot()
|
||||
RelativeTime(time: state.events.get_cache_data(event.id).relative_time)
|
||||
RelativeTime(time: state.events.get_cache_data(event.id).relative_time, size: size, font_size: state.settings.font_size)
|
||||
if let clientTag = event.clientTag {
|
||||
TimeDot()
|
||||
ClientTagLabel(clientTag: clientTag, size: size, font_size: state.settings.font_size)
|
||||
}
|
||||
Spacer()
|
||||
EventMenuContext(damus: state, event: event)
|
||||
if !options.contains(.no_context_menu) {
|
||||
EventMenuContext(damus: state, event: event)
|
||||
}
|
||||
}
|
||||
.lineLimit(1)
|
||||
}
|
||||
@@ -40,6 +50,20 @@ struct EventTop: View {
|
||||
|
||||
struct EventTop_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
EventTop(state: test_damus_state, event: test_note, pubkey: test_note.pubkey, is_anon: false)
|
||||
EventTop(state: test_damus_state, event: test_note, pubkey: test_note.pubkey, is_anon: false, size: .normal, options: [])
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the client name that published an event (e.g., "via Damus").
|
||||
struct ClientTagLabel: View {
|
||||
let clientTag: ClientTagMetadata
|
||||
let size: EventViewKind
|
||||
let font_size: Double
|
||||
|
||||
var body: some View {
|
||||
Text(String(format: NSLocalizedString("via %@", comment: "Label indicating which client published the event"), clientTag.name))
|
||||
.font(eventviewsize_to_font(size, font_size: font_size))
|
||||
.foregroundColor(.gray)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ import SwiftUI
|
||||
|
||||
struct RelativeTime: View {
|
||||
@ObservedObject var time: RelativeTimeModel
|
||||
let size: EventViewKind
|
||||
let font_size: Double
|
||||
|
||||
var body: some View {
|
||||
Text(verbatim: "\(time.value)")
|
||||
.font(.system(size: 16))
|
||||
.font(eventviewsize_to_font(size, font_size: font_size))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +22,6 @@ struct RelativeTime: View {
|
||||
|
||||
struct RelativeTime_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
RelativeTime(time: RelativeTimeModel())
|
||||
RelativeTime(time: RelativeTimeModel(), size: .normal, font_size: 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,14 +38,10 @@ func reply_desc(ndb: Ndb, event: NostrEvent, replying_to: NostrEvent?, locale: L
|
||||
return NSLocalizedString("Replying to self", bundle: bundle, comment: "Label to indicate that the user is replying to themself.")
|
||||
}
|
||||
|
||||
guard let profile_txn = NdbTxn(ndb: ndb) else {
|
||||
return ""
|
||||
}
|
||||
|
||||
let names: [String] = pubkeys.map { pk in
|
||||
let prof = ndb.lookup_profile_with_txn(pk, txn: profile_txn)
|
||||
let profile = try? ndb.lookup_profile_and_copy(pk)
|
||||
|
||||
return Profile.displayName(profile: prof?.profile, pubkey: pk).username.truncate(maxLength: 50)
|
||||
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
|
||||
}
|
||||
|
||||
let uniqueNames = NSOrderedSet(array: names).array as! [String]
|
||||
|
||||
@@ -13,7 +13,9 @@ struct EventBody: View {
|
||||
let size: EventViewKind
|
||||
let should_blur_img: Bool
|
||||
let options: EventViewOptions
|
||||
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind, should_blur_img: Bool? = nil, options: EventViewOptions) {
|
||||
self.damus_state = damus_state
|
||||
self.event = event
|
||||
@@ -29,11 +31,35 @@ struct EventBody: View {
|
||||
|
||||
var body: some View {
|
||||
if event.known_kind == .longform {
|
||||
LongformPreviewBody(state: damus_state, ev: event, options: options, header: true)
|
||||
let isFullArticle = !options.contains(.truncate_content)
|
||||
let sepiaEnabled = damus_state.settings.longform_sepia_mode
|
||||
|
||||
// truncated longform bodies are just the preview
|
||||
if !options.contains(.truncate_content) {
|
||||
note_content
|
||||
if isFullArticle && sepiaEnabled {
|
||||
// Wrap in single sepia container to eliminate gaps
|
||||
VStack(spacing: 0) {
|
||||
LongformPreviewBody(
|
||||
state: damus_state,
|
||||
ev: event,
|
||||
options: options,
|
||||
header: true,
|
||||
sepiaEnabled: true
|
||||
)
|
||||
note_content
|
||||
}
|
||||
.background(DamusColors.sepiaBackground(for: colorScheme))
|
||||
} else {
|
||||
LongformPreviewBody(
|
||||
state: damus_state,
|
||||
ev: event,
|
||||
options: options,
|
||||
header: true,
|
||||
sepiaEnabled: false
|
||||
)
|
||||
|
||||
// truncated longform bodies are just the preview
|
||||
if isFullArticle {
|
||||
note_content
|
||||
}
|
||||
}
|
||||
} else if event.known_kind == .highlight {
|
||||
HighlightBodyView(state: damus_state, ev: event, options: options)
|
||||
|
||||
@@ -7,74 +7,141 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// This view handles the loading logic for Nostr events, so that you can easily use views that require `NostrEvent`, even if you only have a `NoteId`
|
||||
/// This view handles the loading logic for Nostr events, so that you can easily use views that require `NostrEvent`, even if you only have a `NoteId`.
|
||||
///
|
||||
/// Supports NIP-01/NIP-10 relay hints to fetch events from relays not in the user's pool.
|
||||
struct EventLoaderView<Content: View>: View {
|
||||
let damus_state: DamusState
|
||||
let event_id: NoteId
|
||||
let relayHints: [RelayURL]
|
||||
@State var event: NostrEvent?
|
||||
@State var subscription_uuid: String = UUID().description
|
||||
@State private var eventNotFound: Bool = false
|
||||
@State private var isReloading: Bool = false
|
||||
let content: (NostrEvent) -> Content
|
||||
|
||||
init(damus_state: DamusState, event_id: NoteId, @ViewBuilder content: @escaping (NostrEvent) -> Content) {
|
||||
|
||||
/// Creates an event loader view.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - damus_state: The app's shared state.
|
||||
/// - event_id: The ID of the event to load.
|
||||
/// - relayHints: Optional relay URLs where the event may be found (per NIP-01/NIP-10).
|
||||
/// - content: A view builder that receives the loaded event.
|
||||
init(damus_state: DamusState, event_id: NoteId, relayHints: [RelayURL] = [], @ViewBuilder content: @escaping (NostrEvent) -> Content) {
|
||||
self.damus_state = damus_state
|
||||
self.event_id = event_id
|
||||
self.relayHints = relayHints
|
||||
self.content = content
|
||||
let event = damus_state.events.lookup(event_id)
|
||||
_event = State(initialValue: event)
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subscription_uuid)
|
||||
}
|
||||
|
||||
func subscribe(filters: [NostrFilter]) {
|
||||
damus_state.nostrNetwork.pool.register_handler(sub_id: subscription_uuid, handler: handle_event)
|
||||
damus_state.nostrNetwork.pool.send(.subscribe(.init(filters: filters, sub_id: subscription_uuid)))
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
guard case .nostr_event(let nostr_response) = ev else {
|
||||
return
|
||||
/// Loads the event from nostrdb or the network using relay hints or the default relay pool.
|
||||
///
|
||||
/// This method attempts to fetch the event via `nostrNetwork.reader.lookup`, which first checks
|
||||
/// nostrdb for a cached copy, then queries the network if not found locally. Network queries use
|
||||
/// either the specified relay hints (if provided) or the user's relay pool (if no hints are provided).
|
||||
/// On success, sets `event` and clears `eventNotFound`. On failure, sets `eventNotFound` to true.
|
||||
///
|
||||
/// Side effects:
|
||||
/// - Updates `event` with the fetched event on success
|
||||
/// - Updates `eventNotFound` flag based on the result
|
||||
/// - Logs debug information when relay hints are used
|
||||
func load() async {
|
||||
let targetRelays = relayHints.isEmpty ? nil : relayHints
|
||||
#if DEBUG
|
||||
if let targetRelays, !targetRelays.isEmpty {
|
||||
print("[relay-hints] EventLoaderView: Loading event \(event_id.hex().prefix(8))... with \(targetRelays.count) relay hint(s): \(targetRelays.map { $0.absoluteString })")
|
||||
}
|
||||
|
||||
guard case .event(let id, let nostr_event) = nostr_response else {
|
||||
return
|
||||
#endif
|
||||
let lender = try? await damus_state.nostrNetwork.reader.lookup(noteId: self.event_id, to: targetRelays)
|
||||
if let foundEvent = lender?.justGetACopy() {
|
||||
event = foundEvent
|
||||
eventNotFound = false
|
||||
}
|
||||
|
||||
guard id == subscription_uuid else {
|
||||
return
|
||||
else {
|
||||
// Handle nil case: event was not found
|
||||
eventNotFound = true
|
||||
}
|
||||
|
||||
if event != nil {
|
||||
return
|
||||
#if DEBUG
|
||||
if let targetRelays, !targetRelays.isEmpty {
|
||||
print("[relay-hints] EventLoaderView: Event \(event_id.hex().prefix(8))... loaded: \(event != nil)")
|
||||
}
|
||||
|
||||
event = nostr_event
|
||||
|
||||
unsubscribe()
|
||||
#endif
|
||||
}
|
||||
|
||||
func load() {
|
||||
subscribe(filters: [
|
||||
NostrFilter(ids: [self.event_id], limit: 1)
|
||||
])
|
||||
/// Retries loading the event and displays loading state during the operation.
|
||||
///
|
||||
/// This method sets the `isReloading` flag to true, calls `load()`, and resets
|
||||
/// the flag when complete. It is typically triggered by user action (e.g., "Try Again" button).
|
||||
///
|
||||
/// Side effects:
|
||||
/// - Updates `isReloading` to true during the operation
|
||||
/// - Delegates to `load()`, which updates `event` and `eventNotFound`
|
||||
/// - Resets `isReloading` to false after completion
|
||||
func retry() async {
|
||||
isReloading = true
|
||||
await load()
|
||||
isReloading = false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
VStack {
|
||||
if let event {
|
||||
self.content(event)
|
||||
} else if eventNotFound {
|
||||
not_found
|
||||
} else {
|
||||
ProgressView().padding()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
.task {
|
||||
guard event == nil else {
|
||||
return
|
||||
}
|
||||
self.load()
|
||||
await self.load()
|
||||
}
|
||||
}
|
||||
|
||||
var not_found: some View {
|
||||
VStack(spacing: 0) {
|
||||
LoadableNostrEventView.SomethingWrong(
|
||||
imageSystemName: "questionmark.app",
|
||||
heading: NSLocalizedString("Note not found", comment: "Heading for the event loader view in a not found error state."),
|
||||
description: NSLocalizedString("This note may have been deleted, or it might not be available on the relays you're connected to.", comment: "Text for the event loader view when it is unable to find the note the user is looking for"),
|
||||
advice: NSLocalizedString("Try checking your internet connection, expanding your relay list, or contacting the person who quoted this note.", comment: "Tips on what to do if a quoted note cannot be found.")
|
||||
)
|
||||
|
||||
Button(action: {
|
||||
Task {
|
||||
await retry()
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
if !isReloading {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
Text("Try Again", comment: "Button label to retry loading a note that was not found")
|
||||
}
|
||||
else {
|
||||
ProgressView()
|
||||
Text("Retrying…", comment: "Button label for the retry-in-progress state when loading a note")
|
||||
}
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.secondary)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(isReloading)
|
||||
.opacity(isReloading ? 0.6 : 1.0)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ struct MenuItems: View {
|
||||
self.profileModel = profileModel
|
||||
}
|
||||
|
||||
var event_relay_url_strings: [RelayURL] {
|
||||
let relays = damus_state.nostrNetwork.relaysForEvent(event: event)
|
||||
func event_relay_url_strings() async -> [RelayURL] {
|
||||
let relays = await damus_state.nostrNetwork.relaysForEvent(event: event)
|
||||
if !relays.isEmpty {
|
||||
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0 }
|
||||
}
|
||||
@@ -88,7 +88,7 @@ struct MenuItems: View {
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))
|
||||
Task { UIPasteboard.general.string = Bech32Object.encode(.nevent(NEvent(event: event, relays: await event_relay_url_strings()))) }
|
||||
} label: {
|
||||
Label(NSLocalizedString("Copy note ID", comment: "Context menu option for copying the ID of the note."), image: "note-book")
|
||||
}
|
||||
@@ -122,7 +122,7 @@ struct MenuItems: View {
|
||||
if let full_keypair = self.damus_state.keypair.to_full(),
|
||||
let new_mutelist_ev = toggle_from_mutelist(keypair: full_keypair, prev: damus_state.mutelist_manager.event, to_toggle: .thread(event.thread_id(), duration?.date_from_now)) {
|
||||
damus_state.mutelist_manager.set_mutelist(new_mutelist_ev)
|
||||
damus_state.nostrNetwork.postbox.send(new_mutelist_ev)
|
||||
Task { await damus_state.nostrNetwork.postbox.send(new_mutelist_ev) }
|
||||
}
|
||||
let muted = damus_state.mutelist_manager.is_event_muted(event)
|
||||
isMutedThread = muted
|
||||
@@ -152,7 +152,7 @@ struct MenuItems: View {
|
||||
profileModel.subscribeToFindRelays()
|
||||
}
|
||||
.onDisappear() {
|
||||
profileModel.unsubscribeFindRelays()
|
||||
profileModel.findRelaysListener?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ struct EventProfile: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
ProfilePicView(pubkey: pubkey, size: pfp_size, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation, show_zappability: true)
|
||||
ProfilePicView(pubkey: pubkey, size: pfp_size, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation, show_zappability: true, damusState: damus_state)
|
||||
.onTapGesture {
|
||||
show_profile_action_sheet_if_enabled(damus_state: damus_state, pubkey: pubkey)
|
||||
}
|
||||
|
||||
@@ -35,12 +35,12 @@ struct EventShell<Content: View>: View {
|
||||
!options.contains(.no_action_bar)
|
||||
}
|
||||
|
||||
func get_mention(ndb: Ndb) -> Mention<NoteId>? {
|
||||
func get_mention(ndb: Ndb) -> NoteMentionWithHints? {
|
||||
if self.options.contains(.nested) || self.options.contains(.no_mentions) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return first_eref_mention(ndb: ndb, ev: event, keypair: state.keypair)
|
||||
|
||||
return first_eref_mention_with_hints(ndb: ndb, ev: event, keypair: state.keypair)
|
||||
}
|
||||
|
||||
var ActionBar: some View {
|
||||
@@ -63,9 +63,11 @@ struct EventShell<Content: View>: View {
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
EventTop(state: state, event: event, pubkey: pubkey, is_anon: is_anon)
|
||||
|
||||
UserStatusView(status: state.profiles.profile_data(pubkey).status, show_general: state.settings.show_general_statuses, show_music: state.settings.show_music_statuses)
|
||||
EventTop(state: state, event: event, pubkey: pubkey, is_anon: is_anon, size: options.contains(.small_text) ? .small : .normal, options: options)
|
||||
|
||||
if !options.contains(.no_status) {
|
||||
UserStatusView(status: state.profiles.profile_data(pubkey).status, show_general: state.settings.show_general_statuses, show_music: state.settings.show_music_statuses)
|
||||
}
|
||||
|
||||
if !options.contains(.no_replying_to) {
|
||||
ReplyPart(events: state.events, event: event, keypair: state.keypair, ndb: state.ndb)
|
||||
@@ -74,7 +76,7 @@ struct EventShell<Content: View>: View {
|
||||
content
|
||||
|
||||
if let mention = get_mention(ndb: state.ndb) {
|
||||
MentionView(damus_state: state, mention: mention)
|
||||
MentionView(damus_state: state, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
|
||||
}
|
||||
|
||||
if has_action_bar {
|
||||
@@ -93,7 +95,7 @@ struct EventShell<Content: View>: View {
|
||||
Pfp(is_anon: is_anon)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
EventTop(state: state, event: event, pubkey: pubkey, is_anon: is_anon)
|
||||
EventTop(state: state, event: event, pubkey: pubkey, is_anon: is_anon, size: options.contains(.small_text) ? .small : .normal, options: options)
|
||||
UserStatusView(status: state.profiles.profile_data(pubkey).status, show_general: state.settings.show_general_statuses, show_music: state.settings.show_music_statuses)
|
||||
ReplyPart(events: state.events, event: event, keypair: state.keypair, ndb: state.ndb)
|
||||
ProxyView(event: event)
|
||||
@@ -106,7 +108,7 @@ struct EventShell<Content: View>: View {
|
||||
if !options.contains(.no_mentions),
|
||||
let mention = get_mention(ndb: state.ndb)
|
||||
{
|
||||
MentionView(damus_state: state, mention: mention)
|
||||
MentionView(damus_state: state, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,14 @@ struct EventView: View {
|
||||
let options: EventViewOptions
|
||||
let damus: DamusState
|
||||
let pubkey: Pubkey
|
||||
let highlightTerms: [String]
|
||||
|
||||
init(damus: DamusState, event: NostrEvent, pubkey: Pubkey? = nil, options: EventViewOptions = []) {
|
||||
init(damus: DamusState, event: NostrEvent, pubkey: Pubkey? = nil, options: EventViewOptions = [], highlightTerms: [String] = []) {
|
||||
self.event = event
|
||||
self.options = options
|
||||
self.damus = damus
|
||||
self.pubkey = pubkey ?? event.pubkey
|
||||
self.highlightTerms = highlightTerms
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -34,6 +36,11 @@ struct EventView: View {
|
||||
if event.known_kind == .boost {
|
||||
if let inner_ev = event.get_inner_event(cache: damus.events) {
|
||||
RepostedEvent(damus: damus, event: event, inner_ev: inner_ev, options: options)
|
||||
} else if let target = event.repostTarget() {
|
||||
// Inner event not in cache - load using relay hints from e tag (NIP-18)
|
||||
EventLoaderView(damus_state: damus, event_id: target.noteId, relayHints: target.relayHints) { loaded_event in
|
||||
RepostedEvent(damus: damus, event: event, inner_ev: loaded_event, options: options)
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
@@ -48,7 +55,7 @@ struct EventView: View {
|
||||
} else if event.known_kind == .highlight {
|
||||
HighlightView(state: damus, event: event, options: options)
|
||||
} else {
|
||||
TextEvent(damus: damus, event: event, pubkey: pubkey, options: options)
|
||||
TextEvent(damus: damus, event: event, pubkey: pubkey, options: options, highlightTerms: highlightTerms)
|
||||
//.padding([.top], 6)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +63,7 @@ struct EventView: View {
|
||||
}
|
||||
|
||||
// blame the porn bots for this code
|
||||
@MainActor
|
||||
func should_blur_images(settings: UserSettingsStore, contacts: Contacts, ev: NostrEvent, our_pubkey: Pubkey, booster_pubkey: Pubkey? = nil) -> Bool {
|
||||
if settings.undistractMode {
|
||||
return true
|
||||
@@ -78,6 +86,7 @@ func should_blur_images(settings: UserSettingsStore, contacts: Contacts, ev: Nos
|
||||
}
|
||||
|
||||
// blame the porn bots for this code too
|
||||
@MainActor
|
||||
func should_blur_images(damus_state: DamusState, ev: NostrEvent) -> Bool {
|
||||
return should_blur_images(
|
||||
settings: damus_state.settings,
|
||||
@@ -106,7 +115,7 @@ func format_date(date: Date, time_style: DateFormatter.Style = .short) -> String
|
||||
|
||||
func make_actionbar_model(ev: NoteId, damus: DamusState) -> ActionBarModel {
|
||||
let model = ActionBarModel.empty()
|
||||
model.update(damus: damus, evid: ev)
|
||||
Task { await model.update(damus: damus, evid: ev) }
|
||||
return model
|
||||
}
|
||||
|
||||
@@ -158,4 +167,3 @@ struct EventView_Previews: PreviewProvider {
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,19 +7,30 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A view that renders an inline mention of a Nostr event.
|
||||
///
|
||||
/// Supports NIP-01/NIP-10 relay hints to fetch events from relays not in the user's pool.
|
||||
struct MentionView: View {
|
||||
let damus_state: DamusState
|
||||
let mention: Mention<NoteId>
|
||||
|
||||
init(damus_state: DamusState, mention: Mention<NoteId>) {
|
||||
let relayHints: [RelayURL]
|
||||
|
||||
/// Creates a mention view.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - damus_state: The app's shared state.
|
||||
/// - mention: The mention containing the note ID.
|
||||
/// - relayHints: Optional relay URLs where the event may be found (per NIP-01/NIP-10).
|
||||
init(damus_state: DamusState, mention: Mention<NoteId>, relayHints: [RelayURL] = []) {
|
||||
self.damus_state = damus_state
|
||||
self.mention = mention
|
||||
self.relayHints = relayHints
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
EventLoaderView(damus_state: damus_state, event_id: mention.ref) { event in
|
||||
EventLoaderView(damus_state: damus_state, event_id: mention.ref, relayHints: relayHints) { event in
|
||||
EventMutingContainerView(damus_state: damus_state, event: event) {
|
||||
BuilderEventView(damus: damus_state, event_id: mention.ref)
|
||||
BuilderEventView(damus: damus_state, event_id: mention.ref, relayHints: relayHints)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ class EventsModel: ObservableObject {
|
||||
let state: DamusState
|
||||
let target: NoteId
|
||||
let kind: QueryKind
|
||||
let sub_id = UUID().uuidString
|
||||
let profiles_id = UUID().uuidString
|
||||
var events: EventHolder
|
||||
@Published var loading: Bool
|
||||
var loadingTask: Task<Void, Never>?
|
||||
|
||||
enum QueryKind {
|
||||
case kind(NostrKind)
|
||||
@@ -68,42 +68,40 @@ class EventsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func subscribe() {
|
||||
state.nostrNetwork.pool.subscribe(sub_id: sub_id,
|
||||
filters: [get_filter()],
|
||||
handler: handle_nostr_event)
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
DispatchQueue.main.async { self.loading = true }
|
||||
outerLoop: for await item in state.nostrNetwork.reader.advancedStream(filters: [get_filter()]) {
|
||||
switch item {
|
||||
case .event(let lender):
|
||||
Task {
|
||||
await lender.justUseACopy({ event in
|
||||
if await events.insert(event) {
|
||||
DispatchQueue.main.async { self.objectWillChange.send() }
|
||||
}
|
||||
})
|
||||
}
|
||||
case .eose:
|
||||
break outerLoop
|
||||
case .ndbEose:
|
||||
DispatchQueue.main.async { self.loading = false }
|
||||
break
|
||||
case .networkEose:
|
||||
break
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async { self.loading = false }
|
||||
}
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
loadingTask?.cancel()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handle_event(relay_id: RelayURL, ev: NostrEvent) {
|
||||
if events.insert(ev) {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
func handle_nostr_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
guard case .nostr_event(let nev) = ev, nev.subid == self.sub_id
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
switch nev {
|
||||
case .event(_, let ev):
|
||||
handle_event(relay_id: relay_id, ev: ev)
|
||||
case .notice:
|
||||
break
|
||||
case .ok:
|
||||
break
|
||||
case .auth:
|
||||
break
|
||||
case .eose:
|
||||
self.loading = false
|
||||
guard let txn = NdbTxn(ndb: self.state.ndb) else {
|
||||
return
|
||||
}
|
||||
load_profiles(context: "events_model", profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events.all_events), damus_state: state, txn: txn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,64 +21,69 @@ class LoadableNostrEventViewModel: ObservableObject {
|
||||
let damus_state: DamusState
|
||||
let note_reference: NoteReference
|
||||
@Published var state: ThreadModelLoadingState = .loading
|
||||
/// The time period after which it will give up loading the view.
|
||||
/// Written in nanoseconds
|
||||
let TIMEOUT: UInt64 = 10 * 1_000_000_000 // 10 seconds
|
||||
|
||||
|
||||
init(damus_state: DamusState, note_reference: NoteReference) {
|
||||
self.damus_state = damus_state
|
||||
self.note_reference = note_reference
|
||||
Task { await self.load() }
|
||||
}
|
||||
|
||||
func load() async {
|
||||
// Start the loading process in a separate task to manage the timeout independently.
|
||||
let loadTask = Task { @MainActor in
|
||||
self.state = await executeLoadingLogic(note_reference: self.note_reference)
|
||||
}
|
||||
|
||||
// Setup a timer to cancel the load after the timeout period
|
||||
let timeoutTask = Task { @MainActor in
|
||||
try await Task.sleep(nanoseconds: TIMEOUT)
|
||||
loadTask.cancel() // This sends a cancellation signal to the load task.
|
||||
self.state = .not_found
|
||||
}
|
||||
|
||||
await loadTask.value
|
||||
timeoutTask.cancel() // Cancel the timeout task if loading finishes earlier.
|
||||
/// Waits for relay connection then loads the referenced event.
|
||||
///
|
||||
/// Timeout is handled by `awaitConnection()` (30 s built-in).
|
||||
func load() async {
|
||||
await damus_state.nostrNetwork.awaitConnection()
|
||||
self.state = await executeLoadingLogic(note_reference: self.note_reference)
|
||||
}
|
||||
|
||||
/// Asynchronously find an event from NostrDB or from the network (if not available on NostrDB)
|
||||
private func loadEvent(noteId: NoteId) async -> NostrEvent? {
|
||||
let res = await find_event(state: damus_state, query: .event(evid: noteId))
|
||||
/// Loads the Nostr event identified by `noteId`, optionally restricting the lookup to specific relays.
|
||||
/// - Parameters:
|
||||
/// - relays: An array of relay URLs to restrict the lookup to. If empty, the lookup is not restricted to any relays.
|
||||
/// - Returns: The `NostrEvent` matching `noteId` if found, `nil` otherwise.
|
||||
private func loadEvent(noteId: NoteId, relays: [RelayURL]) async -> NostrEvent? {
|
||||
let targetRelays = relays.isEmpty ? nil : relays
|
||||
let res = await damus_state.nostrNetwork.reader.findEvent(query: .event(evid: noteId, find_from: targetRelays))
|
||||
guard let res, case .event(let ev) = res else { return nil }
|
||||
return ev
|
||||
}
|
||||
|
||||
/// Gets the note reference and tries to load it, outputting a new state for this view model.
|
||||
/// Resolve a NoteReference into a ThreadModelLoadingState describing how the referenced note should be presented.
|
||||
///
|
||||
/// For a `.note_id` reference this attempts to load the event (honoring optional relay hints) and maps event kinds as follows:
|
||||
/// - `.text` or `.highlight` → `.loaded` with a `Route.Thread`.
|
||||
/// - `.dm` → `.loaded` with a `Route.DMChat` for the corresponding DM model.
|
||||
/// - `.like` → follows the first referenced note ID (propagating the same relay hints) and resolves it recursively.
|
||||
/// - `.zap` or `.zap_request` → resolves a zap and, if found, returns `.loaded` with a `Route.Zaps`.
|
||||
/// - any other known kind → `.unknown_or_unsupported_kind`.
|
||||
/// If the event cannot be retrieved or a required referenced note/zap is missing, returns `.not_found`.
|
||||
///
|
||||
/// For an `.naddr` reference this looks up the event (using relays from the NAddr if provided) and returns `.loaded` with a `Route.Thread` when found or `.not_found` when not found.
|
||||
/// - Parameter note_reference: The note identifier to resolve; may include relay hints for relay-aware lookup.
|
||||
/// - Returns: A `ThreadModelLoadingState` indicating the resolved presentation route (`.loaded(route: Route)`), `.not_found` when the note or required referenced data is missing, or `.unknown_or_unsupported_kind` when the event kind cannot be presented.
|
||||
private func executeLoadingLogic(note_reference: NoteReference) async -> ThreadModelLoadingState {
|
||||
switch note_reference {
|
||||
case .note_id(let note_id):
|
||||
guard let ev = await self.loadEvent(noteId: note_id) else { return .not_found }
|
||||
case .note_id(let note_id, let relays):
|
||||
guard let ev = await self.loadEvent(noteId: note_id, relays: relays) else { return .not_found }
|
||||
guard let known_kind = ev.known_kind else { return .unknown_or_unsupported_kind }
|
||||
switch known_kind {
|
||||
case .text, .highlight, .voice_message:
|
||||
case .text, .highlight, .longform:
|
||||
return .loaded(route: Route.Thread(thread: ThreadModel(event: ev, damus_state: damus_state)))
|
||||
case .dm:
|
||||
let dm_model = damus_state.dms.lookup_or_create(ev.pubkey)
|
||||
return .loaded(route: Route.DMChat(dms: dm_model))
|
||||
case .like:
|
||||
// Load the event that this reaction refers to.
|
||||
guard let first_referenced_note_id = ev.referenced_ids.first else { return .not_found }
|
||||
return await self.executeLoadingLogic(note_reference: .note_id(first_referenced_note_id))
|
||||
// Pass the same relay hints - the referenced note is likely on the same relay as the reaction
|
||||
return await self.executeLoadingLogic(note_reference: .note_id(first_referenced_note_id, relays: relays))
|
||||
case .zap, .zap_request:
|
||||
guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found }
|
||||
return .loaded(route: Route.Zaps(target: zap.target))
|
||||
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list, .interest_list:
|
||||
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list, .interest_list, .contact_card, .live, .live_chat:
|
||||
return .unknown_or_unsupported_kind
|
||||
}
|
||||
case .naddr(let naddr):
|
||||
guard let event = await naddrLookup(damus_state: damus_state, naddr: naddr) else { return .not_found }
|
||||
let targetRelays = naddr.relays.isEmpty ? nil : naddr.relays
|
||||
guard let event = await damus_state.nostrNetwork.reader.lookup(naddr: naddr, to: targetRelays) else { return .not_found }
|
||||
return .loaded(route: Route.Thread(thread: ThreadModel(event: event, damus_state: damus_state)))
|
||||
}
|
||||
}
|
||||
@@ -91,7 +96,7 @@ class LoadableNostrEventViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
enum NoteReference: Hashable {
|
||||
case note_id(NoteId)
|
||||
case note_id(NoteId, relays: [RelayURL])
|
||||
case naddr(NAddr)
|
||||
}
|
||||
}
|
||||
@@ -271,5 +276,5 @@ extension LoadableNostrEventView {
|
||||
}
|
||||
|
||||
#Preview("Loadable") {
|
||||
LoadableNostrEventView(state: test_damus_state, note_reference: .note_id(test_thread_note_1.id))
|
||||
}
|
||||
LoadableNostrEventView(state: test_damus_state, note_reference: .note_id(test_thread_note_1.id, relays: []))
|
||||
}
|
||||
@@ -72,8 +72,9 @@ func render_immediately_available_note_content(ndb: Ndb, ev: NostrEvent, profile
|
||||
}
|
||||
|
||||
do {
|
||||
let blocks = try NdbBlockGroup.from(event: ev, using: ndb, and: keypair)
|
||||
return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
|
||||
return try NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blocks in
|
||||
return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// TODO: Improve error handling in the future, bubbling it up so that the view can decide how display errors. Keep legacy behavior for now.
|
||||
@@ -83,12 +84,6 @@ func render_immediately_available_note_content(ndb: Ndb, ev: NostrEvent, profile
|
||||
|
||||
actor ContentRenderer {
|
||||
func render_note_content(ndb: Ndb, ev: NostrEvent, profiles: Profiles, keypair: Keypair) async -> NoteArtifacts {
|
||||
if ev.known_kind == .dm {
|
||||
// Use the enhanced render_immediately_available_note_content which now handles DMs properly
|
||||
// by decrypting and parsing the content with ndb_parse_content
|
||||
return render_immediately_available_note_content(ndb: ndb, ev: ev, profiles: profiles, keypair: keypair)
|
||||
}
|
||||
let result = try? await ndb.waitFor(noteId: ev.id, timeout: 3)
|
||||
return render_immediately_available_note_content(ndb: ndb, ev: ev, profiles: profiles, keypair: keypair)
|
||||
}
|
||||
}
|
||||
@@ -196,8 +191,7 @@ func render_blocks(blocks: borrowing NdbBlockGroup, profiles: Profiles, can_hide
|
||||
let url_type = classify_url(url)
|
||||
urls.append(url_type)
|
||||
case .invoice(let invoice_block):
|
||||
guard let invoice = invoice_block.as_invoice() else { break }
|
||||
invoices.append(invoice)
|
||||
invoices.append(invoice_block.as_invoice())
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -263,9 +257,9 @@ func render_blocks(blocks: borrowing NdbBlockGroup, profiles: Profiles, can_hide
|
||||
return .loopReturn(str + CompatibleText(stringLiteral: reduce_text_block(ind: index, hide_text_index: hide_text_index_argument, txt: txt.as_str())))
|
||||
case .hashtag(let htag):
|
||||
return .loopReturn(str + hashtag_str(htag.as_str()))
|
||||
case .invoice(let invoice):
|
||||
guard let inv = invoice.as_invoice() else { return .loopContinue }
|
||||
invoices.append(inv)
|
||||
case .invoice:
|
||||
// Invoice already added in previewable-collection switch above
|
||||
break
|
||||
case .url(let url):
|
||||
guard let url = URL(string: url.as_str()) else { return .loopContinue }
|
||||
return .loopReturn(str + url_str(url))
|
||||
@@ -319,8 +313,6 @@ func classify_url(_ url: URL) -> UrlType {
|
||||
return .media(.image(url))
|
||||
case "mp4", "mov", "m3u8":
|
||||
return .media(.video(url))
|
||||
case "m4a":
|
||||
return .media(.audio(url))
|
||||
default:
|
||||
return .link(url)
|
||||
}
|
||||
@@ -335,8 +327,7 @@ func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage)
|
||||
}
|
||||
|
||||
func getDisplayName(pk: Pubkey, profiles: Profiles) -> String {
|
||||
let profile_txn = profiles.lookup(id: pk, txn_name: "getDisplayName")
|
||||
let profile = profile_txn?.unsafeUnownedValue
|
||||
let profile = try? profiles.lookup(id: pk)
|
||||
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
|
||||
}
|
||||
|
||||
@@ -389,11 +380,182 @@ struct LongformContent {
|
||||
let markdown: MarkdownContent
|
||||
let words: Int
|
||||
|
||||
/// Estimated reading time in minutes, based on average reading speed of 200 words per minute.
|
||||
var estimatedReadTimeMinutes: Int {
|
||||
return max(1, Int(ceil(Double(words) / 200.0)))
|
||||
}
|
||||
|
||||
init(_ markdown: String) {
|
||||
let blocks = [BlockNode].init(markdown: markdown)
|
||||
// Pre-process markdown to ensure images are block-level (have blank lines around them)
|
||||
// This prevents images from being parsed as inline within text paragraphs
|
||||
let processedMarkdown = LongformContent.ensureBlockLevelImages(markdown)
|
||||
let blocks = [BlockNode].init(markdown: processedMarkdown)
|
||||
self.markdown = MarkdownContent(blocks: blocks)
|
||||
self.words = count_markdown_words(blocks: blocks)
|
||||
}
|
||||
|
||||
/// Ensures markdown images have blank lines around them so they parse as block-level elements.
|
||||
/// Without blank lines, images followed by text are parsed as inline within a paragraph,
|
||||
/// causing them to be clipped by SwiftUI's Text view.
|
||||
///
|
||||
/// Safety: This function excludes:
|
||||
/// - Fenced code blocks (``` or ~~~), indented code blocks (4 spaces/tab), and inline code
|
||||
/// - Images inside lists (lines starting with -, *, + followed by space)
|
||||
/// - Images inside blockquotes (lines starting with >)
|
||||
/// - Images inside tables (lines containing | with table structure)
|
||||
///
|
||||
/// Known limitations (images remain inline, may clip if mixed with text):
|
||||
/// - Multi-line list items with images on continuation lines
|
||||
/// - Reference-style images: ![alt][id]
|
||||
/// - HTML <img> tags
|
||||
/// - Inline code with nested backticks (e.g., `` `code` `` using longer delimiters)
|
||||
private static func ensureBlockLevelImages(_ markdown: String) -> String {
|
||||
// First, identify regions to exclude (fenced code blocks and inline code)
|
||||
let excludedRanges = findExcludedRanges(in: markdown)
|
||||
|
||||
// Pattern matches markdown images:  or 
|
||||
// Handles URLs with parentheses by matching balanced parens or escaped parens
|
||||
let imagePattern = #"!\[[^\]]*\]\((?:[^()]+|\([^)]*\))+(?:\s+"[^"]*")?\)"#
|
||||
guard let regex = try? NSRegularExpression(pattern: imagePattern, options: []) else {
|
||||
return markdown
|
||||
}
|
||||
|
||||
var result = markdown
|
||||
let matches = regex.matches(in: result, options: [], range: NSRange(result.startIndex..., in: result))
|
||||
|
||||
// Process matches in reverse order to preserve indices
|
||||
for match in matches.reversed() {
|
||||
guard let range = Range(match.range, in: result) else { continue }
|
||||
|
||||
// Skip if this match is inside an excluded region (code block/inline code)
|
||||
// Use String.Index comparison for correctness with non-ASCII content
|
||||
if excludedRanges.contains(where: { $0.overlaps(range) }) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if inside a list, blockquote, or table context
|
||||
if isInStructuredContext(result, at: range.lowerBound) {
|
||||
continue
|
||||
}
|
||||
|
||||
let imageMarkdown = String(result[range])
|
||||
|
||||
// Check what's before the image
|
||||
let beforeIndex = range.lowerBound
|
||||
let hasParagraphBreakBefore = beforeIndex == result.startIndex ||
|
||||
result[result.index(before: beforeIndex)] == "\n" && (
|
||||
beforeIndex == result.index(after: result.startIndex) ||
|
||||
result[result.index(beforeIndex, offsetBy: -2)] == "\n"
|
||||
)
|
||||
|
||||
// Check what's after the image
|
||||
let afterIndex = range.upperBound
|
||||
let hasParagraphBreakAfter = afterIndex == result.endIndex ||
|
||||
result[afterIndex] == "\n" && (
|
||||
result.index(after: afterIndex) == result.endIndex ||
|
||||
result[result.index(after: afterIndex)] == "\n"
|
||||
)
|
||||
|
||||
// Build replacement with proper paragraph breaks
|
||||
var replacement = imageMarkdown
|
||||
if !hasParagraphBreakBefore && beforeIndex != result.startIndex {
|
||||
replacement = "\n\n" + replacement
|
||||
}
|
||||
if !hasParagraphBreakAfter && afterIndex != result.endIndex {
|
||||
replacement = replacement + "\n\n"
|
||||
}
|
||||
|
||||
if replacement != imageMarkdown {
|
||||
result.replaceSubrange(range, with: replacement)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Checks if the position is inside a list, blockquote, or table context.
|
||||
/// Returns true if modifying this position would break markdown structure.
|
||||
private static func isInStructuredContext(_ markdown: String, at position: String.Index) -> Bool {
|
||||
// Find the start of the current line
|
||||
var lineStart = position
|
||||
while lineStart > markdown.startIndex {
|
||||
let prevIndex = markdown.index(before: lineStart)
|
||||
if markdown[prevIndex] == "\n" {
|
||||
break
|
||||
}
|
||||
lineStart = prevIndex
|
||||
}
|
||||
|
||||
// Get the line prefix (content before the image on this line)
|
||||
let linePrefix = String(markdown[lineStart..<position])
|
||||
let trimmedPrefix = linePrefix.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Check for list markers: -, *, + followed by space (to avoid false positives)
|
||||
if (trimmedPrefix.hasPrefix("- ") || trimmedPrefix.hasPrefix("* ") ||
|
||||
trimmedPrefix.hasPrefix("+ ")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for numbered list: digit(s) followed by . or ) and space
|
||||
let numberedListPattern = #"^\d+[.)]\s"#
|
||||
if let numberedRegex = try? NSRegularExpression(pattern: numberedListPattern, options: []),
|
||||
numberedRegex.firstMatch(in: trimmedPrefix, options: [], range: NSRange(trimmedPrefix.startIndex..., in: trimmedPrefix)) != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for blockquote marker: >
|
||||
if trimmedPrefix.hasPrefix(">") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for table context: line has | at both start area and elsewhere (actual table row)
|
||||
var lineEnd = position
|
||||
while lineEnd < markdown.endIndex && markdown[lineEnd] != "\n" {
|
||||
lineEnd = markdown.index(after: lineEnd)
|
||||
}
|
||||
let fullLine = String(markdown[lineStart..<lineEnd])
|
||||
// Only treat as table if line has multiple | characters (actual table structure)
|
||||
if fullLine.filter({ $0 == "|" }).count >= 2 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// Finds ranges in the markdown that should be excluded from image processing.
|
||||
/// Returns ranges as String.Index for correct handling of non-ASCII content.
|
||||
private static func findExcludedRanges(in markdown: String) -> [Range<String.Index>] {
|
||||
var ranges: [Range<String.Index>] = []
|
||||
let fullRange = NSRange(markdown.startIndex..., in: markdown)
|
||||
|
||||
// Find fenced code blocks (3+ backticks or tildes)
|
||||
let fencedPattern = #"(?:^|\n)(`{3,}|~{3,}).*?(?:\n\1|\z)"#
|
||||
guard let fencedRegex = try? NSRegularExpression(pattern: fencedPattern, options: [.dotMatchesLineSeparators]) else {
|
||||
return ranges
|
||||
}
|
||||
ranges.append(contentsOf: fencedRegex.matches(in: markdown, options: [], range: fullRange)
|
||||
.compactMap { Range($0.range, in: markdown) })
|
||||
|
||||
// Find indented code blocks (lines starting with 4 spaces or tab, preceded by blank line)
|
||||
// Blank line may contain whitespace: \n followed by optional spaces/tabs then \n
|
||||
let indentedPattern = #"(?:^|\n[ \t]*\n)((?:(?: |\t).+\n?)+)"#
|
||||
guard let indentedRegex = try? NSRegularExpression(pattern: indentedPattern, options: []) else {
|
||||
return ranges
|
||||
}
|
||||
ranges.append(contentsOf: indentedRegex.matches(in: markdown, options: [], range: fullRange)
|
||||
.filter { $0.numberOfRanges > 1 }
|
||||
.compactMap { Range($0.range(at: 1), in: markdown) })
|
||||
|
||||
// Find inline code (1+ backticks, matching pairs)
|
||||
let inlinePattern = #"(`+)(?!`)[^`]*?\1"#
|
||||
guard let inlineRegex = try? NSRegularExpression(pattern: inlinePattern, options: []) else {
|
||||
return ranges
|
||||
}
|
||||
ranges.append(contentsOf: inlineRegex.matches(in: markdown, options: [], range: fullRange)
|
||||
.compactMap { Range($0.range, in: markdown) })
|
||||
|
||||
return ranges
|
||||
}
|
||||
}
|
||||
|
||||
func count_markdown_words(blocks: [BlockNode]) -> Int {
|
||||
@@ -454,8 +616,6 @@ enum UrlType {
|
||||
return url
|
||||
case .video(let url):
|
||||
return url
|
||||
case .audio(let url):
|
||||
return url
|
||||
}
|
||||
case .link(let url):
|
||||
return url
|
||||
@@ -466,7 +626,7 @@ enum UrlType {
|
||||
switch self {
|
||||
case .media(let media_url):
|
||||
switch media_url {
|
||||
case .image, .audio:
|
||||
case .image:
|
||||
return nil
|
||||
case .video(let url):
|
||||
return url
|
||||
@@ -482,28 +642,14 @@ enum UrlType {
|
||||
switch media_url {
|
||||
case .image(let url):
|
||||
return url
|
||||
case .video, .audio:
|
||||
case .video:
|
||||
return nil
|
||||
}
|
||||
case .link:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var is_audio: URL? {
|
||||
switch self {
|
||||
case .media(let media_url):
|
||||
switch media_url {
|
||||
case .audio(let url):
|
||||
return url
|
||||
case .image, .video:
|
||||
return nil
|
||||
}
|
||||
case .link:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var is_link: URL? {
|
||||
switch self {
|
||||
case .media:
|
||||
@@ -526,16 +672,13 @@ enum UrlType {
|
||||
enum MediaUrl {
|
||||
case image(URL)
|
||||
case video(URL)
|
||||
case audio(URL)
|
||||
|
||||
|
||||
var url: URL {
|
||||
switch self {
|
||||
case .image(let url):
|
||||
return url
|
||||
case .video(let url):
|
||||
return url
|
||||
case .audio(let url):
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import LinkPresentation
|
||||
import NaturalLanguage
|
||||
import MarkdownUI
|
||||
import Translation
|
||||
import UIKit
|
||||
|
||||
struct Blur: UIViewRepresentable {
|
||||
var style: UIBlurEffect.Style = .systemUltraThinMaterial
|
||||
@@ -45,9 +46,11 @@ struct NoteContentView: View {
|
||||
let event: NostrEvent
|
||||
@State var blur_images: Bool
|
||||
@State var load_media: Bool = false
|
||||
@State private var showLinksDropdown = false
|
||||
let size: EventViewKind
|
||||
let preview_height: CGFloat?
|
||||
let options: EventViewOptions
|
||||
let highlightTerms: [String]
|
||||
|
||||
@State var isAppleTranslationPopoverPresented: Bool = false
|
||||
|
||||
@@ -62,12 +65,13 @@ struct NoteContentView: View {
|
||||
return self.artifacts_model.state.artifacts ?? .separated(.just_content(event.get_content(damus_state.keypair)))
|
||||
}
|
||||
|
||||
init(damus_state: DamusState, event: NostrEvent, blur_images: Bool, size: EventViewKind, options: EventViewOptions) {
|
||||
init(damus_state: DamusState, event: NostrEvent, blur_images: Bool, size: EventViewKind, options: EventViewOptions, highlightTerms: [String] = []) {
|
||||
self.damus_state = damus_state
|
||||
self.event = event
|
||||
self.blur_images = blur_images
|
||||
self.size = size
|
||||
self.options = options
|
||||
self.highlightTerms = highlightTerms
|
||||
self.preview_height = lookup_cached_preview_size(previews: damus_state.previews, evid: event.id)
|
||||
let cached = damus_state.events.get_cache_data(event.id)
|
||||
self._preview_model = ObservedObject(wrappedValue: cached.preview_model)
|
||||
@@ -167,29 +171,33 @@ struct NoteContentView: View {
|
||||
}
|
||||
|
||||
func MainContent(artifacts: NoteArtifactsSeparated) -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
if size == .selected {
|
||||
if with_padding {
|
||||
SelectableText(damus_state: damus_state, event: self.event, attributedString: artifacts.content.attributed, size: self.size)
|
||||
.padding(.horizontal)
|
||||
} else {
|
||||
SelectableText(damus_state: damus_state, event: self.event, attributedString: artifacts.content.attributed, size: self.size)
|
||||
}
|
||||
} else {
|
||||
if with_padding {
|
||||
truncatedText(content: artifacts.content)
|
||||
.padding(.horizontal)
|
||||
} else {
|
||||
truncatedText(content: artifacts.content)
|
||||
}
|
||||
}
|
||||
let contentToRender = highlightedContent(artifacts.content)
|
||||
|
||||
if !options.contains(.no_translate) && (size == .selected || TranslationService.isAppleTranslationPopoverSupported || damus_state.settings.auto_translate) {
|
||||
if with_padding {
|
||||
translateView
|
||||
.padding(.horizontal)
|
||||
return VStack(alignment: .leading) {
|
||||
if artifacts.content.attributed.characters.count != 0 {
|
||||
if size == .selected {
|
||||
if with_padding {
|
||||
SelectableText(damus_state: damus_state, event: self.event, attributedString: contentToRender.attributed, size: self.size)
|
||||
.padding(.horizontal)
|
||||
} else {
|
||||
SelectableText(damus_state: damus_state, event: self.event, attributedString: contentToRender.attributed, size: self.size)
|
||||
}
|
||||
} else {
|
||||
translateView
|
||||
if with_padding {
|
||||
truncatedText(content: contentToRender)
|
||||
.padding(.horizontal)
|
||||
} else {
|
||||
truncatedText(content: contentToRender)
|
||||
}
|
||||
}
|
||||
|
||||
if !options.contains(.no_translate) && (size == .selected || TranslationService.isAppleTranslationPopoverSupported || damus_state.settings.auto_translate) {
|
||||
if with_padding {
|
||||
translateView
|
||||
.padding(.horizontal)
|
||||
} else {
|
||||
translateView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,49 +238,196 @@ struct NoteContentView: View {
|
||||
}
|
||||
|
||||
}
|
||||
.padding(.top, artifacts.content.attributed.characters.count == 0 ? 7 : 0)
|
||||
}
|
||||
|
||||
var has_previews: Bool {
|
||||
!options.contains(.no_previews)
|
||||
}
|
||||
|
||||
|
||||
func loadMediaButton(artifacts: NoteArtifactsSeparated) -> some View {
|
||||
Button(action: {
|
||||
load_media = true
|
||||
}, label: {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Image("images")
|
||||
Text("Load media", comment: "Button to show media in note.")
|
||||
.fontWeight(.bold)
|
||||
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
|
||||
}
|
||||
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
|
||||
ForEach(artifacts.media.indices, id: \.self) { index in
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
switch artifacts.media[index] {
|
||||
case .image(let url), .video(let url), .audio(let url):
|
||||
Text(abbreviateURL(url))
|
||||
Button(action: {
|
||||
load_media = true
|
||||
}) {
|
||||
HStack(spacing: 10) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image("images")
|
||||
.foregroundStyle(DamusColors.neutral6)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
if artifacts.media.count > 1 {
|
||||
Text(verbatim: "\(artifacts.media.count)")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(DamusColors.neutral6)
|
||||
)
|
||||
.offset(x: 6, y: -6)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
Text(verbatim: "\(pluralizedString(key: "media_count", count: artifacts.media.count))")
|
||||
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
|
||||
.foregroundStyle(DamusColors.neutral6)
|
||||
.multilineTextAlignment(.leading)
|
||||
.padding(EdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10))
|
||||
|
||||
Spacer()
|
||||
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.leading, 14)
|
||||
.padding(.trailing, 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
Rectangle()
|
||||
.fill(DamusColors.neutral3)
|
||||
.frame(width: 1)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Button(action: {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
showLinksDropdown.toggle()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: showLinksDropdown ? "chevron.up.circle.fill" : "chevron.down.circle")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(DamusColors.neutral6)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.accessibilityLabel(showLinksDropdown ? NSLocalizedString("Hide media links", comment: "Accessibility label for toggle button to hide media link list") : NSLocalizedString("Show media links", comment: "Accessibility label for toggle button to show media link list"))
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(DamusColors.neutral1.opacity(0.6))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(DamusColors.neutral3, lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.05), radius: 2, y: 1)
|
||||
)
|
||||
|
||||
if showLinksDropdown {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(artifacts.media.enumerated()), id: \.offset) { index, mediaItem in
|
||||
if index > 0 {
|
||||
Divider()
|
||||
.background(DamusColors.neutral3)
|
||||
}
|
||||
|
||||
mediaLinkRow(for: mediaItem, at: index)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(DamusColors.neutral1.opacity(0.4))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(DamusColors.neutral3, lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.05), radius: 2, y: 1)
|
||||
)
|
||||
.padding(.top, 6)
|
||||
.transition(.asymmetric(
|
||||
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top)),
|
||||
removal: .opacity
|
||||
))
|
||||
}
|
||||
.background(DamusColors.neutral1)
|
||||
.frame(minWidth: nil, maxWidth: .infinity, alignment: .center)
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(DamusColors.neutral3, lineWidth: 1)
|
||||
)
|
||||
})
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
@concurrent
|
||||
func streamProfiles() async throws {
|
||||
var mentionPubkeys: Set<Pubkey> = []
|
||||
let event = await self.event.clone()
|
||||
try await NdbBlockGroup.borrowBlockGroup(event: event, using: damus_state.ndb, and: damus_state.keypair, borrow: { blockGroup in
|
||||
blockGroup.forEachBlock({ _, block in
|
||||
guard let pubkey = block.mentionPubkey(tags: event.tags) else {
|
||||
return .loopContinue
|
||||
}
|
||||
mentionPubkeys.insert(pubkey)
|
||||
return .loopContinue
|
||||
})
|
||||
})
|
||||
|
||||
if mentionPubkeys.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
// Only re-render on network updates, not cached profiles.
|
||||
// Initial render already uses cached profile data via the view hierarchy.
|
||||
for await profile in await damus_state.nostrNetwork.profilesManager.streamProfiles(pubkeys: mentionPubkeys, yieldCached: false) {
|
||||
await load(force_artifacts: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func mediaLinkRow(for mediaItem: MediaUrl, at index: Int) -> some View {
|
||||
switch mediaItem {
|
||||
case .image(let url), .video(let url):
|
||||
Button(action: {
|
||||
load_media = true
|
||||
}) {
|
||||
HStack(spacing: 10) {
|
||||
|
||||
Image(systemName: "photo.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(DamusColors.neutral6)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(abbreviateURL(url))
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(DamusColors.neutral6)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
if let domain = url.host {
|
||||
Text(domain)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(DamusColors.neutral6)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = url.absoluteString
|
||||
let impactFeedback = UIImpactFeedbackGenerator(style: .light)
|
||||
impactFeedback.impactOccurred()
|
||||
}) {
|
||||
Image(systemName: "doc.on.doc")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(DamusColors.neutral6)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.accessibilityLabel(NSLocalizedString("Copy media link", comment: "Accessibility label for copy media link button"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.accessibilityLabel(String(format: NSLocalizedString("Load %@", comment: "Accessibility label for button to load specific media item"), abbreviateURL(url)))
|
||||
}
|
||||
}
|
||||
|
||||
func load(force_artifacts: Bool = false) {
|
||||
if case .loading = damus_state.events.get_cache_data(event.id).artifacts_model.state {
|
||||
return
|
||||
@@ -331,63 +486,82 @@ struct NoteContentView: View {
|
||||
Group {
|
||||
switch self.note_artifacts {
|
||||
case .longform(let md):
|
||||
Markdown(md.markdown)
|
||||
.padding([.leading, .trailing, .top])
|
||||
// Note: Do NOT apply .fixedSize to longform content - it prevents async images from expanding
|
||||
// Limit line length to ~600pt for optimal readability (50-75 chars per line)
|
||||
LongformMarkdownView(
|
||||
markdown: md.markdown,
|
||||
disableAnimation: damus_state.settings.disable_animation,
|
||||
lineHeightMultiplier: damus_state.settings.longform_line_height,
|
||||
sepiaEnabled: damus_state.settings.longform_sepia_mode
|
||||
)
|
||||
case .separated(let separated):
|
||||
if #available(iOS 17.4, macOS 14.4, *) {
|
||||
MainContent(artifacts: separated)
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
.translationPresentation(isPresented: $isAppleTranslationPopoverPresented, text: event.get_content(damus_state.keypair))
|
||||
#endif
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else {
|
||||
MainContent(artifacts: separated)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
|
||||
var normalizedHighlightTerms: [String] {
|
||||
var output: [String] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
let preparedTerms = highlightTerms
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.flatMap { term -> [String] in
|
||||
if term.hasPrefix("#") {
|
||||
let stripped = String(term.dropFirst())
|
||||
return [term, stripped]
|
||||
}
|
||||
return [term]
|
||||
}
|
||||
|
||||
for term in preparedTerms {
|
||||
let lower = term.lowercased()
|
||||
if !lower.isEmpty && seen.insert(lower).inserted {
|
||||
output.append(lower)
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func highlightedContent(_ content: CompatibleText) -> CompatibleText {
|
||||
guard !normalizedHighlightTerms.isEmpty else { return content }
|
||||
|
||||
var attributed = content.attributed
|
||||
highlightAttributedString(&attributed)
|
||||
return CompatibleText(attributed: attributed)
|
||||
}
|
||||
|
||||
func highlightAttributedString(_ attributed: inout AttributedString) {
|
||||
for term in normalizedHighlightTerms {
|
||||
var searchStart = attributed.startIndex
|
||||
|
||||
while let range = attributed[searchStart...].range(of: term, options: .caseInsensitive) {
|
||||
attributed[range].backgroundColor = DamusColors.highlight
|
||||
searchStart = range.upperBound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ArtifactContent
|
||||
.onReceive(handle_notify(.profile_updated)) { profile in
|
||||
guard let blockGroup = try? NdbBlockGroup.from(event: event, using: damus_state.ndb, and: damus_state.keypair) else {
|
||||
return
|
||||
}
|
||||
let _: Int? = try? blockGroup.forEachBlock { index, block in
|
||||
switch block {
|
||||
case .mention(let m):
|
||||
guard let typ = m.bech32_type else {
|
||||
return .loopContinue
|
||||
}
|
||||
switch typ {
|
||||
case .nprofile:
|
||||
if m.bech32.nprofile.matches_pubkey(pk: profile.pubkey) {
|
||||
load(force_artifacts: true)
|
||||
}
|
||||
case .npub:
|
||||
if m.bech32.npub.matches_pubkey(pk: profile.pubkey) {
|
||||
load(force_artifacts: true)
|
||||
}
|
||||
case .nevent: return .loopContinue
|
||||
case .nrelay: return .loopContinue
|
||||
case .nsec: return .loopContinue
|
||||
case .note: return .loopContinue
|
||||
case .naddr: return .loopContinue
|
||||
}
|
||||
case .text: return .loopContinue
|
||||
case .hashtag: return .loopContinue
|
||||
case .url: return .loopContinue
|
||||
case .invoice: return .loopContinue
|
||||
case .mention_index(_): return .loopContinue
|
||||
}
|
||||
return .loopContinue
|
||||
}
|
||||
.task {
|
||||
try? await streamProfiles()
|
||||
}
|
||||
.onAppear {
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class NoteArtifactsParts {
|
||||
@@ -458,7 +632,7 @@ struct BlurOverlayView: View {
|
||||
.foregroundStyle(.white)
|
||||
.bold()
|
||||
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
|
||||
Text(NSLocalizedString("Media from someone you don't follow", comment: "Label on the image blur mask"))
|
||||
Text("Media from someone you don't follow", comment: "Label on the image blur mask")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(Color.white)
|
||||
.font(.title2)
|
||||
@@ -477,8 +651,8 @@ struct BlurOverlayView: View {
|
||||
let damus_state = damus_state
|
||||
{
|
||||
switch artifacts.media[0] {
|
||||
case .image(let url), .video(let url), .audio(let url):
|
||||
Text(abbreviateURL(url, maxLength: 30))
|
||||
case .image(let url), .video(let url):
|
||||
Text(verbatim: "\(abbreviateURL(url, maxLength: 30))")
|
||||
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size * 0.8))
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -536,24 +710,46 @@ struct NoteContentView_Previews: PreviewProvider {
|
||||
}
|
||||
|
||||
func separate_images(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [MediaUrl]? {
|
||||
guard let blockGroup = try? NdbBlockGroup.from(event: ev, using: ndb, and: keypair) else {
|
||||
return nil
|
||||
}
|
||||
let urlBlocks: [URL] = (try? blockGroup.reduce(initialResult: Array<URL>()) { index, urls, block in
|
||||
switch block {
|
||||
case .url(let url):
|
||||
guard let parsed_url = URL(string: url.as_str()) else {
|
||||
return .loopContinue
|
||||
return try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
|
||||
let urlBlocks: [URL] = (blockGroup.reduce(initialResult: Array<URL>()) { index, urls, block in
|
||||
switch block {
|
||||
case .url(let url):
|
||||
guard let parsed_url = URL(string: url.as_str()) else {
|
||||
return .loopContinue
|
||||
}
|
||||
|
||||
if classify_url(parsed_url).is_img != nil {
|
||||
return .loopReturn(urls + [parsed_url])
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if classify_url(parsed_url).is_img != nil {
|
||||
return .loopReturn(urls + [parsed_url])
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
return .loopContinue
|
||||
}) ?? []
|
||||
let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
|
||||
return mediaUrls.isEmpty ? nil : mediaUrls
|
||||
return .loopContinue
|
||||
}) ?? []
|
||||
let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
|
||||
return mediaUrls.isEmpty ? nil : mediaUrls
|
||||
})
|
||||
}
|
||||
|
||||
extension NdbBlock {
|
||||
func mentionPubkey(tags: Tags) -> Pubkey? {
|
||||
switch self {
|
||||
case .mention(let mentionBlock):
|
||||
guard let mention = MentionRef(block: mentionBlock) else {
|
||||
return nil
|
||||
}
|
||||
return mention.pubkey
|
||||
case .mention_index(let mentionIndex):
|
||||
let tagPosition = Int(mentionIndex)
|
||||
guard tagPosition >= 0, tagPosition < tags.count else {
|
||||
return nil
|
||||
}
|
||||
guard let mention = MentionRef.from_tag(tag: tags[tagPosition]) else {
|
||||
return nil
|
||||
}
|
||||
return mention.pubkey
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,20 +11,27 @@ struct SelectedEventView: View {
|
||||
let damus: DamusState
|
||||
let event: NostrEvent
|
||||
let size: EventViewKind
|
||||
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var pubkey: Pubkey {
|
||||
event.pubkey
|
||||
}
|
||||
|
||||
|
||||
@StateObject var bar: ActionBarModel
|
||||
|
||||
/// Whether to apply sepia styling to the entire view (for longform articles)
|
||||
var useSepia: Bool {
|
||||
event.known_kind == .longform && damus.settings.longform_sepia_mode
|
||||
}
|
||||
|
||||
init(damus: DamusState, event: NostrEvent, size: EventViewKind) {
|
||||
self.damus = damus
|
||||
self.event = event
|
||||
self.size = size
|
||||
self._bar = StateObject(wrappedValue: make_actionbar_model(ev: event.id, damus: damus))
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading) {
|
||||
@@ -74,16 +81,20 @@ struct SelectedEventView: View {
|
||||
}
|
||||
.onReceive(handle_notify(.update_stats)) { target in
|
||||
guard target == self.event.id else { return }
|
||||
self.bar.update(damus: self.damus, evid: target)
|
||||
Task { await self.bar.update(damus: self.damus, evid: target) }
|
||||
}
|
||||
.compositingGroup()
|
||||
}
|
||||
// Apply sepia background to outer HStack for full width coverage
|
||||
// Note: foregroundStyle intentionally NOT applied here to preserve UI element contrast
|
||||
// (buttons, icons, timestamps). Article text gets sepia styling via EventBody.
|
||||
.background(useSepia ? DamusColors.sepiaBackground(for: colorScheme) : Color.clear)
|
||||
}
|
||||
|
||||
var Mention: some View {
|
||||
Group {
|
||||
if let mention = first_eref_mention(ndb: damus.ndb, ev: event, keypair: damus.keypair) {
|
||||
MentionView(damus_state: damus, mention: mention)
|
||||
if let mention = first_eref_mention_with_hints(ndb: damus.ndb, ev: event, keypair: damus.keypair) {
|
||||
MentionView(damus_state: damus, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,13 @@ struct EventViewOptions: OptionSet {
|
||||
static let truncate_content_very_short = EventViewOptions(rawValue: 1 << 11)
|
||||
static let no_previews = EventViewOptions(rawValue: 1 << 12)
|
||||
static let no_show_more = EventViewOptions(rawValue: 1 << 13)
|
||||
static let small_text = EventViewOptions(rawValue: 1 << 14)
|
||||
static let no_status = EventViewOptions(rawValue: 1 << 15)
|
||||
static let no_context_menu = EventViewOptions(rawValue: 1 << 16)
|
||||
|
||||
static let embedded: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested]
|
||||
static let embedded_text_only: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested, .no_media, .truncate_content_very_short, .no_previews]
|
||||
static let live_chat: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .no_previews, .nested, .small_text, .no_status, .no_context_menu]
|
||||
}
|
||||
|
||||
struct TextEvent: View {
|
||||
@@ -34,13 +38,15 @@ struct TextEvent: View {
|
||||
let pubkey: Pubkey
|
||||
let options: EventViewOptions
|
||||
let evdata: EventData
|
||||
let highlightTerms: [String]
|
||||
|
||||
init(damus: DamusState, event: NostrEvent, pubkey: Pubkey, options: EventViewOptions) {
|
||||
init(damus: DamusState, event: NostrEvent, pubkey: Pubkey, options: EventViewOptions, highlightTerms: [String] = []) {
|
||||
self.damus = damus
|
||||
self.event = event
|
||||
self.pubkey = pubkey
|
||||
self.options = options
|
||||
self.evdata = damus.events.get_cache_data(event.id)
|
||||
self.highlightTerms = highlightTerms
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -51,12 +57,24 @@ struct TextEvent: View {
|
||||
|
||||
func EvBody(options: EventViewOptions) -> some View {
|
||||
let blur_imgs = should_blur_images(settings: damus.settings, contacts: damus.contacts, ev: event, our_pubkey: damus.pubkey)
|
||||
|
||||
if options.contains(.small_text) {
|
||||
return NoteContentView(
|
||||
damus_state: damus,
|
||||
event: event,
|
||||
blur_images: blur_imgs,
|
||||
size: .small,
|
||||
options: options,
|
||||
highlightTerms: highlightTerms)
|
||||
}
|
||||
|
||||
return NoteContentView(
|
||||
damus_state: damus,
|
||||
event: event,
|
||||
blur_images: blur_imgs,
|
||||
size: .normal,
|
||||
options: options
|
||||
options: options,
|
||||
highlightTerms: highlightTerms
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,4 +91,3 @@ struct TextEvent_Previews: PreviewProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class FollowPackModel: ObservableObject {
|
||||
@Published var loading: Bool = false
|
||||
|
||||
let damus_state: DamusState
|
||||
let subid = UUID().description
|
||||
var listener: Task<Void, Never>? = nil
|
||||
let limit: UInt32 = 500
|
||||
|
||||
init(damus_state: DamusState) {
|
||||
@@ -25,52 +25,36 @@ class FollowPackModel: ObservableObject {
|
||||
|
||||
func subscribe(follow_pack_users: [Pubkey]) {
|
||||
loading = true
|
||||
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
|
||||
self.listener?.cancel()
|
||||
self.listener = Task {
|
||||
await self.listenForUpdates(follow_pack_users: follow_pack_users)
|
||||
}
|
||||
}
|
||||
|
||||
func unsubscribe(to: RelayURL? = nil) {
|
||||
loading = false
|
||||
self.listener?.cancel()
|
||||
}
|
||||
|
||||
func listenForUpdates(follow_pack_users: [Pubkey]) async {
|
||||
let to_relays = await damus_state.nostrNetwork.determineToRelays(filters: damus_state.relay_filters)
|
||||
var filter = NostrFilter(kinds: [.text, .chat])
|
||||
filter.until = UInt32(Date.now.timeIntervalSince1970)
|
||||
filter.authors = follow_pack_users
|
||||
filter.limit = 500
|
||||
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: subid, filters: [filter], handler: handle_event, to: to_relays)
|
||||
}
|
||||
|
||||
func unsubscribe(to: RelayURL? = nil) {
|
||||
loading = false
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: to.map { [$0] })
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
|
||||
guard case .nostr_event(let event) = conn_ev else {
|
||||
return
|
||||
}
|
||||
|
||||
switch event {
|
||||
case .event(let sub_id, let ev):
|
||||
guard sub_id == self.subid else {
|
||||
return
|
||||
}
|
||||
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
|
||||
{
|
||||
if self.events.insert(ev) {
|
||||
self.objectWillChange.send()
|
||||
for await event in damus_state.nostrNetwork.reader.streamIndefinitely(filters: [filter], to: to_relays) {
|
||||
await event.justUseACopy({ event in
|
||||
let should_show_event = await should_show_event(state: damus_state, ev: event)
|
||||
if event.is_textlike && should_show_event && !event.is_reply()
|
||||
{
|
||||
if await self.events.insert(event) {
|
||||
DispatchQueue.main.async {
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case .notice(let msg):
|
||||
print("follow pack notice: \(msg)")
|
||||
case .ok:
|
||||
break
|
||||
case .eose(let sub_id):
|
||||
loading = false
|
||||
|
||||
if sub_id == self.subid {
|
||||
unsubscribe(to: relay_id)
|
||||
|
||||
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
|
||||
}
|
||||
|
||||
break
|
||||
case .auth:
|
||||
break
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ struct FollowPackBannerImage: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(NSLocalizedString("No cover image", comment: "Text letting user know there is no cover image."))
|
||||
Text("No cover image", comment: "Text letting user know there is no cover image.")
|
||||
.foregroundColor(.gray)
|
||||
.frame(width: 350, height: 180)
|
||||
Divider()
|
||||
@@ -153,12 +153,11 @@ struct FollowPackPreviewBody: View {
|
||||
}
|
||||
|
||||
HStack(alignment: .center) {
|
||||
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true)
|
||||
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true, damusState: state)
|
||||
.onTapGesture {
|
||||
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
|
||||
}
|
||||
let profile_txn = state.profiles.lookup(id: event.event.pubkey)
|
||||
let profile = profile_txn?.unsafeUnownedValue
|
||||
let profile = try? state.profiles.lookup(id: event.event.pubkey)
|
||||
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
|
||||
switch displayName {
|
||||
case .one(let one):
|
||||
|
||||
@@ -66,7 +66,7 @@ struct FollowPackTimelineView<Content: View>: View {
|
||||
.coordinateSpace(name: "scroll")
|
||||
.onReceive(handle_notify(.scroll_to_top)) { () in
|
||||
events.flush()
|
||||
self.events.should_queue = false
|
||||
self.events.set_should_queue(false)
|
||||
scroll_to_event(scroller: scroller, id: "startblock", delay: 0.0, animate: true, anchor: .top)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,12 +131,11 @@ struct FollowPackView: View {
|
||||
}
|
||||
|
||||
HStack(alignment: .center) {
|
||||
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true)
|
||||
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true, damusState: state)
|
||||
.onTapGesture {
|
||||
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
|
||||
}
|
||||
let profile_txn = state.profiles.lookup(id: event.event.pubkey)
|
||||
let profile = profile_txn?.unsafeUnownedValue
|
||||
let profile = try? state.profiles.lookup(id: event.event.pubkey)
|
||||
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
|
||||
switch displayName {
|
||||
case .one(let one):
|
||||
|
||||
@@ -9,17 +9,17 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
func follow_reference(box: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, follow: FollowRef) -> NostrEvent? {
|
||||
func follow_reference(box: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, follow: FollowRef) async -> NostrEvent? {
|
||||
guard let ev = follow_user_event(our_contacts: our_contacts, keypair: keypair, follow: follow) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
box.send(ev)
|
||||
await box.send(ev)
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
func unfollow_reference(postbox: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, unfollow: FollowRef) -> NostrEvent? {
|
||||
func unfollow_reference(postbox: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, unfollow: FollowRef) async -> NostrEvent? {
|
||||
guard let cs = our_contacts else {
|
||||
return nil
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func unfollow_reference(postbox: PostBox, our_contacts: NostrEvent?, keypair: Fu
|
||||
return nil
|
||||
}
|
||||
|
||||
postbox.send(ev)
|
||||
await postbox.send(ev)
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
class Contacts {
|
||||
private var friends: Set<Pubkey> = Set()
|
||||
private var friend_of_friends: Set<Pubkey> = Set()
|
||||
@@ -96,6 +97,13 @@ class Contacts {
|
||||
func get_friended_followers(_ pubkey: Pubkey) -> [Pubkey] {
|
||||
return Array((pubkey_to_our_friends[pubkey] ?? Set()))
|
||||
}
|
||||
|
||||
var friend_filter: (NostrEvent) -> Bool {
|
||||
{ [weak self] ev in
|
||||
guard let self else { return false }
|
||||
return self.is_friend(ev.pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delegate protocol for `Contacts`. Use this to listen to significant updates from a `Contacts` instance
|
||||
|
||||
@@ -14,8 +14,8 @@ class FollowersModel: ObservableObject {
|
||||
@Published var contacts: [Pubkey]? = nil
|
||||
var has_contact: Set<Pubkey> = Set()
|
||||
|
||||
let sub_id: String = UUID().description
|
||||
let profiles_id: String = UUID().description
|
||||
var listener: Task<Void, Never>? = nil
|
||||
var profilesListener: Task<Void, Never>? = nil
|
||||
|
||||
var count: Int? {
|
||||
guard let contacts = self.contacts else {
|
||||
@@ -36,14 +36,22 @@ class FollowersModel: ObservableObject {
|
||||
func subscribe() {
|
||||
let filter = get_filter()
|
||||
let filters = [filter]
|
||||
//print_filters(relay_id: "following", filters: [filters])
|
||||
self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
|
||||
self.listener?.cancel()
|
||||
self.listener = Task {
|
||||
for await lender in damus_state.nostrNetwork.reader.streamIndefinitely(filters: filters) {
|
||||
lender.justUseACopy({ self.handle_event(ev: $0) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
self.listener?.cancel()
|
||||
self.profilesListener?.cancel()
|
||||
self.listener = nil
|
||||
self.profilesListener = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handle_contact_event(_ ev: NostrEvent) {
|
||||
if has_contact.contains(ev.pubkey) {
|
||||
return
|
||||
@@ -52,47 +60,10 @@ class FollowersModel: ObservableObject {
|
||||
contacts?.append(ev.pubkey)
|
||||
has_contact.insert(ev.pubkey)
|
||||
}
|
||||
|
||||
func load_profiles<Y>(relay_id: RelayURL, txn: NdbTxn<Y>) {
|
||||
let authors = find_profiles_to_fetch_from_keys(profiles: damus_state.profiles, pks: contacts ?? [], txn: txn)
|
||||
if authors.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
let filter = NostrFilter(kinds: [.metadata],
|
||||
authors: authors)
|
||||
damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event)
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
guard case .nostr_event(let nev) = ev else {
|
||||
return
|
||||
}
|
||||
|
||||
switch nev {
|
||||
case .event(let sub_id, let ev):
|
||||
guard sub_id == self.sub_id || sub_id == self.profiles_id else {
|
||||
return
|
||||
}
|
||||
|
||||
if ev.known_kind == .contacts {
|
||||
handle_contact_event(ev)
|
||||
}
|
||||
case .notice(let msg):
|
||||
print("followingmodel notice: \(msg)")
|
||||
|
||||
case .eose(let sub_id):
|
||||
if sub_id == self.sub_id {
|
||||
guard let txn = NdbTxn(ndb: self.damus_state.ndb) else { return }
|
||||
load_profiles(relay_id: relay_id, txn: txn)
|
||||
} else if sub_id == self.profiles_id {
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
|
||||
}
|
||||
|
||||
case .ok:
|
||||
break
|
||||
case .auth:
|
||||
break
|
||||
|
||||
func handle_event(ev: NostrEvent) {
|
||||
if ev.known_kind == .contacts {
|
||||
Task { await handle_contact_event(ev) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class FollowingModel {
|
||||
let contacts: [Pubkey]
|
||||
let hashtags: [Hashtag]
|
||||
|
||||
let sub_id: String = UUID().description
|
||||
private var listener: Task<Void, Never>? = nil
|
||||
|
||||
init(damus_state: DamusState, contacts: [Pubkey], hashtags: [Hashtag]) {
|
||||
self.damus_state = damus_state
|
||||
@@ -22,11 +22,11 @@ class FollowingModel {
|
||||
self.hashtags = hashtags
|
||||
}
|
||||
|
||||
func get_filter<Y>(txn: NdbTxn<Y>) -> NostrFilter {
|
||||
func get_filter() -> NostrFilter {
|
||||
var f = NostrFilter(kinds: [.metadata])
|
||||
f.authors = self.contacts.reduce(into: Array<Pubkey>()) { acc, pk in
|
||||
// don't fetch profiles we already have
|
||||
if damus_state.profiles.has_fresh_profile(id: pk, txn: txn) {
|
||||
if (try? damus_state.profiles.has_fresh_profile(id: pk)) ?? false {
|
||||
return
|
||||
}
|
||||
acc.append(pk)
|
||||
@@ -34,26 +34,24 @@ class FollowingModel {
|
||||
return f
|
||||
}
|
||||
|
||||
func subscribe<Y>(txn: NdbTxn<Y>) {
|
||||
let filter = get_filter(txn: txn)
|
||||
func subscribe() {
|
||||
let filter = get_filter()
|
||||
if (filter.authors?.count ?? 0) == 0 {
|
||||
needs_sub = false
|
||||
return
|
||||
}
|
||||
let filters = [filter]
|
||||
//print_filters(relay_id: "following", filters: [filters])
|
||||
self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
|
||||
self.listener?.cancel()
|
||||
self.listener = Task {
|
||||
for await item in self.damus_state.nostrNetwork.reader.advancedStream(filters: filters) {
|
||||
// don't need to do anything here really
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
if !needs_sub {
|
||||
return
|
||||
}
|
||||
print("unsubscribing from following \(sub_id)")
|
||||
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
// don't need to do anything here really
|
||||
self.listener?.cancel()
|
||||
self.listener = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,8 +151,7 @@ struct FollowingView: View {
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.onAppear {
|
||||
guard let txn = NdbTxn(ndb: self.damus_state.ndb) else { return }
|
||||
following.subscribe(txn: txn)
|
||||
following.subscribe()
|
||||
}
|
||||
.onDisappear {
|
||||
following.unsubscribe()
|
||||
|
||||
@@ -100,14 +100,10 @@ struct HighlightEvent {
|
||||
return NSLocalizedString("Highlighted", bundle: bundle, comment: "Label to indicate that the user is highlighting their own post.")
|
||||
}
|
||||
|
||||
guard let profile_txn = NdbTxn(ndb: ndb) else {
|
||||
return ""
|
||||
}
|
||||
|
||||
let names: [String] = pubkeys.map { pk in
|
||||
let prof = ndb.lookup_profile_with_txn(pk, txn: profile_txn)
|
||||
let profile = try? ndb.lookup_profile_and_copy(pk)
|
||||
|
||||
return Profile.displayName(profile: prof?.profile, pubkey: pk).username.truncate(maxLength: 50)
|
||||
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
|
||||
}
|
||||
|
||||
let uniqueNames: [String] = Array(Set(names))
|
||||
|
||||
@@ -63,8 +63,7 @@ struct HighlightEventRef: View {
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.lineLimit(1)
|
||||
|
||||
let profile_txn = damus_state.profiles.lookup(id: longform_event.event.pubkey, txn_name: "highlight-profile")
|
||||
let profile = profile_txn?.unsafeUnownedValue
|
||||
let profile = try? damus_state.profiles.lookup(id: longform_event.event.pubkey)
|
||||
|
||||
if let display_name = profile?.display_name {
|
||||
Text(display_name)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// LabsExplainerView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 11/6/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LabsExplainerView: View {
|
||||
let labName: String
|
||||
let systemImage: String
|
||||
let labDescription: String
|
||||
|
||||
var body: some View {
|
||||
PurpleBackdrop {
|
||||
VStack(alignment: .center) {
|
||||
HStack {
|
||||
Image(systemName: systemImage)
|
||||
.resizable()
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 25, height: 25)
|
||||
Text(labName)
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(labDescription)
|
||||
.foregroundColor(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.overlay(Rectangle().frame(width: nil, height: 1, alignment: .top).foregroundColor(DamusColors.purple), alignment: .top)
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationDetents([.height(300)])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// LabsToggleView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 11/6/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LabsToggleView: View {
|
||||
let toggleName: String
|
||||
let systemImage: String
|
||||
@Binding var isOn: Bool
|
||||
@Binding var showInfo: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
HStack {
|
||||
Toggle(toggleName, systemImage: systemImage, isOn: $isOn)
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
.padding(15)
|
||||
.background(DamusColors.black)
|
||||
.cornerRadius(20)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(isOn ? DamusColors.purple : DamusColors.neutral6, lineWidth: 2)
|
||||
)
|
||||
|
||||
Image("info")
|
||||
.foregroundColor(DamusColors.purple)
|
||||
.onTapGesture {
|
||||
showInfo.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// DamusLabs.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 10/17/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
struct DamusLabsView: View {
|
||||
let damus_state: DamusState
|
||||
@State var purple_account: DamusPurple.Account?
|
||||
|
||||
@State var show_intro_sheet: Bool = true
|
||||
@State private var shouldDismissView = false
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
init(damus_state: DamusState) {
|
||||
self.damus_state = damus_state
|
||||
self.purple_account = nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
PurpleBackdrop {
|
||||
VStack {
|
||||
MainContent
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.navigationBarItems(leading: BackNav())
|
||||
}
|
||||
.onReceive(handle_notify(.switched_timeline)) { _ in
|
||||
dismiss()
|
||||
}
|
||||
.onAppear {
|
||||
notify(.display_tabbar(false))
|
||||
}
|
||||
.task {
|
||||
if damus_state.purple.enable_purple {
|
||||
self.purple_account = try? await damus_state.purple.get_maybe_cached_account(pubkey: damus_state.pubkey)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(.all)
|
||||
}
|
||||
|
||||
var MainContent: some View {
|
||||
VStack {
|
||||
if let purple_account, purple_account.active == true {
|
||||
DamusLabsExperiments(damus_state: damus_state, settings: damus_state.settings)
|
||||
} else {
|
||||
LabsLogoView()
|
||||
.padding(.top, 125)
|
||||
LabsIntroductionView(damus_state: damus_state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DamusLabsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
DamusLabsView(damus_state: test_damus_state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// DamusLabsExpirements.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 10/24/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct DamusLabsExperiments: View {
|
||||
|
||||
let damus_state: DamusState
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
@State var show_live_explainer: Bool = false
|
||||
@State var show_favorites_explainer: Bool = false
|
||||
@State var show_gifs_explainer: Bool = false
|
||||
|
||||
let live_label = NSLocalizedString("Live", comment: "Label for a toggle that enables an experimental feature")
|
||||
let favorites_label = NSLocalizedString("Favorites", comment: "Label for a toggle that enables an experimental feature")
|
||||
let gifs_label = NSLocalizedString("GIFs", comment: "Label for a toggle that enables an experimental feature")
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
|
||||
LabsLogoView()
|
||||
|
||||
VStack(alignment: .leading, spacing: 30) {
|
||||
PurpleViewPrimitives.SubtitleView(text: NSLocalizedString("As a subscriber, you’re getting an early look at new and innovative tools. These are beta features — still being tested and tuned. Try them out, share your thoughts, and help us perfect what’s next.", comment: "Damus Labs explainer"))
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("More features coming soon!", comment: "Label indicating that more features for Damus Lab experiments are coming soon.")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
.fontWeight(.bold)
|
||||
.padding(.bottom, 2)
|
||||
Spacer()
|
||||
}
|
||||
.padding(15)
|
||||
.background(DamusColors.black)
|
||||
.cornerRadius(15)
|
||||
.padding(.top, 10)
|
||||
|
||||
LabsToggleView(toggleName: live_label, systemImage: "record.circle", isOn: $settings.live, showInfo: $show_live_explainer)
|
||||
LabsToggleView(toggleName: favorites_label, systemImage: "heart.fill", isOn: $settings.enable_favourites_feature, showInfo: $show_favorites_explainer)
|
||||
LabsToggleView(toggleName: gifs_label, systemImage: "smiley", isOn: $settings.enable_gifs_feature, showInfo: $show_gifs_explainer)
|
||||
|
||||
}
|
||||
.padding([.trailing, .leading], 20)
|
||||
.padding(.bottom, 50)
|
||||
|
||||
Image("damooseLabs")
|
||||
.resizable()
|
||||
.accessibilityHidden(true)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
}
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
.sheet(isPresented: $show_live_explainer) {
|
||||
LabsExplainerView(
|
||||
labName: live_label,
|
||||
systemImage: "record.circle",
|
||||
labDescription: NSLocalizedString("This will allow you to see all the real-time live streams happening on Nostr! As well as let you view and interact in the Live Chat. Please keep in mind this is still a work in progress and issues are expected. When enabled you will see the Live option in your side menu.", comment: "Damus Labs feature explanation"))
|
||||
}
|
||||
.sheet(isPresented: $show_favorites_explainer) {
|
||||
LabsExplainerView(
|
||||
labName: favorites_label,
|
||||
systemImage: "heart.fill",
|
||||
labDescription: NSLocalizedString("This will allow you to pick users to be part of your favorites list. You can also switch your profile timeline to only see posts from your favorite contacts.", comment: "Damus Labs feature explanation"))
|
||||
}
|
||||
.sheet(isPresented: $show_gifs_explainer) {
|
||||
LabsExplainerView(
|
||||
labName: gifs_label,
|
||||
systemImage: "",
|
||||
labDescription: NSLocalizedString("This will allow you to easily add gifs from Tenor to your posts. You will see the GIF icon in the attachment bar when creating a post. Tapping it will show you all of tenor's featured GIFs. You can also search for GIFs.", comment: "Damus Labs feature explanation"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PurpleBackdrop {
|
||||
DamusLabsExperiments(damus_state: test_damus_state, settings: test_damus_state.settings)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// LabsLogoView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 10/17/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LabsLogoView: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 20) {
|
||||
Image("damus-dark-logo")
|
||||
.resizable()
|
||||
.frame(width: 60, height: 60)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 15.0))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 15)
|
||||
.stroke(LinearGradient(
|
||||
colors: [DamusColors.lighterPink.opacity(0.8), .white.opacity(0), DamusColors.deepPurple.opacity(0.6)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing), lineWidth: 1)
|
||||
)
|
||||
.shadow(radius: 5)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
HStack(spacing: 0) {
|
||||
Text("Labs ", comment: "Feature name")
|
||||
.font(.system(size: 60.0).weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.tracking(-2)
|
||||
.accessibilityHidden(true)
|
||||
Image(systemName: "flask.fill")
|
||||
.padding(.top, 25)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [DamusColors.deepPurple, DamusColors.lighterPink],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.accessibilityHidden(true)
|
||||
Image(systemName: "testtube.2")
|
||||
.padding(.top, 25)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [DamusColors.lighterPink, DamusColors.deepPurple],
|
||||
startPoint: .bottomLeading,
|
||||
endPoint: .topTrailing
|
||||
)
|
||||
)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 30)
|
||||
.accessibilityLabel(NSLocalizedString("Damus Labs stylized logo", comment: "Accessibility label for a stylized Damus Labs logo"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
PurpleBackdrop {
|
||||
LabsLogoView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// LabsIntroduction.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 10/17/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
struct LabsIntroductionView: View {
|
||||
|
||||
let damus_state: DamusState
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
VStack(alignment: .leading, spacing: 30) {
|
||||
PurpleViewPrimitives.SubtitleView(text: NSLocalizedString("Purple subscribers get first access to new and experimental features — fresh ideas straight from the lab.", comment: "Damus purple subscription pitch"))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack {
|
||||
NavigationLink(destination: DamusPurpleView(damus_state: damus_state)) {
|
||||
HStack(spacing: 10) {
|
||||
Spacer()
|
||||
Text("Learn more about Purple", comment: "Button to learn more about the Damus Purple subscription.")
|
||||
.foregroundColor(Color.white)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(PinkGradient)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding([.trailing, .leading], 30)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Image("damooseLabs")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
PurpleBackdrop {
|
||||
LabsIntroductionView(damus_state: test_damus_state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// LiveChatModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 8/7/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The data model for the LiveEventHome view
|
||||
class LiveChatModel: ObservableObject {
|
||||
var events: EventHolder
|
||||
@Published var loading: Bool = false
|
||||
|
||||
let damus_state: DamusState
|
||||
let root: String
|
||||
let dtag: String
|
||||
var subscriptionTask: Task<Void, any Error>? = nil
|
||||
let limit: UInt32 = 1000
|
||||
|
||||
init(damus_state: DamusState, root: String, dtag: String) {
|
||||
self.damus_state = damus_state
|
||||
self.root = root
|
||||
self.dtag = dtag
|
||||
self.events = EventHolder(on_queue: { ev in
|
||||
preload_events(state: damus_state, events: [ev])
|
||||
})
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func filter_muted() {
|
||||
events.filter { should_show_event(state: damus_state, ev: $0) }
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func set(loading: Bool) {
|
||||
self.loading = loading
|
||||
}
|
||||
|
||||
func subscribe() {
|
||||
subscriptionTask?.cancel()
|
||||
|
||||
subscriptionTask = Task {
|
||||
await set(loading: true)
|
||||
|
||||
let live_chat_filter = NostrFilter(kinds: [.live_chat])
|
||||
|
||||
let to_relays = await damus_state.nostrNetwork.ourRelayDescriptors
|
||||
.map { $0.url }
|
||||
.filter { !damus_state.relay_filters.is_filtered(timeline: .search, relay_id: $0) }
|
||||
|
||||
for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [live_chat_filter], to: to_relays) {
|
||||
switch item {
|
||||
case .event(let lender):
|
||||
await lender.justUseACopy({ await handle_event(event: $0) })
|
||||
case .eose:
|
||||
continue
|
||||
case .ndbEose:
|
||||
await set(loading: false)
|
||||
case .networkEose:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func unsubscribe(to: RelayURL? = nil) {
|
||||
set(loading: false)
|
||||
subscriptionTask?.cancel()
|
||||
}
|
||||
|
||||
func handle_event(event: NostrEvent) async {
|
||||
for tag in event.tags {
|
||||
guard tag.count >= 2 else { continue }
|
||||
switch tag[0].string() {
|
||||
case "a":
|
||||
let atag = tag[1].string()
|
||||
let split = atag.split(separator: ":")
|
||||
if root != split[1] {
|
||||
return
|
||||
}
|
||||
if dtag != split[2] {
|
||||
return
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
if should_show_event(state: damus_state, ev: event) {
|
||||
if self.events.insert(event) {
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// LiveChatHomeView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 8/7/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LiveChatHomeView: View, KeyboardReadable {
|
||||
let state: DamusState
|
||||
let event: LiveEvent
|
||||
@StateObject var model: LiveChatModel
|
||||
@State private var chat_message = ""
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
|
||||
var filters = ContentFilters.defaults(damus_state: state)
|
||||
filters.append(fstate.filter)
|
||||
return ContentFilters(filters: filters).filter
|
||||
}
|
||||
|
||||
var Footer: some View {
|
||||
HStack(spacing: 0) {
|
||||
ChatInput
|
||||
|
||||
Button(
|
||||
role: .none,
|
||||
action: {
|
||||
Task { await send_chat() }
|
||||
}
|
||||
) {
|
||||
Label("", image: "send")
|
||||
.font(.title)
|
||||
}
|
||||
.disabled(chat_message.isEmpty)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Color.clear.frame(height: 10)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func send_chat() async {
|
||||
guard
|
||||
let keypair = state.keypair.to_full(),
|
||||
let liveChat = make_live_chat_event(keypair: keypair, content: chat_message, root: event.event.pubkey.hex(), dtag: event.uuid ?? "", relayURL: nil)
|
||||
else {
|
||||
return
|
||||
}
|
||||
await state.nostrNetwork.postbox.send(liveChat)
|
||||
chat_message = ""
|
||||
end_editing()
|
||||
}
|
||||
|
||||
var ChatInput: some View {
|
||||
HStack{
|
||||
TextField(NSLocalizedString("Chat", comment: "Placeholder text to prompt entry of chat message."), text: $chat_message)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($isTextFieldFocused)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.secondary.opacity(0.2))
|
||||
.cornerRadius(20)
|
||||
.padding(.horizontal, 15)
|
||||
}
|
||||
|
||||
func scroll_to_end(_ scroller: ScrollViewProxy, animated: Bool = false) {
|
||||
if animated {
|
||||
withAnimation {
|
||||
scroller.scrollTo("endblock")
|
||||
}
|
||||
} else {
|
||||
scroller.scrollTo("endblock")
|
||||
}
|
||||
}
|
||||
|
||||
var Chat: some View {
|
||||
ScrollViewReader { scroller in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
let events = model.events.events
|
||||
ForEach(Array(zip(events, events.indices).reversed()).filter { should_show_event(state: state, ev: $0.0)}, id: \.0.id) { (ev, ind) in
|
||||
TextEvent(damus: state, event: ev, pubkey: ev.pubkey, options: .live_chat)
|
||||
}
|
||||
EndBlock(height: 1)
|
||||
}
|
||||
}
|
||||
.dismissKeyboardOnTap()
|
||||
.onAppear {
|
||||
scroll_to_end(scroller)
|
||||
}.onChange(of: model.events.events.count) { _ in
|
||||
scroll_to_end(scroller, animated: true)
|
||||
}
|
||||
.padding(.top, 5)
|
||||
|
||||
Footer
|
||||
.onReceive(keyboardPublisher) { visible in
|
||||
guard visible else {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
scroll_to_end(scroller, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Live Chat", comment: "Title for the live stream chat.")
|
||||
.fontWeight(.bold)
|
||||
.padding(5)
|
||||
|
||||
LiveStreamViewers(state: state, currentParticipants: event.currentParticipants ?? 0, preview: false)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Chat
|
||||
}
|
||||
.onReceive(handle_notify(.new_mutes)) { _ in
|
||||
self.model.filter_muted()
|
||||
}
|
||||
.onAppear {
|
||||
model.subscribe()
|
||||
}
|
||||
.onDisappear {
|
||||
model.unsubscribe()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// LiveChatTimeline.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 8/7/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LiveChatTimelineView<Content: View>: View {
|
||||
@ObservedObject var events: EventHolder
|
||||
@Binding var loading: Bool
|
||||
|
||||
let damus: DamusState
|
||||
let show_friend_icon: Bool
|
||||
let filter: (NostrEvent) -> Bool
|
||||
let content: Content?
|
||||
let apply_mute_rules: Bool
|
||||
|
||||
init(events: EventHolder, loading: Binding<Bool>, headerHeight: Binding<CGFloat>, headerOffset: Binding<CGFloat>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
|
||||
self.events = events
|
||||
self._loading = loading
|
||||
self.damus = damus
|
||||
self.show_friend_icon = show_friend_icon
|
||||
self.filter = filter
|
||||
self.apply_mute_rules = apply_mute_rules
|
||||
self.content = content?()
|
||||
}
|
||||
|
||||
init(events: EventHolder, loading: Binding<Bool>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
|
||||
self.events = events
|
||||
self._loading = loading
|
||||
self.damus = damus
|
||||
self.show_friend_icon = show_friend_icon
|
||||
self.filter = filter
|
||||
self.apply_mute_rules = apply_mute_rules
|
||||
self.content = content?()
|
||||
}
|
||||
|
||||
func scroll_to_end(_ scroller: ScrollViewProxy, animated: Bool = false) {
|
||||
if animated {
|
||||
withAnimation {
|
||||
scroller.scrollTo("endblock")
|
||||
}
|
||||
} else {
|
||||
scroller.scrollTo("endblock")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { scroller in
|
||||
ScrollView {
|
||||
if let content {
|
||||
content
|
||||
}
|
||||
|
||||
Color.clear
|
||||
.id("startblock")
|
||||
.frame(height: 0)
|
||||
|
||||
LiveChatInnerView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
|
||||
.redacted(reason: loading ? .placeholder : [])
|
||||
.shimmer(loading)
|
||||
.disabled(loading)
|
||||
.background {
|
||||
GeometryReader { proxy -> Color in
|
||||
handle_scroll_queue(proxy, queue: self.events)
|
||||
return Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: "scroll")
|
||||
.onReceive(handle_notify(.scroll_to_top)) { () in
|
||||
events.flush()
|
||||
self.events.set_should_queue(false)
|
||||
scroll_to_event(scroller: scroller, id: "startblock", delay: 0.0, animate: true, anchor: .top)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
events.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveChatInnerView: View {
|
||||
@ObservedObject var events: EventHolder
|
||||
let state: DamusState
|
||||
let filter: (NostrEvent) -> Bool
|
||||
|
||||
init(events: EventHolder, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true) {
|
||||
self.events = events
|
||||
self.state = damus
|
||||
self.filter = apply_mute_rules ? { filter($0) && !damus.mutelist_manager.is_event_muted($0) } : filter
|
||||
}
|
||||
|
||||
var event_options: EventViewOptions {
|
||||
if self.state.settings.truncate_timeline_text {
|
||||
return [.wide, .truncate_content]
|
||||
}
|
||||
|
||||
return [.wide]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LazyVStack(spacing: 0) {
|
||||
let events = self.events.events
|
||||
if events.isEmpty {
|
||||
EmptyTimelineView()
|
||||
} else {
|
||||
let evs = events.filter(filter)
|
||||
let indexed = Array(zip(evs, 0...))
|
||||
ForEach(indexed, id: \.0.id) { tup in
|
||||
let ev = tup.0
|
||||
let ind = tup.1
|
||||
if ev.kind == NostrKind.live_chat.rawValue {
|
||||
LiveChatView(state: state, ev: ev)
|
||||
.padding(.top, 7)
|
||||
.onAppear {
|
||||
let to_preload =
|
||||
Array([indexed[safe: ind+1]?.0,
|
||||
indexed[safe: ind+2]?.0,
|
||||
indexed[safe: ind+3]?.0,
|
||||
indexed[safe: ind+4]?.0,
|
||||
indexed[safe: ind+5]?.0
|
||||
].compactMap({ $0 }))
|
||||
|
||||
preload_events(state: state, events: to_preload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// LiveChatView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 8/7/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct LiveChatView: View {
|
||||
let state: DamusState
|
||||
let event: NostrEvent
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@ObservedObject var artifacts: NoteArtifactsModel
|
||||
|
||||
init(state: DamusState, ev: NostrEvent) {
|
||||
self.state = state
|
||||
self.event = ev
|
||||
|
||||
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
|
||||
}
|
||||
|
||||
func content_filter(_ pubkeys: [Pubkey]) -> ((NostrEvent) -> Bool) {
|
||||
var filters = ContentFilters.defaults(damus_state: self.state)
|
||||
filters.append({ pubkeys.contains($0.pubkey) })
|
||||
return ContentFilters(filters: filters).filter
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
TextEvent(damus: state, event: event, pubkey: event.pubkey, options: [.no_action_bar,.small_pfp,.wide,.no_previews,.small_text])
|
||||
}
|
||||
.padding(.bottom, 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// LiveEvent.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 7/10/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum LiveEventStatus: String {
|
||||
case planned = "SCHEDULED"
|
||||
case live = "LIVE"
|
||||
case ended = "ENDED"
|
||||
}
|
||||
|
||||
struct LiveEvent: Hashable {
|
||||
let event: NostrEvent
|
||||
var uuid: String? = nil
|
||||
var title: String? = nil
|
||||
var summary: String? = nil
|
||||
var image: URL? = nil
|
||||
var streaming: URL? = nil
|
||||
var recording: URL? = nil
|
||||
var starts: String? = nil
|
||||
var ends: String? = nil
|
||||
var status: LiveEventStatus? = nil
|
||||
var currentParticipants: Int? = nil
|
||||
var totalParticipants: Int? = nil
|
||||
var pinned: String? = nil
|
||||
var hashtags: [String]? = nil
|
||||
var publicKeys: [Pubkey] = []
|
||||
|
||||
static func parse(from ev: NostrEvent) -> LiveEvent {
|
||||
var liveEvent = LiveEvent(event: ev)
|
||||
|
||||
for tag in ev.tags {
|
||||
guard tag.count >= 2 else { continue }
|
||||
switch tag[0].string() {
|
||||
case "title": liveEvent.title = tag[1].string()
|
||||
case "d": liveEvent.uuid = tag[1].string()
|
||||
case "image": liveEvent.image = URL(string: tag[1].string())
|
||||
case "summary": liveEvent.summary = tag[1].string()
|
||||
case "streaming": liveEvent.streaming = URL(string: tag[1].string())
|
||||
case "recording": liveEvent.recording = URL(string: tag[1].string())
|
||||
case "starts": liveEvent.starts = tag[1].string()
|
||||
case "ends": liveEvent.ends = tag[1].string()
|
||||
case "status":
|
||||
if tag[1].string() == "planned" {
|
||||
liveEvent.status = .planned
|
||||
} else if tag[1].string() == "live" {
|
||||
liveEvent.status = .live
|
||||
} else if tag[1].string() == "ended" {
|
||||
liveEvent.status = .ended
|
||||
}
|
||||
case "current_participants": liveEvent.currentParticipants = Int(tag[1].string())
|
||||
case "total_participants": liveEvent.totalParticipants = Int(tag[1].string())
|
||||
case "pinned": liveEvent.pinned = tag[1].string()
|
||||
case "t":
|
||||
if (liveEvent.hashtags?.append(tag[1].string())) == nil {
|
||||
liveEvent.hashtags = [tag[1].string()]
|
||||
}
|
||||
case "p":
|
||||
liveEvent.publicKeys.append(Pubkey(Data(hex: tag[1].string())))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return liveEvent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// LiveEventModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 7/25/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The data model for the LiveEventHome view
|
||||
class LiveEventModel: ObservableObject {
|
||||
var events: EventHolder
|
||||
@Published var loading: Bool = false
|
||||
|
||||
let damus_state: DamusState
|
||||
var subscriptionTask: Task<Void, any Error>? = nil
|
||||
var seen_dtag: Set<String> = Set()
|
||||
|
||||
init(damus_state: DamusState) {
|
||||
self.damus_state = damus_state
|
||||
self.events = EventHolder(on_queue: { ev in
|
||||
preload_events(state: damus_state, events: [ev])
|
||||
})
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func filter_muted() {
|
||||
events.filter { should_show_event(state: damus_state, ev: $0) }
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
|
||||
/// Helper function to set the `loading` member in the correct actor
|
||||
@MainActor
|
||||
private func set(loading: Bool) {
|
||||
self.loading = loading
|
||||
}
|
||||
|
||||
func subscribe() {
|
||||
subscriptionTask?.cancel()
|
||||
|
||||
subscriptionTask = Task {
|
||||
await self.set(loading: true)
|
||||
|
||||
var live_event_filter = NostrFilter(kinds: [.live])
|
||||
live_event_filter.until = UInt32(Date.now.timeIntervalSince1970)
|
||||
let calendar = Calendar.current
|
||||
let twoWeeksAgo = calendar.date(byAdding: .day, value: -14, to: Date())!
|
||||
live_event_filter.since = UInt32(twoWeeksAgo.timeIntervalSince1970)
|
||||
|
||||
let to_relays = await damus_state.nostrNetwork.ourRelayDescriptors
|
||||
.map { $0.url }
|
||||
.filter { !damus_state.relay_filters.is_filtered(timeline: .search, relay_id: $0) }
|
||||
|
||||
for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [live_event_filter], to: to_relays) {
|
||||
switch item {
|
||||
case .event(let lender):
|
||||
await lender.justUseACopy({ await handle_event(ev: $0) })
|
||||
case .eose:
|
||||
continue
|
||||
case .ndbEose:
|
||||
await self.set(loading: false)
|
||||
case .networkEose:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func unsubscribe() {
|
||||
self.set(loading: false)
|
||||
subscriptionTask?.cancel()
|
||||
}
|
||||
|
||||
func handle_event(ev: NostrEvent) async {
|
||||
let should_show_event = await should_show_event(state: damus_state, ev: ev)
|
||||
if ev.is_textlike && should_show_event && !ev.is_reply()
|
||||
{
|
||||
for tag in ev.tags {
|
||||
guard tag.count >= 2 else { continue }
|
||||
if tag[0].string() == "d" {
|
||||
if seen_dtag.contains(tag[1].string()) {
|
||||
return
|
||||
} else {
|
||||
seen_dtag.insert(tag[1].string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
if self.events.insert(ev) {
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// LiveStreamBanner.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 8/8/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct LiveStreamBanner: View {
|
||||
let state: DamusState
|
||||
let options: EventViewOptions
|
||||
var image: URL? = nil
|
||||
var preview: Bool
|
||||
|
||||
func Placeholder(url: URL, preview: Bool) -> some View {
|
||||
Group {
|
||||
if let meta = state.events.lookup_img_metadata(url: url),
|
||||
case .processed(let blurhash) = meta.state {
|
||||
Image(uiImage: blurhash)
|
||||
.resizable()
|
||||
.frame(minWidth: UIScreen.main.bounds.width, minHeight: preview ? 200 : 200, maxHeight: preview ? 200 : 200)
|
||||
} else {
|
||||
DamusColors.adaptableWhite
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func titleImage(url: URL, preview: Bool) -> some View {
|
||||
KFAnimatedImage(url)
|
||||
.callbackQueue(.dispatch(.global(qos:.background)))
|
||||
.backgroundDecode(true)
|
||||
.imageContext(.note, disable_animation: state.settings.disable_animation)
|
||||
.image_fade(duration: 0.25)
|
||||
.cancelOnDisappear(true)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.background {
|
||||
Placeholder(url: url, preview: preview)
|
||||
}
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(minWidth: UIScreen.main.bounds.width, minHeight: preview ? 200 : 200, maxHeight: preview ? 200 : 200)
|
||||
.kfClickable()
|
||||
.cornerRadius(1)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let url = image {
|
||||
if (self.options.contains(.no_media)) {
|
||||
EmptyView()
|
||||
} else {
|
||||
titleImage(url: url, preview: preview)
|
||||
}
|
||||
} else {
|
||||
Text("No cover image", comment: "Text letting user know there is no cover image.")
|
||||
.bold()
|
||||
.foregroundColor(.white)
|
||||
.frame(width: UIScreen.main.bounds.width, height: 200)
|
||||
.background(DamusGradient.gradient.opacity(0.75))
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// LiveStreamProfile.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 8/8/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LiveStreamProfile: View {
|
||||
var state: DamusState
|
||||
var pubkey: Pubkey
|
||||
var size: CGFloat = 25
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
ProfilePicView(pubkey: pubkey, size: size, highlight: .custom(DamusColors.neutral3, 1.0), profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true, damusState: state)
|
||||
.onTapGesture {
|
||||
state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
|
||||
}
|
||||
let profile = try? state.profiles.lookup(id: pubkey)
|
||||
let displayName = Profile.displayName(profile: profile, pubkey: pubkey)
|
||||
switch displayName {
|
||||
case .one(let one):
|
||||
Text(one)
|
||||
.font(.subheadline).foregroundColor(.gray)
|
||||
|
||||
case .both(username: let username, displayName: let displayName):
|
||||
HStack(spacing: 6) {
|
||||
Text(verbatim: displayName)
|
||||
.font(.subheadline).foregroundColor(.gray)
|
||||
|
||||
Text(verbatim: "@\(username)")
|
||||
.font(.subheadline).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(5)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user