Compare commits
550 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 | |||
| 719a0c8cb0 | |||
| 89ad22833d | |||
| 9407c75d60 | |||
| c4e6e5e6a7 | |||
| 592e9f9405 | |||
| d924485bb3 | |||
| b774f28427 | |||
| deae6c0636 | |||
| da386f3bcd | |||
|
55dbb46bb5
|
|||
|
dc8e647c34
|
|||
|
eb25ff3584
|
|||
|
0ae03fc3f3
|
|||
|
e60f74eb9f
|
|||
|
0d75f9cdd9
|
|||
|
33a3ddbfd6
|
|||
|
6555531846
|
|||
|
97b9d06774
|
|||
|
198448b114
|
|||
|
a0333058a6
|
|||
|
e640d5185e
|
|||
|
9723718bc5
|
|||
|
08e19fd395
|
|||
|
7f39c3c4b2
|
|||
|
cd3314c068
|
|||
| f73c0ec1c4 | |||
| 05b62c5860 | |||
| fae061cec0 | |||
| 4570ba797c | |||
| d1ea081018 | |||
| 682704b2cb | |||
| 176f1a338a | |||
| fc1eb326e8 | |||
| 5e420187e0 | |||
| 4815c8a6f7 | |||
| f42ae0673d | |||
| 474e2d8d57 | |||
| 95a91bed7e | |||
| ff12d8bd7e | |||
| f8245a7b0e | |||
| 4036995348 | |||
| 5b6534fd56 | |||
| bdd10cccaa | |||
| e9f4cbe881 | |||
| 91abd187d3 | |||
| b9d8b1dbf3 | |||
| 12a7b483a0 | |||
| caa7802bce | |||
| 9c47d2e0bd | |||
| 5cd5a249ce | |||
| c86b3a999d | |||
| b5afa3c0b4 | |||
| 8f32c81b6c | |||
| f8185d0ca5 | |||
| eb99584501 | |||
| 919f644cba | |||
| 690e1347e0 | |||
| 744bf4bb07 | |||
| 475940aa01 | |||
| 28a06af534 | |||
| 208b3331ca | |||
| 5b1f0c4714 | |||
| 249e765642 | |||
| 712624f515 | |||
| 6e7b3b94d7 | |||
| 969a2b656e | |||
| d8e7b4707e | |||
| a51618cfd3 | |||
| 82da5da4d3 | |||
| 37f9c93705 | |||
| 094cf5e8cc | |||
| 46541694a0 | |||
| 04d4ff4e99 | |||
| 2d02766461 | |||
| 1e6873c879 | |||
| d3496af5cc | |||
| ec798bdeb2 | |||
| fa9b952295 | |||
| 27f55bc09f | |||
| 52845a52bb | |||
| 4e27cca12b | |||
| 98e9ba25da | |||
| e6cb6c938b | |||
| af5961ce26 | |||
| 58de0025aa | |||
| c931108741 | |||
| 20255198fd | |||
| 289a8e262a | |||
| 05baba9c03 | |||
| e0461d3458 | |||
| 62aa72c215 | |||
| 287b35a8fb | |||
| 478d7b4060 | |||
| 2c4728508b | |||
| d24a3f0ce5 | |||
| efba599779 | |||
| 19243d49e1 | |||
| 6845d0df47 | |||
| 8e79ad582a | |||
| 282c02eed4 | |||
| 155ac27bb5 | |||
| be1d149f4b | |||
| 9e0dc47e98 | |||
| 0916b14b32 | |||
| 6818d001f2 | |||
| 4bf9160502 | |||
| 02df1e209b | |||
| 3186b0e1d3 | |||
| de0935582c | |||
| 573de6b881 | |||
| 44ab702792 | |||
| 1fdf234c46 | |||
| 3018200e95 | |||
| 47b79fc02e | |||
| 0c483bb55a | |||
| ddd30054e8 | |||
| 30c5225ed0 | |||
| 8c446f804c | |||
| e92018aee5 | |||
| cfb140472d | |||
| 2f5fd54297 | |||
| 02e970eb9b | |||
| b4b84e6895 | |||
| 7831ede057 | |||
| a8d7d971b1 | |||
| 201cdd7edc | |||
| e3ca6ca5b4 | |||
| 494386d211 | |||
| 6c53bc75f2 | |||
| 6001063754 | |||
| eb0a1ee807 | |||
| 827731b9cb | |||
| 56d44d0004 | |||
| 7742c8fb3c | |||
| 7f2ee78512 | |||
| 4d75894bc4 | |||
| bbed448ccb | |||
| 3fb4d81d48 | |||
| fc30b68c40 | |||
| 0ac25b7aa3 | |||
| b326f007f2 | |||
| a86d8416fc | |||
| b5c57dc935 | |||
| 7d6814a481 | |||
| 8dd048681b | |||
| 2d02a17af6 | |||
| 3171959d85 | |||
| bca3716e33 | |||
| 57db252783 | |||
| 319579f912 | |||
| 92e1e4b08f | |||
| ffc50bb2c1 | |||
| a562be009d | |||
| 30c9bc7db7 | |||
| 0ac03df841 | |||
| db99b4f4d4 | |||
| cc9585b6e3 | |||
| bd17dcfac6 | |||
| 25e91b386c | |||
| 560e9e53cd | |||
| 1c1e5fa2a0 | |||
| 2d5f86b142 | |||
| 89686d758a | |||
| 6c26add1da | |||
| 3c5a83392e | |||
| 1c63c3b9bb | |||
| 0bd4717e01 | |||
| bebd531b58 | |||
| 5788c077c4 | |||
| 1b77b4f0e0 | |||
| 62625c6ff3 | |||
| c8d88058d4 | |||
| b8bef86ea1 | |||
| b128330b2a | |||
| 934ea80f85 | |||
| 588cebd18d | |||
| ccca6e58ec | |||
| c1befa5221 | |||
| 8b3c86c5de | |||
| 05c5a6dacb | |||
| 1a6568deca | |||
| 1b2f4c41df | |||
| 25bcf9c243 | |||
| 3993679cc0 | |||
| e302bf37fa | |||
| a45f4d3087 | |||
| d598e178c1 | |||
| 77601e77ee | |||
| 206efba58a | |||
| a84749cd07 | |||
| 099b588be2 | |||
| 75c7adddb8 | |||
| 9f1b9ab945 | |||
| b2080a946e | |||
| 942e47a720 | |||
| 6dbf3416b9 | |||
| 2b14acd62f | |||
| 267a9ac54b | |||
| 8b03ed6175 | |||
| 6cd7b945ca | |||
| e5e6735129 | |||
| 9c2f7a931c | |||
| b1bbf355de | |||
| d7a2064786 | |||
| 4d14ca8d0a | |||
| 81d65cd5bf | |||
| f03d8a5ac9 | |||
| 0df18ae1a4 | |||
| 8c5ec32eaa | |||
| bdedf8bd8c | |||
| c2383060aa | |||
| 432cdb96d9 | |||
| f580c7dd93 | |||
| c677233dcb | |||
| d063362bd7 | |||
| 088683696a | |||
| f2795aa71c | |||
| c831976078 | |||
| c2c73c3af6 | |||
| 971fa3e4ef | |||
| dfa145dd4a | |||
| 4cfe28d802 | |||
| 034f2cc02f | |||
| 6f9bd6c4f4 | |||
| d73422db38 | |||
| c3b06d281e | |||
| 1b09e9458c | |||
| e0a2dcf3db | |||
| 9ff1f69a82 | |||
| 623b8603c2 | |||
| d8b083010d | |||
| 887eb4e1e2 | |||
| b5ad3ed1a5 | |||
| 371e9fb406 | |||
| aa5809d792 | |||
| 30ba0d72cc | |||
| 373cd71f69 | |||
| acaf327a07 | |||
| 9f0bf7dff5 | |||
| 88d7eb8a86 | |||
| 76862776b8 | |||
| 4c55459c1f | |||
| f7cdc7bc31 | |||
| 1bc4971111 | |||
| 6ce6c79160 | |||
| 1ffbd80c67 | |||
| 1fb88a912a | |||
| 954f48b23d | |||
| cc75a8450a | |||
| 389c2c9695 | |||
| 4a6121ba13 | |||
| a469f2e127 | |||
| 2f8f18b846 | |||
| 3a7cf4d08d | |||
| e3001cc240 | |||
| d1ef113a8b | |||
| f187f4f8f2 | |||
| 4e9583ef54 | |||
| cc95d5df6e | |||
| 4ca156fd83 | |||
| 9f6da8eb79 | |||
| 65a22813a3 | |||
| fdbf271432 | |||
| b26eedc633 | |||
| 793970beaf | |||
| 049d9170be | |||
| fd10c5672a | |||
| 37bd9447f0 | |||
| e8457d7486 | |||
| 280297ad35 | |||
| 7da3ead01e | |||
| 3ddb2625e9 | |||
| f53ffae767 | |||
| b9168f9914 | |||
| 63ff2b6f9e | |||
| 7d9468388b | |||
|
66b555e0ff
|
|||
|
8df332472c
|
|||
|
6072668438
|
|||
|
6f26ddf7ac
|
|||
|
df156df6d9
|
|||
|
11c367b541
|
|||
|
4e1b23d1cb
|
|||
|
2de3083dad
|
|||
|
93149642db
|
|||
|
0b0d422b7a
|
|||
|
036ea50a3a
|
|||
| 073feccbbf | |||
| eeea9d3266 | |||
| b8bf5df7bc | |||
| e9e68422d4 | |||
| 6f9a00d728 | |||
| 51e07df1b5 | |||
| 2a42723b81 | |||
| 839ef6a80d | |||
| c073dd8fea | |||
| 8d9f728cf0 | |||
| 2c62741e25 | |||
| 1f612f7fde | |||
| 0e9e102d0f | |||
| b94e8765a1 | |||
| 53964f5c1a | |||
| bd574d93c3 | |||
| 47514ace79 | |||
| 298b43733f | |||
| 02116c0af5 | |||
| 92121e3b2d | |||
| c92094823e | |||
| f4b1a504a5 | |||
| 99ae7de5eb | |||
| b3d9ee3fc0 | |||
| e65219ee3e | |||
| 414c67a919 | |||
| f436291209 | |||
| a9196a39df | |||
| 6a8ee9c360 | |||
|
947e24864e
|
|||
|
b9198d6bd7
|
|||
| 14bf187a6e | |||
| c996e5f8b3 | |||
|
56dde30cf6
|
|||
|
95bfbae131
|
|||
|
3da0ff7ecc
|
|||
|
b8f846ded8
|
|||
|
e74c45ad39
|
|||
|
e6a03522c6
|
|||
|
dbc7d79ecd
|
|||
|
d2b5a65eca
|
|||
|
16b19d3a96
|
|||
|
70edb8d7c5
|
|||
|
ea04ebe95c
|
|||
|
44cf47faa4
|
|||
|
612abfd862
|
|||
|
20af086273
|
@@ -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.]_
|
||||
@@ -6,3 +6,4 @@ damus.xcodeproj/xcshareddata/xcbaselines
|
||||
TODO.bak
|
||||
tags
|
||||
build-git-hash.txt
|
||||
.build
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -103,7 +103,7 @@ struct NotificationFormatter {
|
||||
content.title = Self.zap_notification_title(zap)
|
||||
content.body = Self.zap_notification_body(profiles: state.profiles, zap: zap)
|
||||
content.sound = UNNotificationSound.default
|
||||
content.userInfo = LossyLocalNotification(type: .zap, mention: .note(notify.event.id)).to_user_info()
|
||||
content.userInfo = LossyLocalNotification(type: .zap, mention: .init(nip19: .note(notify.event.id))).to_user_info()
|
||||
return (content, "myZapNotification")
|
||||
default:
|
||||
// The sync method should have taken care of this.
|
||||
@@ -125,8 +125,7 @@ struct NotificationFormatter {
|
||||
let src = zap.request.ev
|
||||
let pk = zap.is_anon ? ANON_PUBKEY : src.pubkey
|
||||
|
||||
let profile_txn = profiles.lookup(id: pk)
|
||||
let profile = profile_txn?.unsafeUnownedValue
|
||||
let profile = try? profiles.lookup(id: pk)
|
||||
let name = Profile.displayName(profile: profile, pubkey: pk).displayName.truncate(maxLength: 50)
|
||||
|
||||
let sats = NSNumber(value: (Double(zap.invoice.amount) / 1000.0))
|
||||
|
||||
@@ -44,63 +44,61 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
// Log that we got a push notification
|
||||
Log.debug("Got nostr event push notification from pubkey %s", for: .push_notifications, nostr_event.pubkey.hex())
|
||||
|
||||
guard let state = NotificationExtensionState() else {
|
||||
Log.debug("Failed to open nostrdb", for: .push_notifications)
|
||||
Task {
|
||||
guard let state = await NotificationExtensionState() else {
|
||||
Log.debug("Failed to open nostrdb", for: .push_notifications)
|
||||
|
||||
// Something failed to initialize so let's go for the next best thing
|
||||
guard let improved_content = NotificationFormatter.shared.format_message(event: nostr_event) else {
|
||||
// We cannot format this nostr event. Suppress notification.
|
||||
contentHandler(UNNotificationContent())
|
||||
// Something failed to initialize so let's go for the next best thing
|
||||
guard let improved_content = NotificationFormatter.shared.format_message(event: nostr_event) else {
|
||||
// We cannot format this nostr event. Suppress notification.
|
||||
contentHandler(UNNotificationContent())
|
||||
return
|
||||
}
|
||||
contentHandler(improved_content)
|
||||
return
|
||||
}
|
||||
contentHandler(improved_content)
|
||||
return
|
||||
}
|
||||
|
||||
let sender_profile = {
|
||||
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
|
||||
let profile = txn?.unsafeUnownedValue?.profile
|
||||
let picture = ((profile?.picture.map { URL(string: $0) }) ?? URL(string: robohash(nostr_event.pubkey)))!
|
||||
return ProfileBuf(picture: picture,
|
||||
name: profile?.name,
|
||||
display_name: profile?.display_name,
|
||||
nip05: profile?.nip05)
|
||||
}()
|
||||
let sender_pubkey = nostr_event.pubkey
|
||||
let sender_profile = {
|
||||
let profile = try? state.profiles.lookup(id: nostr_event.pubkey)
|
||||
let picture = ((profile?.picture.map { URL(string: $0) }) ?? URL(string: robohash(nostr_event.pubkey)))!
|
||||
return ProfileBuf(picture: picture,
|
||||
name: profile?.name,
|
||||
display_name: profile?.display_name,
|
||||
nip05: profile?.nip05)
|
||||
}()
|
||||
let sender_pubkey = nostr_event.pubkey
|
||||
|
||||
// Don't show notification details that match mute list.
|
||||
// TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
|
||||
if state.mutelist_manager.is_event_muted(nostr_event) {
|
||||
// We cannot really suppress muted notifications until we have the notification supression entitlement.
|
||||
// The best we can do if we ever get those muted notifications (which we generally won't due to server-side processing) is to obscure the details
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = NSLocalizedString("Muted event", comment: "Title for a push notification which has been muted")
|
||||
content.body = NSLocalizedString("This is an event that has been muted according to your mute list rules. We cannot suppress this notification, but we obscured the details to respect your preferences", comment: "Description for a push notification which has been muted, and explanation that we cannot suppress it")
|
||||
content.sound = UNNotificationSound.default
|
||||
contentHandler(content)
|
||||
return
|
||||
}
|
||||
// Don't show notification details that match mute list.
|
||||
// TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
|
||||
if await state.mutelist_manager.is_event_muted(nostr_event) {
|
||||
// We cannot really suppress muted notifications until we have the notification supression entitlement.
|
||||
// The best we can do if we ever get those muted notifications (which we generally won't due to server-side processing) is to obscure the details
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = NSLocalizedString("Muted event", comment: "Title for a push notification which has been muted")
|
||||
content.body = NSLocalizedString("This is an event that has been muted according to your mute list rules. We cannot suppress this notification, but we obscured the details to respect your preferences", comment: "Description for a push notification which has been muted, and explanation that we cannot suppress it")
|
||||
content.sound = UNNotificationSound.default
|
||||
contentHandler(content)
|
||||
return
|
||||
}
|
||||
|
||||
guard should_display_notification(state: state, event: nostr_event, mode: .push) else {
|
||||
Log.debug("should_display_notification failed", for: .push_notifications)
|
||||
// We should not display notification for this event. Suppress notification.
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
guard await should_display_notification(state: state, event: nostr_event, mode: .push) else {
|
||||
Log.debug("should_display_notification failed", for: .push_notifications)
|
||||
// We should not display notification for this event. Suppress notification.
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else {
|
||||
Log.debug("generate_local_notification_object failed", for: .push_notifications)
|
||||
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Task {
|
||||
guard let notification_object = generate_local_notification_object(ndb: state.ndb, from: nostr_event, state: state) else {
|
||||
Log.debug("generate_local_notification_object failed", for: .push_notifications)
|
||||
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
let sender_dn = DisplayName(name: sender_profile.name, display_name: sender_profile.display_name, pubkey: sender_pubkey)
|
||||
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: sender_dn.displayName, notify: notification_object, state: state) else {
|
||||
|
||||
@@ -186,8 +184,13 @@ func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: Str
|
||||
|
||||
// gather recipients
|
||||
if let recipient_note_id = note.direct_replies() {
|
||||
let replying_to = ndb.lookup_note(recipient_note_id)
|
||||
if let replying_to_pk = replying_to?.unsafeUnownedValue?.pubkey {
|
||||
let replying_to_pk = try? ndb.lookup_note(recipient_note_id, borrow: { replying_to_note -> Pubkey? in
|
||||
switch replying_to_note {
|
||||
case .none: return nil
|
||||
case .some(let note): return note.pubkey
|
||||
}
|
||||
})
|
||||
if let replying_to_pk {
|
||||
meta.isReplyToCurrentUser = replying_to_pk == our_pubkey
|
||||
|
||||
if replying_to_pk != sender_pk {
|
||||
@@ -247,8 +250,12 @@ func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: Str
|
||||
}
|
||||
|
||||
func pubkey_to_inperson(ndb: Ndb, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson {
|
||||
let profile_txn = ndb.lookup_profile(pubkey)
|
||||
let profile = profile_txn?.unsafeUnownedValue?.profile
|
||||
let profile = try? ndb.lookup_profile(pubkey, borrow: { profileRecord in
|
||||
switch profileRecord {
|
||||
case .some(let pr): return pr.profile
|
||||
case .none: return nil
|
||||
}
|
||||
})
|
||||
let name = profile?.name
|
||||
let display_name = profile?.display_name
|
||||
let nip05 = profile?.nip05
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
[](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml)
|
||||
<div align="center">
|
||||
|
||||
# damus
|
||||
<img src="./damus/Assets.xcassets/damus-home.imageset/damus-home@2x.png" alt="Damus Logo" title="Damus logo" width=""/>
|
||||
|
||||
# Damus
|
||||
|
||||
The social network you control
|
||||
|
||||
A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS.
|
||||
|
||||
<img src="./ss.png" width="50%" height="50%" />
|
||||
[](/LICENSE)
|
||||
|
||||
## Download and Install
|
||||
|
||||
[](https://apps.apple.com/us/app/damus/id1628663131)
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
iOS 16.0+ • macOS 13.0+
|
||||
|
||||
<img src="./demo1.png" width="70%" height="50%" />
|
||||
|
||||
</div>
|
||||
|
||||
[nostr]: https://github.com/fiatjaf/nostr
|
||||
|
||||
@@ -138,7 +154,7 @@ We have a few mailing lists that anyone can join to get involved in damus develo
|
||||
|
||||
### Contributing
|
||||
|
||||
See [docs/CONTRIBUTING.md](./docs/CONTRIBUTING.md)
|
||||
Before starting to work on any contributions, please read [docs/CONTRIBUTING.md](./docs/CONTRIBUTING.md).
|
||||
|
||||
### Privacy
|
||||
Your internet protocol (IP) address is exposed to the relays you connect to, and third party media hosters (e.g. nostr.build, imgur.com, giphy.com, youtube.com etc.) that render on Damus. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
//
|
||||
// block.h
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-09.
|
||||
//
|
||||
|
||||
#ifndef block_h
|
||||
#define block_h
|
||||
|
||||
#include "nostr_bech32.h"
|
||||
#include "str_block.h"
|
||||
|
||||
#define MAX_BLOCKS 1024
|
||||
|
||||
enum block_type {
|
||||
BLOCK_HASHTAG = 1,
|
||||
BLOCK_TEXT = 2,
|
||||
BLOCK_MENTION_INDEX = 3,
|
||||
BLOCK_MENTION_BECH32 = 4,
|
||||
BLOCK_URL = 5,
|
||||
BLOCK_INVOICE = 6,
|
||||
};
|
||||
|
||||
|
||||
typedef struct invoice_block {
|
||||
struct str_block invstr;
|
||||
union {
|
||||
struct bolt11 *bolt11;
|
||||
};
|
||||
} invoice_block_t;
|
||||
|
||||
typedef struct mention_bech32_block {
|
||||
struct str_block str;
|
||||
struct nostr_bech32 bech32;
|
||||
} mention_bech32_block_t;
|
||||
|
||||
typedef struct note_block {
|
||||
enum block_type type;
|
||||
union {
|
||||
struct str_block str;
|
||||
struct invoice_block invoice;
|
||||
struct mention_bech32_block mention_bech32;
|
||||
int mention_index;
|
||||
} block;
|
||||
} block_t;
|
||||
|
||||
typedef struct note_blocks {
|
||||
int words;
|
||||
int num_blocks;
|
||||
struct note_block *blocks;
|
||||
} blocks_t;
|
||||
|
||||
void blocks_init(struct note_blocks *blocks);
|
||||
void blocks_free(struct note_blocks *blocks);
|
||||
|
||||
#endif /* block_h */
|
||||
@@ -2,7 +2,6 @@
|
||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||
//
|
||||
|
||||
#include "damus.h"
|
||||
#include "bolt11.h"
|
||||
#include "amount.h"
|
||||
#include "nostr_bech32.h"
|
||||
|
||||
-393
@@ -1,393 +0,0 @@
|
||||
//
|
||||
// damus.c
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-10-17.
|
||||
//
|
||||
|
||||
#include "damus.h"
|
||||
#include "cursor.h"
|
||||
#include "bolt11.h"
|
||||
#include "bech32.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static int parse_digit(struct cursor *cur, int *digit) {
|
||||
int c;
|
||||
if ((c = peek_char(cur, 0)) == -1)
|
||||
return 0;
|
||||
|
||||
c -= '0';
|
||||
|
||||
if (c >= 0 && c <= 9) {
|
||||
*digit = c;
|
||||
cur->p++;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
static int parse_mention_index(struct cursor *cur, struct note_block *block) {
|
||||
int d1, d2, d3, ind;
|
||||
u8 *start = cur->p;
|
||||
|
||||
if (!parse_str(cur, "#["))
|
||||
return 0;
|
||||
|
||||
if (!parse_digit(cur, &d1)) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
ind = d1;
|
||||
|
||||
if (parse_digit(cur, &d2))
|
||||
ind = (d1 * 10) + d2;
|
||||
|
||||
if (parse_digit(cur, &d3))
|
||||
ind = (d1 * 100) + (d2 * 10) + d3;
|
||||
|
||||
if (!parse_char(cur, ']')) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
block->type = BLOCK_MENTION_INDEX;
|
||||
block->block.mention_index = ind;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int parse_hashtag(struct cursor *cur, struct note_block *block) {
|
||||
int c;
|
||||
u8 *start = cur->p;
|
||||
|
||||
if (!parse_char(cur, '#'))
|
||||
return 0;
|
||||
|
||||
c = peek_char(cur, 0);
|
||||
if (c == -1 || is_whitespace(c) || c == '#') {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
consume_until_boundary(cur);
|
||||
|
||||
block->type = BLOCK_HASHTAG;
|
||||
block->block.str.start = (const char*)(start + 1);
|
||||
block->block.str.end = (const char*)cur->p;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int add_block(struct note_blocks *blocks, struct note_block block)
|
||||
{
|
||||
if (blocks->num_blocks + 1 >= MAX_BLOCKS)
|
||||
return 0;
|
||||
|
||||
blocks->blocks[blocks->num_blocks++] = block;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int add_text_block(struct note_blocks *blocks, const u8 *start, const u8 *end)
|
||||
{
|
||||
struct note_block b;
|
||||
|
||||
if (start == end)
|
||||
return 1;
|
||||
|
||||
b.type = BLOCK_TEXT;
|
||||
b.block.str.start = (const char*)start;
|
||||
b.block.str.end = (const char*)end;
|
||||
|
||||
return add_block(blocks, b);
|
||||
}
|
||||
|
||||
static int consume_url_fragment(struct cursor *cur)
|
||||
{
|
||||
int c;
|
||||
|
||||
if ((c = peek_char(cur, 0)) < 0)
|
||||
return 1;
|
||||
|
||||
if (c != '#' && c != '?') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
cur->p++;
|
||||
|
||||
return consume_until_end_url(cur, 1);
|
||||
}
|
||||
|
||||
static int consume_url_path(struct cursor *cur)
|
||||
{
|
||||
int c;
|
||||
|
||||
if ((c = peek_char(cur, 0)) < 0)
|
||||
return 1;
|
||||
|
||||
if (c != '/') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
while (cur->p < cur->end) {
|
||||
c = *cur->p;
|
||||
|
||||
if (c == '?' || c == '#' || is_final_url_char(cur->p, cur->end)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
cur->p++;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int consume_url_host(struct cursor *cur)
|
||||
{
|
||||
char c;
|
||||
int count = 0;
|
||||
|
||||
while (cur->p < cur->end) {
|
||||
c = *cur->p;
|
||||
// TODO: handle IDNs
|
||||
if ((is_alphanumeric(c) || c == '.' || c == '-') && !is_final_url_char(cur->p, cur->end))
|
||||
{
|
||||
count++;
|
||||
cur->p++;
|
||||
continue;
|
||||
}
|
||||
|
||||
return count != 0;
|
||||
}
|
||||
|
||||
|
||||
// this means the end of the URL hostname is the end of the buffer and we finished
|
||||
return count != 0;
|
||||
}
|
||||
|
||||
static int parse_url(struct cursor *cur, struct note_block *block) {
|
||||
u8 *start = cur->p;
|
||||
u8 *host;
|
||||
int host_len;
|
||||
struct cursor path_cur;
|
||||
|
||||
if (!parse_str(cur, "http"))
|
||||
return 0;
|
||||
|
||||
if (parse_char(cur, 's') || parse_char(cur, 'S')) {
|
||||
if (!parse_str(cur, "://")) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
if (!parse_str(cur, "://")) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// make sure to save the hostname. We will use this to detect damus.io links
|
||||
host = cur->p;
|
||||
|
||||
if (!consume_url_host(cur)) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// get the length of the host string
|
||||
host_len = (int)(cur->p - host);
|
||||
|
||||
// save the current parse state so that we can continue from here when
|
||||
// parsing the bech32 in the damus.io link if we have it
|
||||
copy_cursor(cur, &path_cur);
|
||||
|
||||
// skip leading /
|
||||
cursor_skip(&path_cur, 1);
|
||||
|
||||
if (!consume_url_path(cur)) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!consume_url_fragment(cur)) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// smart parens
|
||||
if (start - 1 >= 0 &&
|
||||
start < cur->end &&
|
||||
*(start - 1) == '(' &&
|
||||
(cur->p - 1) < cur->end &&
|
||||
*(cur->p - 1) == ')')
|
||||
{
|
||||
cur->p--;
|
||||
}
|
||||
|
||||
// save the bech32 string pos in case we hit a damus.io link
|
||||
block->block.str.start = (const char *)path_cur.p;
|
||||
|
||||
// if we have a damus link, make it a mention
|
||||
if (host_len == 8
|
||||
&& !strncmp((const char *)host, "damus.io", 8)
|
||||
&& parse_nostr_bech32(&path_cur, &block->block.mention_bech32.bech32))
|
||||
{
|
||||
block->block.str.end = (const char *)path_cur.p;
|
||||
block->type = BLOCK_MENTION_BECH32;
|
||||
return 1;
|
||||
}
|
||||
|
||||
block->type = BLOCK_URL;
|
||||
block->block.str.start = (const char *)start;
|
||||
block->block.str.end = (const char *)cur->p;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int parse_invoice(struct cursor *cur, struct note_block *block) {
|
||||
u8 *start, *end;
|
||||
char *fail;
|
||||
struct bolt11 *bolt11;
|
||||
// optional
|
||||
parse_str(cur, "lightning:");
|
||||
|
||||
start = cur->p;
|
||||
|
||||
if (!parse_str(cur, "lnbc"))
|
||||
return 0;
|
||||
|
||||
if (!consume_until_whitespace(cur, 1)) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
end = cur->p;
|
||||
|
||||
char str[end - start + 1];
|
||||
str[end - start] = 0;
|
||||
memcpy(str, start, end - start);
|
||||
|
||||
if (!(bolt11 = bolt11_decode(NULL, str, &fail))) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
block->type = BLOCK_INVOICE;
|
||||
|
||||
block->block.invoice.invstr.start = (const char*)start;
|
||||
block->block.invoice.invstr.end = (const char*)end;
|
||||
block->block.invoice.bolt11 = bolt11;
|
||||
|
||||
cur->p = end;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
static int parse_mention_bech32(struct cursor *cur, struct note_block *block) {
|
||||
u8 *start = cur->p;
|
||||
|
||||
parse_char(cur, '@');
|
||||
parse_str(cur, "nostr:");
|
||||
|
||||
block->block.str.start = (const char *)cur->p;
|
||||
|
||||
if (!parse_nostr_bech32(cur, &block->block.mention_bech32.bech32)) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
block->block.str.end = (const char *)cur->p;
|
||||
|
||||
block->type = BLOCK_MENTION_BECH32;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int add_text_then_block(struct cursor *cur, struct note_blocks *blocks, struct note_block block, u8 **start, const u8 *pre_mention)
|
||||
{
|
||||
if (!add_text_block(blocks, *start, pre_mention))
|
||||
return 0;
|
||||
|
||||
*start = (u8*)cur->p;
|
||||
|
||||
if (!add_block(blocks, block))
|
||||
return 0;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
int damus_parse_content(struct note_blocks *blocks, const char *content) {
|
||||
int cp, c;
|
||||
struct cursor cur;
|
||||
struct note_block block;
|
||||
u8 *start, *pre_mention;
|
||||
|
||||
blocks->words = 0;
|
||||
blocks->num_blocks = 0;
|
||||
make_cursor((u8*)content, (u8*)content + strlen(content), &cur);
|
||||
|
||||
start = cur.p;
|
||||
while (cur.p < cur.end && blocks->num_blocks < MAX_BLOCKS) {
|
||||
cp = peek_char(&cur, -1);
|
||||
c = peek_char(&cur, 0);
|
||||
|
||||
// new word
|
||||
if (is_whitespace(cp) && !is_whitespace(c)) {
|
||||
blocks->words++;
|
||||
}
|
||||
|
||||
pre_mention = cur.p;
|
||||
if (cp == -1 || is_left_boundary(cp) || c == '#') {
|
||||
if (c == '#' && (parse_mention_index(&cur, &block) || parse_hashtag(&cur, &block))) {
|
||||
if (!add_text_then_block(&cur, blocks, block, &start, pre_mention))
|
||||
return 0;
|
||||
continue;
|
||||
} else if ((c == 'h' || c == 'H') && parse_url(&cur, &block)) {
|
||||
if (!add_text_then_block(&cur, blocks, block, &start, pre_mention))
|
||||
return 0;
|
||||
continue;
|
||||
} else if ((c == 'l' || c == 'L') && parse_invoice(&cur, &block)) {
|
||||
if (!add_text_then_block(&cur, blocks, block, &start, pre_mention))
|
||||
return 0;
|
||||
continue;
|
||||
} else if ((c == 'n' || c == '@') && parse_mention_bech32(&cur, &block)) {
|
||||
if (!add_text_then_block(&cur, blocks, block, &start, pre_mention))
|
||||
return 0;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
cur.p++;
|
||||
}
|
||||
|
||||
if (cur.p - start > 0) {
|
||||
if (!add_text_block(blocks, start, cur.p))
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
void blocks_init(struct note_blocks *blocks) {
|
||||
blocks->blocks = malloc(sizeof(struct note_block) * MAX_BLOCKS);
|
||||
blocks->num_blocks = 0;
|
||||
}
|
||||
|
||||
void blocks_free(struct note_blocks *blocks) {
|
||||
if (!blocks->blocks) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < blocks->num_blocks; ++i) {
|
||||
if (blocks->blocks[i].type == BLOCK_MENTION_BECH32) {
|
||||
free(blocks->blocks[i].block.mention_bech32.bech32.buffer);
|
||||
blocks->blocks[i].block.mention_bech32.bech32.buffer = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
free(blocks->blocks);
|
||||
blocks->num_blocks = 0;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//
|
||||
// damus.h
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-10-17.
|
||||
//
|
||||
|
||||
#ifndef damus_h
|
||||
#define damus_h
|
||||
|
||||
#include <stdio.h>
|
||||
#include "block.h"
|
||||
|
||||
typedef unsigned char u8;
|
||||
|
||||
int damus_parse_content(struct note_blocks *blocks, const char *content);
|
||||
|
||||
#endif /* damus_h */
|
||||
@@ -1,84 +0,0 @@
|
||||
/* CC0 (Public domain) - see LICENSE file for details */
|
||||
#ifndef CCAN_HEX_H
|
||||
#define CCAN_HEX_H
|
||||
#include "config.h"
|
||||
#include <stdbool.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/**
|
||||
* hex_decode - Unpack a hex string.
|
||||
* @str: the hexadecimal string
|
||||
* @slen: the length of @str
|
||||
* @buf: the buffer to write the data into
|
||||
* @bufsize: the length of
|
||||
*
|
||||
* Returns false if there are any characters which aren't 0-9, a-f or A-F,
|
||||
* of the string wasn't the right length for @bufsize.
|
||||
*
|
||||
* Example:
|
||||
* unsigned char data[20];
|
||||
*
|
||||
* if (!hex_decode(argv[1], strlen(argv[1]), data, 20))
|
||||
* printf("String is malformed!\n");
|
||||
*/
|
||||
bool hex_decode(const char *str, size_t slen, void *buf, size_t bufsize);
|
||||
|
||||
/**
|
||||
* hex_encode - Create a nul-terminated hex string
|
||||
* @buf: the buffer to read the data from
|
||||
* @bufsize: the length of buf
|
||||
* @dest: the string to fill
|
||||
* @destsize: the max size of the string
|
||||
*
|
||||
* Returns true if the string, including terminator, fit in @destsize;
|
||||
*
|
||||
* Example:
|
||||
* unsigned char buf[] = { 0x1F, 0x2F };
|
||||
* char str[5];
|
||||
*
|
||||
* if (!hex_encode(buf, sizeof(buf), str, sizeof(str)))
|
||||
* abort();
|
||||
*/
|
||||
bool hex_encode(const void *buf, size_t bufsize, char *dest, size_t destsize);
|
||||
|
||||
/**
|
||||
* hex_str_size - Calculate how big a nul-terminated hex string is
|
||||
* @bytes: bytes of data to represent
|
||||
*
|
||||
* Example:
|
||||
* unsigned char buf[] = { 0x1F, 0x2F };
|
||||
* char str[hex_str_size(sizeof(buf))];
|
||||
*
|
||||
* hex_encode(buf, sizeof(buf), str, sizeof(str));
|
||||
*/
|
||||
static inline size_t hex_str_size(size_t bytes)
|
||||
{
|
||||
return 2 * bytes + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* hex_data_size - Calculate how many bytes of data in a hex string
|
||||
* @strlen: the length of the string (with or without NUL)
|
||||
*
|
||||
* Example:
|
||||
* const char str[] = "1F2F";
|
||||
* unsigned char buf[hex_data_size(sizeof(str))];
|
||||
*
|
||||
* hex_decode(str, strlen(str), buf, sizeof(buf));
|
||||
*/
|
||||
static inline size_t hex_data_size(size_t strlen)
|
||||
{
|
||||
return strlen / 2;
|
||||
}
|
||||
|
||||
static inline char hexchar(unsigned int val)
|
||||
{
|
||||
if (val < 10)
|
||||
return '0' + val;
|
||||
if (val < 16)
|
||||
return 'a' + val - 10;
|
||||
abort();
|
||||
}
|
||||
|
||||
|
||||
#endif /* CCAN_HEX_H */
|
||||
@@ -1,325 +0,0 @@
|
||||
//
|
||||
// nostr_bech32.c
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-09.
|
||||
//
|
||||
|
||||
#include "nostr_bech32.h"
|
||||
#include <stdlib.h>
|
||||
#include "endian.h"
|
||||
#include "cursor.h"
|
||||
#include "bech32.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
#define MAX_TLVS 16
|
||||
|
||||
#define TLV_SPECIAL 0
|
||||
#define TLV_RELAY 1
|
||||
#define TLV_AUTHOR 2
|
||||
#define TLV_KIND 3
|
||||
#define TLV_KNOWN_TLVS 4
|
||||
|
||||
struct nostr_tlv {
|
||||
u8 type;
|
||||
u8 len;
|
||||
const u8 *value;
|
||||
};
|
||||
|
||||
struct nostr_tlvs {
|
||||
struct nostr_tlv tlvs[MAX_TLVS];
|
||||
int num_tlvs;
|
||||
};
|
||||
|
||||
static int parse_nostr_tlv(struct cursor *cur, struct nostr_tlv *tlv) {
|
||||
// get the tlv tag
|
||||
if (!pull_byte(cur, &tlv->type))
|
||||
return 0;
|
||||
|
||||
// unknown, fail!
|
||||
if (tlv->type >= TLV_KNOWN_TLVS)
|
||||
return 0;
|
||||
|
||||
// get the length
|
||||
if (!pull_byte(cur, &tlv->len))
|
||||
return 0;
|
||||
|
||||
// is the reported length greater then our buffer? if so fail
|
||||
if (cur->p + tlv->len > cur->end)
|
||||
return 0;
|
||||
|
||||
tlv->value = cur->p;
|
||||
cur->p += tlv->len;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int parse_nostr_tlvs(struct cursor *cur, struct nostr_tlvs *tlvs) {
|
||||
int i;
|
||||
tlvs->num_tlvs = 0;
|
||||
|
||||
for (i = 0; i < MAX_TLVS; i++) {
|
||||
if (parse_nostr_tlv(cur, &tlvs->tlvs[i])) {
|
||||
tlvs->num_tlvs++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tlvs->num_tlvs == 0)
|
||||
return 0;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int find_tlv(struct nostr_tlvs *tlvs, u8 type, struct nostr_tlv **tlv) {
|
||||
*tlv = NULL;
|
||||
|
||||
for (int i = 0; i < tlvs->num_tlvs; i++) {
|
||||
if (tlvs->tlvs[i].type == type) {
|
||||
*tlv = &tlvs->tlvs[i];
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_type(const char *prefix, enum nostr_bech32_type *type) {
|
||||
// Parse type
|
||||
if (strcmp(prefix, "note") == 0) {
|
||||
*type = NOSTR_BECH32_NOTE;
|
||||
return 1;
|
||||
} else if (strcmp(prefix, "npub") == 0) {
|
||||
*type = NOSTR_BECH32_NPUB;
|
||||
return 1;
|
||||
} else if (strcmp(prefix, "nsec") == 0) {
|
||||
*type = NOSTR_BECH32_NSEC;
|
||||
return 1;
|
||||
} else if (strcmp(prefix, "nprofile") == 0) {
|
||||
*type = NOSTR_BECH32_NPROFILE;
|
||||
return 1;
|
||||
} else if (strcmp(prefix, "nevent") == 0) {
|
||||
*type = NOSTR_BECH32_NEVENT;
|
||||
return 1;
|
||||
} else if (strcmp(prefix, "nrelay") == 0) {
|
||||
*type = NOSTR_BECH32_NRELAY;
|
||||
return 1;
|
||||
} else if (strcmp(prefix, "naddr") == 0) {
|
||||
*type = NOSTR_BECH32_NADDR;
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_note(struct cursor *cur, struct bech32_note *note) {
|
||||
return pull_bytes(cur, 32, ¬e->event_id);
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_npub(struct cursor *cur, struct bech32_npub *npub) {
|
||||
return pull_bytes(cur, 32, &npub->pubkey);
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_nsec(struct cursor *cur, struct bech32_nsec *nsec) {
|
||||
return pull_bytes(cur, 32, &nsec->nsec);
|
||||
}
|
||||
|
||||
static int tlvs_to_relays(struct nostr_tlvs *tlvs, struct relays *relays) {
|
||||
struct nostr_tlv *tlv;
|
||||
struct str_block *str;
|
||||
|
||||
relays->num_relays = 0;
|
||||
|
||||
for (int i = 0; i < tlvs->num_tlvs; i++) {
|
||||
tlv = &tlvs->tlvs[i];
|
||||
if (tlv->type != TLV_RELAY)
|
||||
continue;
|
||||
|
||||
if (relays->num_relays + 1 > MAX_RELAYS)
|
||||
break;
|
||||
|
||||
str = &relays->relays[relays->num_relays++];
|
||||
str->start = (const char*)tlv->value;
|
||||
str->end = (const char*)(tlv->value + tlv->len);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static uint32_t decode_tlv_u32(const uint8_t *bytes) {
|
||||
beint32_t *be32_bytes = (beint32_t*)bytes;
|
||||
return be32_to_cpu(*be32_bytes);
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_nevent(struct cursor *cur, struct bech32_nevent *nevent) {
|
||||
struct nostr_tlvs tlvs;
|
||||
struct nostr_tlv *tlv;
|
||||
|
||||
if (!parse_nostr_tlvs(cur, &tlvs))
|
||||
return 0;
|
||||
|
||||
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
|
||||
return 0;
|
||||
|
||||
if (tlv->len != 32)
|
||||
return 0;
|
||||
|
||||
nevent->event_id = tlv->value;
|
||||
|
||||
if (find_tlv(&tlvs, TLV_AUTHOR, &tlv)) {
|
||||
nevent->pubkey = tlv->value;
|
||||
} else {
|
||||
nevent->pubkey = NULL;
|
||||
}
|
||||
|
||||
if(find_tlv(&tlvs, TLV_KIND, &tlv)) {
|
||||
nevent->kind = decode_tlv_u32(tlv->value);
|
||||
nevent->has_kind = true;
|
||||
} else {
|
||||
nevent->has_kind = false;
|
||||
}
|
||||
|
||||
return tlvs_to_relays(&tlvs, &nevent->relays);
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_naddr(struct cursor *cur, struct bech32_naddr *naddr) {
|
||||
struct nostr_tlvs tlvs;
|
||||
struct nostr_tlv *tlv;
|
||||
|
||||
if (!parse_nostr_tlvs(cur, &tlvs))
|
||||
return 0;
|
||||
|
||||
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
|
||||
return 0;
|
||||
|
||||
naddr->identifier.start = (const char*)tlv->value;
|
||||
naddr->identifier.end = (const char*)tlv->value + tlv->len;
|
||||
|
||||
if (!find_tlv(&tlvs, TLV_AUTHOR, &tlv))
|
||||
return 0;
|
||||
|
||||
naddr->pubkey = tlv->value;
|
||||
|
||||
if(!find_tlv(&tlvs, TLV_KIND, &tlv)) {
|
||||
return 0;
|
||||
}
|
||||
naddr->kind = decode_tlv_u32(tlv->value);
|
||||
|
||||
return tlvs_to_relays(&tlvs, &naddr->relays);
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_nprofile(struct cursor *cur, struct bech32_nprofile *nprofile) {
|
||||
struct nostr_tlvs tlvs;
|
||||
struct nostr_tlv *tlv;
|
||||
|
||||
if (!parse_nostr_tlvs(cur, &tlvs))
|
||||
return 0;
|
||||
|
||||
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
|
||||
return 0;
|
||||
|
||||
if (tlv->len != 32)
|
||||
return 0;
|
||||
|
||||
nprofile->pubkey = tlv->value;
|
||||
|
||||
return tlvs_to_relays(&tlvs, &nprofile->relays);
|
||||
}
|
||||
|
||||
static int parse_nostr_bech32_nrelay(struct cursor *cur, struct bech32_nrelay *nrelay) {
|
||||
struct nostr_tlvs tlvs;
|
||||
struct nostr_tlv *tlv;
|
||||
|
||||
if (!parse_nostr_tlvs(cur, &tlvs))
|
||||
return 0;
|
||||
|
||||
if (!find_tlv(&tlvs, TLV_SPECIAL, &tlv))
|
||||
return 0;
|
||||
|
||||
nrelay->relay.start = (const char*)tlv->value;
|
||||
nrelay->relay.end = (const char*)tlv->value + tlv->len;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
int parse_nostr_bech32(struct cursor *cur, struct nostr_bech32 *obj) {
|
||||
u8 *start, *end;
|
||||
|
||||
start = cur->p;
|
||||
|
||||
if (!consume_until_non_alphanumeric(cur, 1)) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
end = cur->p;
|
||||
|
||||
size_t data_len;
|
||||
size_t input_len = end - start;
|
||||
if (input_len < 10 || input_len > 10000) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
obj->buffer = malloc(input_len * 2);
|
||||
if (!obj->buffer)
|
||||
return 0;
|
||||
|
||||
u8 data[input_len];
|
||||
char prefix[input_len];
|
||||
|
||||
if (bech32_decode_len(prefix, data, &data_len, (const char*)start, input_len) == BECH32_ENCODING_NONE) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
obj->buflen = 0;
|
||||
if (!bech32_convert_bits(obj->buffer, &obj->buflen, 8, data, data_len, 5, 0)) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
if (!parse_nostr_bech32_type(prefix, &obj->type)) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
struct cursor bcur;
|
||||
make_cursor(obj->buffer, obj->buffer + obj->buflen, &bcur);
|
||||
|
||||
switch (obj->type) {
|
||||
case NOSTR_BECH32_NOTE:
|
||||
if (!parse_nostr_bech32_note(&bcur, &obj->data.note))
|
||||
goto fail;
|
||||
break;
|
||||
case NOSTR_BECH32_NPUB:
|
||||
if (!parse_nostr_bech32_npub(&bcur, &obj->data.npub))
|
||||
goto fail;
|
||||
break;
|
||||
case NOSTR_BECH32_NSEC:
|
||||
if (!parse_nostr_bech32_nsec(&bcur, &obj->data.nsec))
|
||||
goto fail;
|
||||
break;
|
||||
case NOSTR_BECH32_NEVENT:
|
||||
if (!parse_nostr_bech32_nevent(&bcur, &obj->data.nevent))
|
||||
goto fail;
|
||||
break;
|
||||
case NOSTR_BECH32_NADDR:
|
||||
if (!parse_nostr_bech32_naddr(&bcur, &obj->data.naddr))
|
||||
goto fail;
|
||||
break;
|
||||
case NOSTR_BECH32_NPROFILE:
|
||||
if (!parse_nostr_bech32_nprofile(&bcur, &obj->data.nprofile))
|
||||
goto fail;
|
||||
break;
|
||||
case NOSTR_BECH32_NRELAY:
|
||||
if (!parse_nostr_bech32_nrelay(&bcur, &obj->data.nrelay))
|
||||
goto fail;
|
||||
break;
|
||||
}
|
||||
|
||||
return 1;
|
||||
|
||||
fail:
|
||||
free(obj->buffer);
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
//
|
||||
// nostr_bech32.h
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-09.
|
||||
//
|
||||
|
||||
#ifndef nostr_bech32_h
|
||||
#define nostr_bech32_h
|
||||
|
||||
#include <stdio.h>
|
||||
#include "str_block.h"
|
||||
#include "cursor.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
typedef unsigned char u8;
|
||||
#define MAX_RELAYS 10
|
||||
|
||||
struct relays {
|
||||
struct str_block relays[MAX_RELAYS];
|
||||
int num_relays;
|
||||
};
|
||||
|
||||
enum nostr_bech32_type {
|
||||
NOSTR_BECH32_NOTE = 1,
|
||||
NOSTR_BECH32_NPUB = 2,
|
||||
NOSTR_BECH32_NPROFILE = 3,
|
||||
NOSTR_BECH32_NEVENT = 4,
|
||||
NOSTR_BECH32_NRELAY = 5,
|
||||
NOSTR_BECH32_NADDR = 6,
|
||||
NOSTR_BECH32_NSEC = 7,
|
||||
};
|
||||
|
||||
struct bech32_note {
|
||||
const u8 *event_id;
|
||||
};
|
||||
|
||||
struct bech32_npub {
|
||||
const u8 *pubkey;
|
||||
};
|
||||
|
||||
struct bech32_nsec {
|
||||
const u8 *nsec;
|
||||
};
|
||||
|
||||
struct bech32_nevent {
|
||||
struct relays relays;
|
||||
const u8 *event_id;
|
||||
const u8 *pubkey; // optional
|
||||
uint32_t kind;
|
||||
bool has_kind;
|
||||
};
|
||||
|
||||
struct bech32_nprofile {
|
||||
struct relays relays;
|
||||
const u8 *pubkey;
|
||||
};
|
||||
|
||||
struct bech32_naddr {
|
||||
struct relays relays;
|
||||
struct str_block identifier;
|
||||
const u8 *pubkey;
|
||||
uint32_t kind;
|
||||
};
|
||||
|
||||
struct bech32_nrelay {
|
||||
struct str_block relay;
|
||||
};
|
||||
|
||||
typedef struct nostr_bech32 {
|
||||
enum nostr_bech32_type type;
|
||||
u8 *buffer; // holds strings and tlv stuff
|
||||
size_t buflen;
|
||||
|
||||
union {
|
||||
struct bech32_note note;
|
||||
struct bech32_npub npub;
|
||||
struct bech32_nsec nsec;
|
||||
struct bech32_nevent nevent;
|
||||
struct bech32_nprofile nprofile;
|
||||
struct bech32_naddr naddr;
|
||||
struct bech32_nrelay nrelay;
|
||||
} data;
|
||||
} nostr_bech32_t;
|
||||
|
||||
|
||||
int parse_nostr_bech32(struct cursor *cur, struct nostr_bech32 *obj);
|
||||
|
||||
#endif /* nostr_bech32_h */
|
||||
@@ -1,308 +0,0 @@
|
||||
/* MIT (BSD) license - see LICENSE file for details */
|
||||
/* SHA256 core code translated from the Bitcoin project's C++:
|
||||
*
|
||||
* src/crypto/sha256.cpp commit 417532c8acb93c36c2b6fd052b7c11b6a2906aa2
|
||||
* Copyright (c) 2014 The Bitcoin Core developers
|
||||
* Distributed under the MIT software license, see the accompanying
|
||||
* file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
*/
|
||||
#include "sha256.h"
|
||||
#include "compiler.h"
|
||||
#include "endian.h"
|
||||
#include <stdbool.h>
|
||||
#include <assert.h>
|
||||
#include <string.h>
|
||||
|
||||
static void invalidate_sha256(struct sha256_ctx *ctx)
|
||||
{
|
||||
#ifdef CCAN_CRYPTO_SHA256_USE_OPENSSL
|
||||
ctx->c.md_len = 0;
|
||||
#else
|
||||
ctx->bytes = (size_t)-1;
|
||||
#endif
|
||||
}
|
||||
|
||||
static void check_sha256(struct sha256_ctx *ctx UNUSED)
|
||||
{
|
||||
#ifdef CCAN_CRYPTO_SHA256_USE_OPENSSL
|
||||
assert(ctx->c.md_len != 0);
|
||||
#else
|
||||
assert(ctx->bytes != (size_t)-1);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef CCAN_CRYPTO_SHA256_USE_OPENSSL
|
||||
void sha256_init(struct sha256_ctx *ctx)
|
||||
{
|
||||
SHA256_Init(&ctx->c);
|
||||
}
|
||||
|
||||
void sha256_update(struct sha256_ctx *ctx, const void *p, size_t size)
|
||||
{
|
||||
check_sha256(ctx);
|
||||
SHA256_Update(&ctx->c, p, size);
|
||||
}
|
||||
|
||||
void sha256_done(struct sha256_ctx *ctx, struct sha256 *res)
|
||||
{
|
||||
SHA256_Final(res->u.u8, &ctx->c);
|
||||
invalidate_sha256(ctx);
|
||||
}
|
||||
#else
|
||||
static uint32_t Ch(uint32_t x, uint32_t y, uint32_t z)
|
||||
{
|
||||
return z ^ (x & (y ^ z));
|
||||
}
|
||||
static uint32_t Maj(uint32_t x, uint32_t y, uint32_t z)
|
||||
{
|
||||
return (x & y) | (z & (x | y));
|
||||
}
|
||||
static uint32_t Sigma0(uint32_t x)
|
||||
{
|
||||
return (x >> 2 | x << 30) ^ (x >> 13 | x << 19) ^ (x >> 22 | x << 10);
|
||||
}
|
||||
static uint32_t Sigma1(uint32_t x)
|
||||
{
|
||||
return (x >> 6 | x << 26) ^ (x >> 11 | x << 21) ^ (x >> 25 | x << 7);
|
||||
}
|
||||
static uint32_t sigma0(uint32_t x)
|
||||
{
|
||||
return (x >> 7 | x << 25) ^ (x >> 18 | x << 14) ^ (x >> 3);
|
||||
}
|
||||
static uint32_t sigma1(uint32_t x)
|
||||
{
|
||||
return (x >> 17 | x << 15) ^ (x >> 19 | x << 13) ^ (x >> 10);
|
||||
}
|
||||
|
||||
/** One round of SHA-256. */
|
||||
static void Round(uint32_t a, uint32_t b, uint32_t c, uint32_t *d, uint32_t e, uint32_t f, uint32_t g, uint32_t *h, uint32_t k, uint32_t w)
|
||||
{
|
||||
uint32_t t1 = *h + Sigma1(e) + Ch(e, f, g) + k + w;
|
||||
uint32_t t2 = Sigma0(a) + Maj(a, b, c);
|
||||
*d += t1;
|
||||
*h = t1 + t2;
|
||||
}
|
||||
|
||||
/** Perform one SHA-256 transformation, processing a 64-byte chunk. */
|
||||
static void Transform(uint32_t *s, const uint32_t *chunk)
|
||||
{
|
||||
uint32_t a = s[0], b = s[1], c = s[2], d = s[3], e = s[4], f = s[5], g = s[6], h = s[7];
|
||||
uint32_t w0, w1, w2, w3, w4, w5, w6, w7, w8, w9, w10, w11, w12, w13, w14, w15;
|
||||
|
||||
Round(a, b, c, &d, e, f, g, &h, 0x428a2f98, w0 = be32_to_cpu(chunk[0]));
|
||||
Round(h, a, b, &c, d, e, f, &g, 0x71374491, w1 = be32_to_cpu(chunk[1]));
|
||||
Round(g, h, a, &b, c, d, e, &f, 0xb5c0fbcf, w2 = be32_to_cpu(chunk[2]));
|
||||
Round(f, g, h, &a, b, c, d, &e, 0xe9b5dba5, w3 = be32_to_cpu(chunk[3]));
|
||||
Round(e, f, g, &h, a, b, c, &d, 0x3956c25b, w4 = be32_to_cpu(chunk[4]));
|
||||
Round(d, e, f, &g, h, a, b, &c, 0x59f111f1, w5 = be32_to_cpu(chunk[5]));
|
||||
Round(c, d, e, &f, g, h, a, &b, 0x923f82a4, w6 = be32_to_cpu(chunk[6]));
|
||||
Round(b, c, d, &e, f, g, h, &a, 0xab1c5ed5, w7 = be32_to_cpu(chunk[7]));
|
||||
Round(a, b, c, &d, e, f, g, &h, 0xd807aa98, w8 = be32_to_cpu(chunk[8]));
|
||||
Round(h, a, b, &c, d, e, f, &g, 0x12835b01, w9 = be32_to_cpu(chunk[9]));
|
||||
Round(g, h, a, &b, c, d, e, &f, 0x243185be, w10 = be32_to_cpu(chunk[10]));
|
||||
Round(f, g, h, &a, b, c, d, &e, 0x550c7dc3, w11 = be32_to_cpu(chunk[11]));
|
||||
Round(e, f, g, &h, a, b, c, &d, 0x72be5d74, w12 = be32_to_cpu(chunk[12]));
|
||||
Round(d, e, f, &g, h, a, b, &c, 0x80deb1fe, w13 = be32_to_cpu(chunk[13]));
|
||||
Round(c, d, e, &f, g, h, a, &b, 0x9bdc06a7, w14 = be32_to_cpu(chunk[14]));
|
||||
Round(b, c, d, &e, f, g, h, &a, 0xc19bf174, w15 = be32_to_cpu(chunk[15]));
|
||||
|
||||
Round(a, b, c, &d, e, f, g, &h, 0xe49b69c1, w0 += sigma1(w14) + w9 + sigma0(w1));
|
||||
Round(h, a, b, &c, d, e, f, &g, 0xefbe4786, w1 += sigma1(w15) + w10 + sigma0(w2));
|
||||
Round(g, h, a, &b, c, d, e, &f, 0x0fc19dc6, w2 += sigma1(w0) + w11 + sigma0(w3));
|
||||
Round(f, g, h, &a, b, c, d, &e, 0x240ca1cc, w3 += sigma1(w1) + w12 + sigma0(w4));
|
||||
Round(e, f, g, &h, a, b, c, &d, 0x2de92c6f, w4 += sigma1(w2) + w13 + sigma0(w5));
|
||||
Round(d, e, f, &g, h, a, b, &c, 0x4a7484aa, w5 += sigma1(w3) + w14 + sigma0(w6));
|
||||
Round(c, d, e, &f, g, h, a, &b, 0x5cb0a9dc, w6 += sigma1(w4) + w15 + sigma0(w7));
|
||||
Round(b, c, d, &e, f, g, h, &a, 0x76f988da, w7 += sigma1(w5) + w0 + sigma0(w8));
|
||||
Round(a, b, c, &d, e, f, g, &h, 0x983e5152, w8 += sigma1(w6) + w1 + sigma0(w9));
|
||||
Round(h, a, b, &c, d, e, f, &g, 0xa831c66d, w9 += sigma1(w7) + w2 + sigma0(w10));
|
||||
Round(g, h, a, &b, c, d, e, &f, 0xb00327c8, w10 += sigma1(w8) + w3 + sigma0(w11));
|
||||
Round(f, g, h, &a, b, c, d, &e, 0xbf597fc7, w11 += sigma1(w9) + w4 + sigma0(w12));
|
||||
Round(e, f, g, &h, a, b, c, &d, 0xc6e00bf3, w12 += sigma1(w10) + w5 + sigma0(w13));
|
||||
Round(d, e, f, &g, h, a, b, &c, 0xd5a79147, w13 += sigma1(w11) + w6 + sigma0(w14));
|
||||
Round(c, d, e, &f, g, h, a, &b, 0x06ca6351, w14 += sigma1(w12) + w7 + sigma0(w15));
|
||||
Round(b, c, d, &e, f, g, h, &a, 0x14292967, w15 += sigma1(w13) + w8 + sigma0(w0));
|
||||
|
||||
Round(a, b, c, &d, e, f, g, &h, 0x27b70a85, w0 += sigma1(w14) + w9 + sigma0(w1));
|
||||
Round(h, a, b, &c, d, e, f, &g, 0x2e1b2138, w1 += sigma1(w15) + w10 + sigma0(w2));
|
||||
Round(g, h, a, &b, c, d, e, &f, 0x4d2c6dfc, w2 += sigma1(w0) + w11 + sigma0(w3));
|
||||
Round(f, g, h, &a, b, c, d, &e, 0x53380d13, w3 += sigma1(w1) + w12 + sigma0(w4));
|
||||
Round(e, f, g, &h, a, b, c, &d, 0x650a7354, w4 += sigma1(w2) + w13 + sigma0(w5));
|
||||
Round(d, e, f, &g, h, a, b, &c, 0x766a0abb, w5 += sigma1(w3) + w14 + sigma0(w6));
|
||||
Round(c, d, e, &f, g, h, a, &b, 0x81c2c92e, w6 += sigma1(w4) + w15 + sigma0(w7));
|
||||
Round(b, c, d, &e, f, g, h, &a, 0x92722c85, w7 += sigma1(w5) + w0 + sigma0(w8));
|
||||
Round(a, b, c, &d, e, f, g, &h, 0xa2bfe8a1, w8 += sigma1(w6) + w1 + sigma0(w9));
|
||||
Round(h, a, b, &c, d, e, f, &g, 0xa81a664b, w9 += sigma1(w7) + w2 + sigma0(w10));
|
||||
Round(g, h, a, &b, c, d, e, &f, 0xc24b8b70, w10 += sigma1(w8) + w3 + sigma0(w11));
|
||||
Round(f, g, h, &a, b, c, d, &e, 0xc76c51a3, w11 += sigma1(w9) + w4 + sigma0(w12));
|
||||
Round(e, f, g, &h, a, b, c, &d, 0xd192e819, w12 += sigma1(w10) + w5 + sigma0(w13));
|
||||
Round(d, e, f, &g, h, a, b, &c, 0xd6990624, w13 += sigma1(w11) + w6 + sigma0(w14));
|
||||
Round(c, d, e, &f, g, h, a, &b, 0xf40e3585, w14 += sigma1(w12) + w7 + sigma0(w15));
|
||||
Round(b, c, d, &e, f, g, h, &a, 0x106aa070, w15 += sigma1(w13) + w8 + sigma0(w0));
|
||||
|
||||
Round(a, b, c, &d, e, f, g, &h, 0x19a4c116, w0 += sigma1(w14) + w9 + sigma0(w1));
|
||||
Round(h, a, b, &c, d, e, f, &g, 0x1e376c08, w1 += sigma1(w15) + w10 + sigma0(w2));
|
||||
Round(g, h, a, &b, c, d, e, &f, 0x2748774c, w2 += sigma1(w0) + w11 + sigma0(w3));
|
||||
Round(f, g, h, &a, b, c, d, &e, 0x34b0bcb5, w3 += sigma1(w1) + w12 + sigma0(w4));
|
||||
Round(e, f, g, &h, a, b, c, &d, 0x391c0cb3, w4 += sigma1(w2) + w13 + sigma0(w5));
|
||||
Round(d, e, f, &g, h, a, b, &c, 0x4ed8aa4a, w5 += sigma1(w3) + w14 + sigma0(w6));
|
||||
Round(c, d, e, &f, g, h, a, &b, 0x5b9cca4f, w6 += sigma1(w4) + w15 + sigma0(w7));
|
||||
Round(b, c, d, &e, f, g, h, &a, 0x682e6ff3, w7 += sigma1(w5) + w0 + sigma0(w8));
|
||||
Round(a, b, c, &d, e, f, g, &h, 0x748f82ee, w8 += sigma1(w6) + w1 + sigma0(w9));
|
||||
Round(h, a, b, &c, d, e, f, &g, 0x78a5636f, w9 += sigma1(w7) + w2 + sigma0(w10));
|
||||
Round(g, h, a, &b, c, d, e, &f, 0x84c87814, w10 += sigma1(w8) + w3 + sigma0(w11));
|
||||
Round(f, g, h, &a, b, c, d, &e, 0x8cc70208, w11 += sigma1(w9) + w4 + sigma0(w12));
|
||||
Round(e, f, g, &h, a, b, c, &d, 0x90befffa, w12 += sigma1(w10) + w5 + sigma0(w13));
|
||||
Round(d, e, f, &g, h, a, b, &c, 0xa4506ceb, w13 += sigma1(w11) + w6 + sigma0(w14));
|
||||
Round(c, d, e, &f, g, h, a, &b, 0xbef9a3f7, w14 + sigma1(w12) + w7 + sigma0(w15));
|
||||
Round(b, c, d, &e, f, g, h, &a, 0xc67178f2, w15 + sigma1(w13) + w8 + sigma0(w0));
|
||||
|
||||
s[0] += a;
|
||||
s[1] += b;
|
||||
s[2] += c;
|
||||
s[3] += d;
|
||||
s[4] += e;
|
||||
s[5] += f;
|
||||
s[6] += g;
|
||||
s[7] += h;
|
||||
}
|
||||
|
||||
static bool alignment_ok(const void *p UNUSED, size_t n UNUSED)
|
||||
{
|
||||
#if HAVE_UNALIGNED_ACCESS
|
||||
return true;
|
||||
#else
|
||||
return ((size_t)p % n == 0);
|
||||
#endif
|
||||
}
|
||||
|
||||
static void add(struct sha256_ctx *ctx, const void *p, size_t len)
|
||||
{
|
||||
const unsigned char *data = p;
|
||||
size_t bufsize = ctx->bytes % 64;
|
||||
|
||||
if (bufsize + len >= 64) {
|
||||
/* Fill the buffer, and process it. */
|
||||
memcpy(ctx->buf.u8 + bufsize, data, 64 - bufsize);
|
||||
ctx->bytes += 64 - bufsize;
|
||||
data += 64 - bufsize;
|
||||
len -= 64 - bufsize;
|
||||
Transform(ctx->s, ctx->buf.u32);
|
||||
bufsize = 0;
|
||||
}
|
||||
|
||||
while (len >= 64) {
|
||||
/* Process full chunks directly from the source. */
|
||||
if (alignment_ok(data, sizeof(uint32_t)))
|
||||
Transform(ctx->s, (const uint32_t *)data);
|
||||
else {
|
||||
memcpy(ctx->buf.u8, data, sizeof(ctx->buf));
|
||||
Transform(ctx->s, ctx->buf.u32);
|
||||
}
|
||||
ctx->bytes += 64;
|
||||
data += 64;
|
||||
len -= 64;
|
||||
}
|
||||
|
||||
if (len) {
|
||||
/* Fill the buffer with what remains. */
|
||||
memcpy(ctx->buf.u8 + bufsize, data, len);
|
||||
ctx->bytes += len;
|
||||
}
|
||||
}
|
||||
|
||||
void sha256_init(struct sha256_ctx *ctx)
|
||||
{
|
||||
struct sha256_ctx init = SHA256_INIT;
|
||||
*ctx = init;
|
||||
}
|
||||
|
||||
void sha256_update(struct sha256_ctx *ctx, const void *p, size_t size)
|
||||
{
|
||||
check_sha256(ctx);
|
||||
add(ctx, p, size);
|
||||
}
|
||||
|
||||
void sha256_done(struct sha256_ctx *ctx, struct sha256 *res)
|
||||
{
|
||||
static const unsigned char pad[64] = {0x80};
|
||||
uint64_t sizedesc;
|
||||
size_t i;
|
||||
|
||||
sizedesc = cpu_to_be64((uint64_t)ctx->bytes << 3);
|
||||
/* Add '1' bit to terminate, then all 0 bits, up to next block - 8. */
|
||||
add(ctx, pad, 1 + ((128 - 8 - (ctx->bytes % 64) - 1) % 64));
|
||||
/* Add number of bits of data (big endian) */
|
||||
add(ctx, &sizedesc, 8);
|
||||
for (i = 0; i < sizeof(ctx->s) / sizeof(ctx->s[0]); i++)
|
||||
res->u.u32[i] = cpu_to_be32(ctx->s[i]);
|
||||
invalidate_sha256(ctx);
|
||||
}
|
||||
#endif
|
||||
|
||||
void sha256(struct sha256 *sha, const void *p, size_t size)
|
||||
{
|
||||
struct sha256_ctx ctx;
|
||||
|
||||
sha256_init(&ctx);
|
||||
sha256_update(&ctx, p, size);
|
||||
sha256_done(&ctx, sha);
|
||||
}
|
||||
|
||||
void sha256_u8(struct sha256_ctx *ctx, uint8_t v)
|
||||
{
|
||||
sha256_update(ctx, &v, sizeof(v));
|
||||
}
|
||||
|
||||
void sha256_u16(struct sha256_ctx *ctx, uint16_t v)
|
||||
{
|
||||
sha256_update(ctx, &v, sizeof(v));
|
||||
}
|
||||
|
||||
void sha256_u32(struct sha256_ctx *ctx, uint32_t v)
|
||||
{
|
||||
sha256_update(ctx, &v, sizeof(v));
|
||||
}
|
||||
|
||||
void sha256_u64(struct sha256_ctx *ctx, uint64_t v)
|
||||
{
|
||||
sha256_update(ctx, &v, sizeof(v));
|
||||
}
|
||||
|
||||
/* Add as little-endian */
|
||||
void sha256_le16(struct sha256_ctx *ctx, uint16_t v)
|
||||
{
|
||||
leint16_t lev = cpu_to_le16(v);
|
||||
sha256_update(ctx, &lev, sizeof(lev));
|
||||
}
|
||||
|
||||
void sha256_le32(struct sha256_ctx *ctx, uint32_t v)
|
||||
{
|
||||
leint32_t lev = cpu_to_le32(v);
|
||||
sha256_update(ctx, &lev, sizeof(lev));
|
||||
}
|
||||
|
||||
void sha256_le64(struct sha256_ctx *ctx, uint64_t v)
|
||||
{
|
||||
leint64_t lev = cpu_to_le64(v);
|
||||
sha256_update(ctx, &lev, sizeof(lev));
|
||||
}
|
||||
|
||||
/* Add as big-endian */
|
||||
void sha256_be16(struct sha256_ctx *ctx, uint16_t v)
|
||||
{
|
||||
beint16_t bev = cpu_to_be16(v);
|
||||
sha256_update(ctx, &bev, sizeof(bev));
|
||||
}
|
||||
|
||||
void sha256_be32(struct sha256_ctx *ctx, uint32_t v)
|
||||
{
|
||||
beint32_t bev = cpu_to_be32(v);
|
||||
sha256_update(ctx, &bev, sizeof(bev));
|
||||
}
|
||||
|
||||
void sha256_be64(struct sha256_ctx *ctx, uint64_t v)
|
||||
{
|
||||
beint64_t bev = cpu_to_be64(v);
|
||||
sha256_update(ctx, &bev, sizeof(bev));
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
|
||||
#ifndef PROTOVERSE_TYPEDEFS_H
|
||||
#define PROTOVERSE_TYPEDEFS_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
typedef unsigned char u8;
|
||||
typedef unsigned int u32;
|
||||
typedef unsigned short u16;
|
||||
typedef uint64_t u64;
|
||||
typedef int64_t s64;
|
||||
|
||||
|
||||
#endif /* PROTOVERSE_TYPEDEFS_H */
|
||||
+23
-23
@@ -1179,7 +1179,7 @@ static INLINE int parse_i64(struct cursor *read, uint64_t *val)
|
||||
shift = 0;
|
||||
|
||||
do {
|
||||
if (!pull_byte(read, &byte))
|
||||
if (!cursor_pull_byte(read, &byte))
|
||||
return 0;
|
||||
*val |= (byte & 0x7FULL) << shift;
|
||||
shift += 7;
|
||||
@@ -1199,7 +1199,7 @@ static INLINE int uleb128_read(struct cursor *read, unsigned int *val)
|
||||
*val = 0;
|
||||
|
||||
for (;;) {
|
||||
if (!pull_byte(read, &byte))
|
||||
if (!cursor_pull_byte(read, &byte))
|
||||
return 0;
|
||||
|
||||
*val |= (0x7F & byte) << shift;
|
||||
@@ -1222,7 +1222,7 @@ static INLINE int sleb128_read(struct cursor *read, signed int *val)
|
||||
shift = 0;
|
||||
|
||||
do {
|
||||
if (!pull_byte(read, &byte))
|
||||
if (!cursor_pull_byte(read, &byte))
|
||||
return 0;
|
||||
*val |= ((byte & 0x7F) << shift);
|
||||
shift += 7;
|
||||
@@ -1241,21 +1241,21 @@ static INLINE int uleb128_read(struct cursor *read, unsigned int *val)
|
||||
unsigned char p[6] = {0};
|
||||
*val = 0;
|
||||
|
||||
if (pull_byte(read, &p[0]) && (p[0] & 0x80) == 0) {
|
||||
if (cursor_pull_byte(read, &p[0]) && (p[0] & 0x80) == 0) {
|
||||
*val = LEB128_1(unsigned int);
|
||||
if (p[0] == 0x7F)
|
||||
assert((int)*val == -1);
|
||||
return 1;
|
||||
} else if (pull_byte(read, &p[1]) && (p[1] & 0x80) == 0) {
|
||||
} else if (cursor_pull_byte(read, &p[1]) && (p[1] & 0x80) == 0) {
|
||||
*val = LEB128_2(unsigned int);
|
||||
return 2;
|
||||
} else if (pull_byte(read, &p[2]) && (p[2] & 0x80) == 0) {
|
||||
} else if (cursor_pull_byte(read, &p[2]) && (p[2] & 0x80) == 0) {
|
||||
*val = LEB128_3(unsigned int);
|
||||
return 3;
|
||||
} else if (pull_byte(read, &p[3]) && (p[3] & 0x80) == 0) {
|
||||
} else if (cursor_pull_byte(read, &p[3]) && (p[3] & 0x80) == 0) {
|
||||
*val = LEB128_4(unsigned int);
|
||||
return 4;
|
||||
} else if (pull_byte(read, &p[4]) && (p[4] & 0x80) == 0) {
|
||||
} else if (cursor_pull_byte(read, &p[4]) && (p[4] & 0x80) == 0) {
|
||||
if (!(p[4] & 0xF0)) {
|
||||
*val = LEB128_5(unsigned int);
|
||||
return 5;
|
||||
@@ -1296,7 +1296,7 @@ static int parse_section_tag(struct cursor *cur, enum section_tag *section)
|
||||
|
||||
start = cur->p;
|
||||
|
||||
if (!pull_byte(cur, &byte)) {
|
||||
if (!cursor_pull_byte(cur, &byte)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1315,7 +1315,7 @@ static int parse_valtype(struct wasm_parser *p, enum valtype *valtype)
|
||||
|
||||
start = p->cur.p;
|
||||
|
||||
if (unlikely(!pull_byte(&p->cur, (unsigned char*)valtype))) {
|
||||
if (unlikely(!cursor_pull_byte(&p->cur, (unsigned char*)valtype))) {
|
||||
return parse_err(p, "valtype tag oob");
|
||||
}
|
||||
|
||||
@@ -1416,7 +1416,7 @@ static int parse_export_desc(struct wasm_parser *p, enum exportdesc *desc)
|
||||
{
|
||||
unsigned char byte;
|
||||
|
||||
if (!pull_byte(&p->cur, &byte)) {
|
||||
if (!cursor_pull_byte(&p->cur, &byte)) {
|
||||
parse_err(p, "export desc byte eof");
|
||||
return 0;
|
||||
}
|
||||
@@ -1523,7 +1523,7 @@ static int parse_name_subsection(struct wasm_parser *p, struct namesec *sec, u32
|
||||
u8 tag;
|
||||
u8 *start = p->cur.p;
|
||||
|
||||
if (!pull_byte(&p->cur, &tag))
|
||||
if (!cursor_pull_byte(&p->cur, &tag))
|
||||
return parse_err(p, "name subsection tag oob?");
|
||||
|
||||
if (!is_valid_name_subsection(tag))
|
||||
@@ -1676,7 +1676,7 @@ static int parse_reftype(struct wasm_parser *p, enum reftype *reftype)
|
||||
{
|
||||
u8 tag;
|
||||
|
||||
if (!pull_byte(&p->cur, &tag)) {
|
||||
if (!cursor_pull_byte(&p->cur, &tag)) {
|
||||
parse_err(p, "reftype");
|
||||
return 0;
|
||||
}
|
||||
@@ -1720,7 +1720,7 @@ static int parse_export_section(struct wasm_parser *p,
|
||||
static int parse_limits(struct wasm_parser *p, struct limits *limits)
|
||||
{
|
||||
unsigned char tag;
|
||||
if (!pull_byte(&p->cur, &tag)) {
|
||||
if (!cursor_pull_byte(&p->cur, &tag)) {
|
||||
return parse_err(p, "oob");
|
||||
}
|
||||
|
||||
@@ -1803,7 +1803,7 @@ static void print_code(u8 *code, int code_len)
|
||||
make_cursor(code, code + code_len, &c);
|
||||
|
||||
for (;;) {
|
||||
if (!pull_byte(&c, &tag)) {
|
||||
if (!cursor_pull_byte(&c, &tag)) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -2169,7 +2169,7 @@ static int parse_const_expr(struct expr_parser *p, struct expr *expr)
|
||||
expr->code = p->code->p;
|
||||
|
||||
while (1) {
|
||||
if (unlikely(!pull_byte(p->code, &tag))) {
|
||||
if (unlikely(!cursor_pull_byte(p->code, &tag))) {
|
||||
return note_error(p->errs, p->code, "oob");
|
||||
}
|
||||
|
||||
@@ -2332,7 +2332,7 @@ static int parse_instrs_until_at(struct expr_parser *p, u8 stop_instr,
|
||||
p->code->p - p->code->start,
|
||||
dbg_inst, instr_name(stop_instr));
|
||||
for (;;) {
|
||||
if (!pull_byte(p->code, &tag))
|
||||
if (!cursor_pull_byte(p->code, &tag))
|
||||
return note_error(p->errs, p->code, "oob");
|
||||
|
||||
if ((tag != i_if && tag == stop_instr) ||
|
||||
@@ -2413,7 +2413,7 @@ static int parse_element(struct wasm_parser *p, struct elem *elem)
|
||||
|
||||
make_expr_parser(&p->errs, &p->cur, &expr_parser);
|
||||
|
||||
if (!pull_byte(&p->cur, &tag))
|
||||
if (!cursor_pull_byte(&p->cur, &tag))
|
||||
return parse_err(p, "tag");
|
||||
|
||||
if (tag > 7)
|
||||
@@ -2545,7 +2545,7 @@ static int parse_wdata(struct wasm_parser *p, struct wdata *data)
|
||||
struct expr_parser parser;
|
||||
u8 tag;
|
||||
|
||||
if (!pull_byte(&p->cur, &tag)) {
|
||||
if (!cursor_pull_byte(&p->cur, &tag)) {
|
||||
return parse_err(p, "tag");
|
||||
}
|
||||
|
||||
@@ -2700,7 +2700,7 @@ static int parse_importdesc(struct wasm_parser *p, struct importdesc *desc)
|
||||
{
|
||||
u8 tag;
|
||||
|
||||
if (!pull_byte(&p->cur, &tag)) {
|
||||
if (!cursor_pull_byte(&p->cur, &tag)) {
|
||||
parse_err(p, "oom");
|
||||
return 0;
|
||||
}
|
||||
@@ -4134,7 +4134,7 @@ static int parse_blocktype(struct cursor *cur, struct errors *errs, struct block
|
||||
{
|
||||
unsigned char byte;
|
||||
|
||||
if (unlikely(!pull_byte(cur, &byte))) {
|
||||
if (unlikely(!cursor_pull_byte(cur, &byte))) {
|
||||
return note_error(errs, cur, "parse_blocktype: oob\n");
|
||||
}
|
||||
|
||||
@@ -4656,7 +4656,7 @@ static int parse_bulk_op(struct cursor *code, struct errors *errs,
|
||||
{
|
||||
u8 tag;
|
||||
|
||||
if (unlikely(!pull_byte(code, &tag)))
|
||||
if (unlikely(!cursor_pull_byte(code, &tag)))
|
||||
return note_error(errs, code, "oob");
|
||||
|
||||
if (unlikely(tag < 10 || tag > 17))
|
||||
@@ -6552,7 +6552,7 @@ static INLINE int interp_parse_instr(struct wasm_interp *interp,
|
||||
{
|
||||
u8 tag;
|
||||
|
||||
if (unlikely(!pull_byte(code, &tag))) {
|
||||
if (unlikely(!cursor_pull_byte(code, &tag))) {
|
||||
return interp_error(interp, "no more instrs to pull");
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ static const unsigned char WASM_MAGIC[] = {0,'a','s','m'};
|
||||
#define interp_error(p, fmt, ...) note_error(&((p)->errors), interp_codeptr(p), fmt, ##__VA_ARGS__)
|
||||
#define parse_err(p, fmt, ...) note_error(&((p)->errs), &(p)->cur, fmt, ##__VA_ARGS__)
|
||||
|
||||
#include "short_types.h"
|
||||
|
||||
enum valtype {
|
||||
val_i32 = 0x7F,
|
||||
val_i64 = 0x7E,
|
||||
|
||||
+2487
-831
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "06318d35ee2e6bd681b95591e67da33a9461b48a3c652e58bd9d1a6f0d82bdac",
|
||||
"originHash" : "c718c1e7dcc1a07671694b2d7d7311e11804fbbaf22f4b81e49523a3df816ad6",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "codescanner",
|
||||
@@ -35,6 +35,15 @@
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "faviconfinder",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/will-lumley/FaviconFinder.git",
|
||||
"state" : {
|
||||
"revision" : "9279f4371f4877ca302ba3bf1015f3f58ae4a56c",
|
||||
"version" : "5.1.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gsplayer",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -53,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",
|
||||
@@ -105,6 +132,15 @@
|
||||
"version" : "0.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftsoup",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/scinfu/SwiftSoup.git",
|
||||
"state" : {
|
||||
"revision" : "bba848db50462894e7fc0891d018dfecad4ef11e",
|
||||
"version" : "2.8.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftycrop",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
+46
-1
@@ -25,18 +25,57 @@ enum AppAccessibilityIdentifiers: String {
|
||||
case sign_in_confirm_button
|
||||
|
||||
|
||||
// MARK: Sign Up / Create Account
|
||||
// Prefix: `sign_up`
|
||||
|
||||
/// Button to navigate to create account view
|
||||
case sign_up_option_button
|
||||
/// Text field for entering name during account creation
|
||||
case sign_up_name_field
|
||||
/// Text field for entering bio during account creation
|
||||
case sign_up_bio_field
|
||||
/// Button to proceed to the next step after entering profile info
|
||||
case sign_up_next_button
|
||||
/// Button to save keys after account creation
|
||||
case sign_up_save_keys_button
|
||||
/// Button to skip saving keys
|
||||
case sign_up_skip_save_keys_button
|
||||
|
||||
|
||||
// MARK: Onboarding
|
||||
// Prefix: `onboarding`
|
||||
|
||||
/// Any interest option button on the "select your interests" page during onboarding
|
||||
case onboarding_interest_option_button
|
||||
|
||||
/// The "next" button on the onboarding interest page
|
||||
case onboarding_interest_page_next_page
|
||||
|
||||
/// The "next" button on the onboarding content settings page
|
||||
case onboarding_content_settings_page_next_page
|
||||
|
||||
/// The skip button on the onboarding sheet
|
||||
case onboarding_sheet_skip_button
|
||||
|
||||
|
||||
// MARK: Post composer
|
||||
// Prefix: `post_composer`
|
||||
|
||||
|
||||
/// The cancel post button
|
||||
case post_composer_cancel_button
|
||||
|
||||
/// The text view where the user types their note
|
||||
case post_composer_text_view
|
||||
|
||||
/// A user result in the mention autocomplete list
|
||||
case post_composer_mention_user_result
|
||||
|
||||
|
||||
// MARK: Post button (FAB)
|
||||
// Prefix: `post_button`
|
||||
|
||||
/// The floating action button to create a new post
|
||||
case post_button
|
||||
|
||||
// MARK: Main interface layout
|
||||
// Prefix: `main`
|
||||
@@ -51,6 +90,12 @@ enum AppAccessibilityIdentifiers: String {
|
||||
/// The profile option in the side menu
|
||||
case side_menu_profile_button
|
||||
|
||||
/// The logout button in the side menu
|
||||
case side_menu_logout_button
|
||||
|
||||
/// The logout confirmation button in the alert dialog
|
||||
case side_menu_logout_confirm_button
|
||||
|
||||
|
||||
// MARK: Items specific to the user's own profile
|
||||
// Prefix: `own_profile`
|
||||
@@ -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 |
@@ -1,15 +0,0 @@
|
||||
//
|
||||
// AlbyGradient.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-05-09.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
fileprivate let alby_grad_c1 = hex_col(r: 226, g: 168, b: 122)
|
||||
fileprivate let alby_grad_c2 = hex_col(r: 249, g: 223, b: 127)
|
||||
fileprivate let alby_grad = [alby_grad_c2, alby_grad_c1]
|
||||
|
||||
let AlbyGradient: LinearGradient =
|
||||
LinearGradient(colors: alby_grad, startPoint: .bottomLeading, endPoint: .topTrailing)
|
||||
+249
-302
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
import AVKit
|
||||
import MediaPlayer
|
||||
import EmojiPicker
|
||||
import TipKit
|
||||
|
||||
struct ZapSheet {
|
||||
let target: ZapTarget
|
||||
@@ -134,6 +135,7 @@ struct ContentView: View {
|
||||
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
|
||||
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
|
||||
let sub_id = UUID().description
|
||||
@State var damusClosingTask: Task<Void, Never>? = nil
|
||||
|
||||
// connect retry timer
|
||||
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
@@ -172,13 +174,13 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
case .home:
|
||||
PostingTimelineView(damus_state: damus_state!, home: home, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset)
|
||||
PostingTimelineView(damus_state: damus_state!, home: home, homeEvents: home.events, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset)
|
||||
|
||||
case .notifications:
|
||||
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
||||
|
||||
case .dms:
|
||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
|
||||
DirectMessagesView(damus_state: damus_state!, home: home, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle)
|
||||
}
|
||||
}
|
||||
.background(DamusColors.adaptableWhite)
|
||||
@@ -194,6 +196,9 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
notify(.display_tabbar(true))
|
||||
}
|
||||
}
|
||||
|
||||
func MaybeReportView(target: ReportTarget) -> some View {
|
||||
@@ -298,16 +303,20 @@ struct ContentView: View {
|
||||
.ignoresSafeArea(.keyboard)
|
||||
.edgesIgnoringSafeArea(hide_bar ? [.bottom] : [])
|
||||
.onAppear() {
|
||||
self.connect()
|
||||
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)
|
||||
setup_notifications()
|
||||
if !hasSeenOnboardingSuggestions || damus_state!.settings.always_show_onboarding_suggestions {
|
||||
active_sheet = .onboardingSuggestions
|
||||
hasSeenOnboardingSuggestions = true
|
||||
}
|
||||
self.appDelegate?.state = damus_state
|
||||
Task { // We probably don't need this to be a detached task. According to https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Defining-and-Calling-Asynchronous-Functions, awaits are only suspension points that do not block the thread.
|
||||
await self.listenAndHandleLocalNotifications()
|
||||
Task {
|
||||
await self.connect()
|
||||
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)
|
||||
setup_notifications()
|
||||
if !hasSeenOnboardingSuggestions || damus_state!.settings.always_show_onboarding_suggestions {
|
||||
if damus_state.is_privkey_user {
|
||||
active_sheet = .onboardingSuggestions
|
||||
hasSeenOnboardingSuggestions = true
|
||||
}
|
||||
}
|
||||
self.appDelegate?.state = damus_state
|
||||
Task { // We probably don't need this to be a detached task. According to https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Defining-and-Calling-Asynchronous-Functions, awaits are only suspension points that do not block the thread.
|
||||
await self.listenAndHandleLocalNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $active_sheet) { item in
|
||||
@@ -333,7 +342,20 @@ struct ContentView: View {
|
||||
.presentationDetents([.height(550)])
|
||||
.presentationDragIndicator(.visible)
|
||||
case .onboardingSuggestions:
|
||||
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!))
|
||||
if let model = try? SuggestedUsersViewModel(damus_state: damus_state!) {
|
||||
OnboardingSuggestionsView(model: model)
|
||||
.interactiveDismissDisabled(true)
|
||||
}
|
||||
else {
|
||||
ErrorView(
|
||||
damus_state: damus_state,
|
||||
error: .init(
|
||||
user_visible_description: NSLocalizedString("Unexpected error loading user suggestions", comment: "Human readable error label"),
|
||||
tip: NSLocalizedString("Please contact support", comment: "Human readable error tip"),
|
||||
technical_info: "Error inializing SuggestedUsersViewModel"
|
||||
)
|
||||
)
|
||||
}
|
||||
case .purple(let purple_url):
|
||||
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
|
||||
case .purple_onboarding:
|
||||
@@ -356,7 +378,7 @@ struct ContentView: View {
|
||||
self.hide_bar = !show
|
||||
}
|
||||
.onReceive(timer) { n in
|
||||
self.damus_state?.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
|
||||
@@ -367,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)
|
||||
@@ -414,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
|
||||
@@ -433,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
|
||||
@@ -458,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)
|
||||
}
|
||||
}
|
||||
@@ -495,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:
|
||||
@@ -504,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.")) {
|
||||
@@ -531,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 {
|
||||
@@ -546,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.")
|
||||
@@ -573,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 {
|
||||
@@ -587,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 {
|
||||
@@ -639,7 +694,7 @@ struct ContentView: View {
|
||||
self.execute_open_action(openAction)
|
||||
}
|
||||
|
||||
func connect() {
|
||||
func connect() async {
|
||||
// nostrdb
|
||||
var mndb = Ndb()
|
||||
if mndb == nil {
|
||||
@@ -661,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,
|
||||
@@ -686,11 +742,14 @@ struct ContentView: View {
|
||||
video: DamusVideoCoordinator(),
|
||||
ndb: ndb,
|
||||
quote_reposts: .init(our_pubkey: pubkey),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||
favicon_cache: FaviconCache()
|
||||
)
|
||||
|
||||
home.damus_state = self.damus_state!
|
||||
|
||||
await damus_state.snapshotManager.startPeriodicSnapshots()
|
||||
|
||||
if let damus_state, damus_state.purple.enable_purple {
|
||||
// Assign delegate so that we can send receipts to the Purple API server as soon as we get updates from user's purchases
|
||||
StoreObserver.standard.delegate = damus_state.purple
|
||||
@@ -702,31 +761,52 @@ struct ContentView: View {
|
||||
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
|
||||
}
|
||||
|
||||
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 {
|
||||
do {
|
||||
try Tips.resetDatastore()
|
||||
} catch {
|
||||
Log.error("Failed to reset tips datastore: %s", for: .tips, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
do {
|
||||
try Tips.configure()
|
||||
} catch {
|
||||
Log.error("Failed to configure tips: %s", for: .tips, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
await damus_state.nostrNetwork.connect()
|
||||
// TODO: Move this to a better spot. Not sure what is the best signal to listen to for sending initial filters
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: {
|
||||
self.home.send_initial_filters()
|
||||
})
|
||||
}
|
||||
|
||||
func music_changed(_ state: MusicState) {
|
||||
guard let damus_state else { return }
|
||||
switch state {
|
||||
case .playback_state:
|
||||
break
|
||||
case .song(let song):
|
||||
guard let song, let kp = damus_state.keypair.to_full() else { return }
|
||||
|
||||
let pdata = damus_state.profiles.profile_data(damus_state.pubkey)
|
||||
|
||||
let desc = "\(song.title ?? "Unknown") - \(song.artist ?? "Unknown")"
|
||||
let encodedDesc = desc.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
|
||||
let url = encodedDesc.flatMap { enc in
|
||||
URL(string: "spotify:search:\(enc)")
|
||||
Task {
|
||||
guard let damus_state else { return }
|
||||
switch state {
|
||||
case .playback_state:
|
||||
break
|
||||
case .song(let song):
|
||||
guard let song, let kp = damus_state.keypair.to_full() else { return }
|
||||
|
||||
let pdata = damus_state.profiles.profile_data(damus_state.pubkey)
|
||||
|
||||
let desc = "\(song.title ?? "Unknown") - \(song.artist ?? "Unknown")"
|
||||
let encodedDesc = desc.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
|
||||
let url = encodedDesc.flatMap { enc in
|
||||
URL(string: "spotify:search:\(enc)")
|
||||
}
|
||||
let music = UserStatus(type: .music, expires_at: Date.now.addingTimeInterval(song.playbackDuration), content: desc, created_at: UInt32(Date.now.timeIntervalSince1970), url: url)
|
||||
|
||||
pdata.status.music = music
|
||||
|
||||
guard let ev = music.to_note(keypair: kp) else { return }
|
||||
await damus_state.nostrNetwork.postbox.send(ev)
|
||||
}
|
||||
let music = UserStatus(type: .music, expires_at: Date.now.addingTimeInterval(song.playbackDuration), content: desc, created_at: UInt32(Date.now.timeIntervalSince1970), url: url)
|
||||
|
||||
pdata.status.music = music
|
||||
|
||||
guard let ev = music.to_note(keypair: kp) else { return }
|
||||
damus_state.nostrNetwork.postbox.send(ev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,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
|
||||
@@ -878,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()
|
||||
@@ -913,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 ""
|
||||
@@ -1093,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
|
||||
}
|
||||
@@ -1121,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
|
||||
}
|
||||
@@ -1146,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
|
||||
@@ -1188,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 {
|
||||
case .pubkey(let pubkey):
|
||||
switch self.mention.nip19 {
|
||||
case .npub(let pubkey):
|
||||
return .route(.ProfileByKey(pubkey: pubkey))
|
||||
case .note(let noteId):
|
||||
return .route(.LoadableNostrEvent(note_reference: .note_id(noteId)))
|
||||
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))
|
||||
@@ -1211,14 +1151,21 @@ extension LossyLocalNotification {
|
||||
)))
|
||||
case .naddr(let nAddr):
|
||||
return .route(.LoadableNostrEvent(note_reference: .naddr(nAddr)))
|
||||
case .nsec(_):
|
||||
// `nsec` urls are a terrible idea security-wise, so we should intentionally not support those — in order to discourage their use.
|
||||
return .sheet(.error(ErrorView.UserPresentableError(
|
||||
user_visible_description: NSLocalizedString("You opened an invalid link. The link you tried to open refers to \"nsec\", which is not supported.", comment: "User-visible error description for a user who tries to open an unsupported \"nsec\" link."),
|
||||
tip: NSLocalizedString("Please contact the person who provided the link, and ask for another link. Also, this link may have sensitive information, please use caution before sharing it.", comment: "User-visible tip on what to do if a link contains an unsupported \"nsec\" reference."),
|
||||
technical_info: "`MentionRef.toViewOpenAction` detected unsupported `nsec` contents"
|
||||
)))
|
||||
case .nscript(let script):
|
||||
return .route(.Script(script: ScriptModel(data: script, state: .not_loaded)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func logout(_ state: DamusState?)
|
||||
{
|
||||
state?.close()
|
||||
notify(.logout)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// Interests.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-06-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct DIP06 {
|
||||
/// Standard general interest topics.
|
||||
/// See https://github.com/damus-io/dips/pull/3
|
||||
enum Interest: String, CaseIterable {
|
||||
/// Bitcoin-related topics (e.g. Bitcoin, Lightning, e-cash etc)
|
||||
case bitcoin = "bitcoin"
|
||||
/// Any non-Bitcoin technology-related topic (e.g. Linux, new releases, software development, supersonic flight, etc)
|
||||
case technology = "technology"
|
||||
/// Any science-related topic (e.g. astronomy, biology, physics, etc)
|
||||
case science = "science"
|
||||
/// Lifestyle topics (e.g. Worldschooling, Digital nomading, vagabonding, homesteading, digital minimalism, life hacks, etc)
|
||||
case lifestyle = "lifestyle"
|
||||
/// Travel-related topics (e.g. Information about locations to visit, travel logs, etc)
|
||||
case travel = "travel"
|
||||
/// Any art-related topic (e.g. poetry, painting, sculpting, photography, etc)
|
||||
case art = "art"
|
||||
/// Topics focused on improving human health (e.g. advances in medicine, exercising, nutrition, meditation, sleep, etc)
|
||||
case health = "health"
|
||||
/// Any music-related topic (e.g. Bands, fan pages, instruments, classical music theory, etc)
|
||||
case music = "music"
|
||||
/// Any topic related to food (e.g. Cooking, recipes, meal planning, nutrition)
|
||||
case food = "food"
|
||||
/// Any topic related to sports (e.g. Athlete fan pages, general sports information, sports news, sports equipment, etc)
|
||||
case sports = "sports"
|
||||
/// Any topic related to religion, spirituality, or faith (e.g. Christianity, Judaism, Buddhism, Islamism, Hinduism, Taoism, general meditation practice, etc)
|
||||
case religionSpirituality = "religion-spirituality"
|
||||
/// General humanities topics (e.g. philosophy, sociology, culture, etc)
|
||||
case humanities = "humanities"
|
||||
/// General topics about politics
|
||||
case politics = "politics"
|
||||
/// Other miscellaneous topics that do not fit in any of the previous items of the list
|
||||
case other = "other"
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .bitcoin:
|
||||
return NSLocalizedString("₿ Bitcoin", comment: "Interest topic label")
|
||||
case .technology:
|
||||
return NSLocalizedString("💻 Tech", comment: "Interest topic label")
|
||||
case .science:
|
||||
return NSLocalizedString("🔭 Science", comment: "Interest topic label")
|
||||
case .lifestyle:
|
||||
return NSLocalizedString("🏝️ Lifestyle", comment: "Interest topic label")
|
||||
case .travel:
|
||||
return NSLocalizedString("✈️ Travel", comment: "Interest topic label")
|
||||
case .art:
|
||||
return NSLocalizedString("🎨 Art", comment: "Interest topic label")
|
||||
case .health:
|
||||
return NSLocalizedString("🏃 Health", comment: "Interest topic label")
|
||||
case .music:
|
||||
return NSLocalizedString("🎶 Music", comment: "Interest topic label")
|
||||
case .food:
|
||||
return NSLocalizedString("🍱 Food", comment: "Interest topic label")
|
||||
case .sports:
|
||||
return NSLocalizedString("⚾️ Sports", comment: "Interest topic label")
|
||||
case .religionSpirituality:
|
||||
return NSLocalizedString("🛐 Religion", comment: "Interest topic label")
|
||||
case .humanities:
|
||||
return NSLocalizedString("📚 Humanities", comment: "Interest topic label")
|
||||
case .politics:
|
||||
return NSLocalizedString("🏛️ Politics", comment: "Interest topic label")
|
||||
case .other:
|
||||
return NSLocalizedString("♾️ Other", comment: "Interest topic label")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//
|
||||
// InterestList.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D'Aquino on 2025-06-23.
|
||||
//
|
||||
// Some text excerpts taken from the Nostr Protocol itself (which are public domain)
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Includes models and functions for working with NIP-51
|
||||
struct NIP51: Sendable {}
|
||||
|
||||
extension NIP51 {
|
||||
/// An error thrown when decoding an item into a NIP-51 list
|
||||
enum NIP51DecodingError: Error {
|
||||
/// The Nostr event being converted is not a NIP-51 interest list
|
||||
case notInterestList
|
||||
}
|
||||
}
|
||||
|
||||
extension NIP51 {
|
||||
/// Models a NIP-51 Interest List (kind:10015)
|
||||
struct InterestList: NostrEventConvertible, Sendable {
|
||||
typealias E = NIP51DecodingError
|
||||
|
||||
enum InterestItem: Sendable, Hashable {
|
||||
case hashtag(String)
|
||||
case interestSet(String, String, String) // a-tag: kind, pubkey, identifier
|
||||
|
||||
var tag: [String] {
|
||||
switch self {
|
||||
case .hashtag(let tag):
|
||||
return ["t", tag]
|
||||
case .interestSet(let kind, let pubkey, let identifier):
|
||||
var tag = ["a", "\(kind):\(pubkey):\(identifier)"]
|
||||
return tag
|
||||
}
|
||||
}
|
||||
|
||||
static func fromTag(tag: TagSequence) -> InterestItem? {
|
||||
var i = tag.makeIterator()
|
||||
|
||||
guard let t0 = i.next(),
|
||||
let t1 = i.next() else { return nil }
|
||||
|
||||
let tagName = t0.string()
|
||||
|
||||
if tagName == "t" {
|
||||
return .hashtag(t1.string())
|
||||
} else if tagName == "a" {
|
||||
let components = t1.string().split(separator: ":")
|
||||
guard components.count > 2 else { return nil }
|
||||
|
||||
let kind = String(components[0])
|
||||
let pubkey = String(components[1])
|
||||
let identifier = String(components[2])
|
||||
|
||||
return .interestSet(kind, pubkey, identifier)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
let interests: [InterestItem]
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(event: NdbNote) throws(E) {
|
||||
try self.init(event: UnownedNdbNote(event))
|
||||
}
|
||||
|
||||
init(event: borrowing UnownedNdbNote) throws(E) {
|
||||
guard event.known_kind == .interest_list else {
|
||||
throw E.notInterestList
|
||||
}
|
||||
|
||||
var interests: [InterestItem] = []
|
||||
|
||||
for tag in event.tags {
|
||||
if let interest = InterestItem.fromTag(tag: tag) {
|
||||
interests.append(interest)
|
||||
}
|
||||
}
|
||||
|
||||
self.interests = interests
|
||||
}
|
||||
|
||||
init?(event: NdbNote?) throws(E) {
|
||||
guard let event else { return nil }
|
||||
try self.init(event: event)
|
||||
}
|
||||
|
||||
init(interests: [InterestItem]) {
|
||||
self.interests = interests
|
||||
}
|
||||
|
||||
// MARK: - Conversion to a Nostr Event
|
||||
|
||||
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32? = nil) -> NostrEvent? {
|
||||
return NdbNote(
|
||||
content: "",
|
||||
keypair: keypair.to_keypair(),
|
||||
kind: NostrKind.interest_list.rawValue,
|
||||
tags: self.interests.map { $0.tag },
|
||||
createdAt: timestamp ?? UInt32(Date.now.timeIntervalSince1970)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//
|
||||
// NostrNetworkManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-02-26.
|
||||
//
|
||||
import Foundation
|
||||
|
||||
/// Manages interactions with the Nostr Network.
|
||||
///
|
||||
/// This delineates a layer that is responsible for doing mid-level management of interactions with the Nostr network, controlling lower-level classes that perform more network/DB specific code, and providing an easier to use and more semantic interfaces for the rest of the app.
|
||||
///
|
||||
/// This is responsible for:
|
||||
/// - Managing the user's relay list
|
||||
/// - Establishing a `RelayPool` and maintaining it in sync with the user's relay list as it changes
|
||||
/// - Abstracting away complexities of interacting with the nostr network, providing an easier-to-use interface to fetch and send content related to the Nostr network
|
||||
///
|
||||
/// This is **NOT** responsible for:
|
||||
/// - Doing actual storage of relay list (delegated via the delegate
|
||||
/// - Handling low-level relay logic (this will be delegated to lower level classes used in RelayPool/RelayConnection)
|
||||
class NostrNetworkManager {
|
||||
/// The relay pool that we manage
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - This will be marked `private` in the future to prevent other code from accessing the relay pool directly. Code outside this layer should use a higher level interface
|
||||
private let pool: RelayPool // TODO: Make this private and make higher level interface for classes outside the NostrNetworkManager
|
||||
/// A delegate that allows us to interact with the rest of app without introducing hard or circular dependencies
|
||||
private var delegate: Delegate
|
||||
/// Manages the user's relay list, controls RelayPool's connected relays
|
||||
let userRelayList: UserRelayListManager
|
||||
/// Handles sending out notes to the network
|
||||
let postbox: PostBox
|
||||
/// Handles subscriptions and functions to read or consume data from the Nostr network
|
||||
let reader: SubscriptionManager
|
||||
let profilesManager: ProfilesManager
|
||||
|
||||
/// 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: addNdbToRelayPool ? delegate.ndb : nil, keypair: delegate.keypair)
|
||||
self.pool = pool
|
||||
let reader = SubscriptionManager(pool: pool, ndb: delegate.ndb, experimentalLocalRelayModelSupport: self.delegate.experimentalLocalRelayModelSupport)
|
||||
let userRelayList = UserRelayListManager(delegate: delegate, pool: pool, reader: reader)
|
||||
self.reader = reader
|
||||
self.userRelayList = userRelayList
|
||||
self.postbox = PostBox(pool: pool)
|
||||
self.profilesManager = ProfilesManager(subscriptionManager: reader, ndb: delegate.ndb)
|
||||
}
|
||||
|
||||
// MARK: - Control and lifecycle functions
|
||||
|
||||
/// Connects the app to the Nostr network
|
||||
func connect() async {
|
||||
await self.userRelayList.connect() // Will load the user's list, apply it, and get RelayPool to connect to it.
|
||||
await self.profilesManager.load()
|
||||
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()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func relaysForEvent(event: NostrEvent) async -> [RelayURL] {
|
||||
// TODO(tyiu) Ideally this list would be sorted by the event author's outbox relay preferences
|
||||
// and reliability of relays to maximize chances of others finding this event.
|
||||
if let relays = await pool.seen[event.id] {
|
||||
return Array(relays)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
// TODO: ORGANIZE THESE
|
||||
|
||||
// MARK: - Communication with the Nostr Network
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - This class hides the relay pool on purpose to avoid other code from dealing with complex relay + nostrDB logic.
|
||||
/// - Instead, we provide an easy to use interface so that normal code can just get the info they want.
|
||||
/// - This is also to help us migrate to the relay model.
|
||||
// TODO: Define a better interface. This is a temporary scaffold to replace direct relay pool access. After that is done, we can refactor this interface to be cleaner and reduce non-sense.
|
||||
|
||||
func sendToNostrDB(event: NostrEvent) async {
|
||||
await self.pool.send_raw_to_local_ndb(.typical(.event(event)))
|
||||
}
|
||||
|
||||
func send(event: NostrEvent, to targetRelays: [RelayURL]? = nil, skipEphemeralRelays: Bool = true) async {
|
||||
await self.pool.send(.event(event), to: targetRelays, skip_ephemeral: skipEphemeralRelays)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func getRelay(_ id: RelayURL) -> RelayPool.Relay? {
|
||||
pool.get_relay(id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var connectedRelays: [RelayPool.Relay] {
|
||||
self.pool.relays
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var ourRelayDescriptors: [RelayPool.RelayDescriptor] {
|
||||
self.pool.our_descriptors
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func relayURLsThatSawNote(id: NoteId) async -> Set<RelayURL>? {
|
||||
return await self.pool.seen[id]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func determineToRelays(filters: RelayFilters) -> [RelayURL] {
|
||||
return self.pool.our_descriptors
|
||||
.map { $0.url }
|
||||
.filter { !filters.is_filtered(timeline: .search, relay_id: $0) }
|
||||
}
|
||||
|
||||
// MARK: NWC
|
||||
// TODO: Move this to NWCManager
|
||||
|
||||
@discardableResult
|
||||
func nwcPay(url: WalletConnectURL, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil, zap_request: NostrEvent? = nil) async -> NostrEvent? {
|
||||
await WalletConnect.pay(url: url, pool: self.pool, post: post, invoice: invoice, zap_request: nil)
|
||||
}
|
||||
|
||||
/// Send a donation zap to the Damus team
|
||||
func send_donation_zap(nwc: WalletConnectURL, percent: Int, base_msats: Int64) async {
|
||||
let percent_f = Double(percent) / 100.0
|
||||
let donations_msats = Int64(percent_f * Double(base_msats))
|
||||
|
||||
let payreq = LNUrlPayRequest(allowsNostr: true, commentAllowed: nil, nostrPubkey: "", callback: "https://sendsats.lol/@damus")
|
||||
guard let invoice = await fetch_zap_invoice(payreq, zapreq: nil, msats: donations_msats, zap_type: .non_zap, comment: nil) else {
|
||||
// we failed... oh well. no donation for us.
|
||||
print("damus-donation failed to fetch invoice")
|
||||
return
|
||||
}
|
||||
|
||||
print("damus-donation donating...")
|
||||
await WalletConnect.pay(url: nwc, pool: self.pool, post: self.postbox, invoice: invoice, zap_request: nil, delay: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Helper types
|
||||
|
||||
extension NostrNetworkManager {
|
||||
/// The delegate that provides information and structure for the `NostrNetworkManager` to function.
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// This is needed to prevent a circular reference between `DamusState` and `NostrNetworkManager`, and reduce coupling.
|
||||
protocol Delegate: Sendable {
|
||||
/// NostrDB instance, used with `RelayPool` to send events for ingestion.
|
||||
var ndb: Ndb { get }
|
||||
|
||||
/// The keypair to use for relay authentication and updating relay lists
|
||||
var keypair: Keypair { get }
|
||||
|
||||
/// The latest relay list event id hex
|
||||
var latestRelayListEventIdHex: String? { get set } // TODO: Update this once we have full NostrDB query support
|
||||
|
||||
/// The latest contact list `NostrEvent`
|
||||
///
|
||||
/// Note: Read-only access, because `NostrNetworkManager` does not manage contact lists.
|
||||
@MainActor
|
||||
var latestContactListEvent: NostrEvent? { get }
|
||||
|
||||
/// Default bootstrap relays to start with when a user relay list is not present
|
||||
var bootstrapRelays: [RelayURL] { get }
|
||||
|
||||
/// Whether the app is in developer mode
|
||||
var developerMode: Bool { get }
|
||||
|
||||
/// Whether the app has the experimental local relay model flag that streams data only from the local relay (ndb)
|
||||
var experimentalLocalRelayModelSupport: Bool { get }
|
||||
|
||||
/// The cache of relay model information
|
||||
var relayModelCache: RelayModelCache { get }
|
||||
|
||||
/// Relay filters
|
||||
var relayFilters: RelayFilters { get }
|
||||
|
||||
/// The user's connected NWC wallet
|
||||
var nwcWallet: WalletConnectURL? { get }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,763 @@
|
||||
//
|
||||
// SubscriptionManager.swift
|
||||
// damus
|
||||
//
|
||||
// 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.
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - This class will be a key part of the local relay model migration. Most higher-level code should fetch content from this class, which will properly setup the correct relay pool subscriptions, and provide a stream from NostrDB for higher performance and reliability.
|
||||
class SubscriptionManager {
|
||||
private let pool: RelayPool
|
||||
private var ndb: Ndb
|
||||
private var taskManager: TaskManager
|
||||
private let experimentalLocalRelayModelSupport: Bool
|
||||
private let entityPreloader: EntityPreloader
|
||||
|
||||
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: - Subscribing and Streaming data from Nostr
|
||||
|
||||
/// Streams notes until the EOSE signal
|
||||
func streamExistingEvents(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, timeout: Duration? = nil, streamMode: StreamMode? = nil, 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 .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
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum StreamItem {
|
||||
/// An event which can be borrowed from NostrDB
|
||||
case event(lender: NdbNoteLender)
|
||||
/// The canonical generic "end of stored events", which depends on the stream mode. See `StreamMode` to see when this event is fired in relation to other EOSEs
|
||||
case eose
|
||||
/// "End of stored events" from NostrDB.
|
||||
case ndbEose
|
||||
/// "End of stored events" from all relays in `RelayPool`.
|
||||
case networkEose
|
||||
|
||||
var debugDescription: String {
|
||||
switch self {
|
||||
case .event(lender: let lender):
|
||||
let detailedDescription = try? lender.borrow({ event in
|
||||
"Note with ID: \(event.id.hex())"
|
||||
})
|
||||
return detailedDescription ?? "Some note"
|
||||
case .eose:
|
||||
return "EOSE"
|
||||
case .ndbEose:
|
||||
return "NDB EOSE"
|
||||
case .networkEose:
|
||||
return "NETWORK EOSE"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode of streaming
|
||||
enum StreamMode {
|
||||
/// Returns notes exclusively through NostrDB, treating it as the only channel for information in the pipeline. Generic EOSE is fired when EOSE is received from NostrDB
|
||||
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
|
||||
}
|
||||
}
|
||||
+65
-53
@@ -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
|
||||
@@ -20,45 +20,6 @@ enum NoteContent {
|
||||
}
|
||||
}
|
||||
|
||||
func parsed_blocks_finish(bs: inout note_blocks, tags: TagsSequence?) -> Blocks {
|
||||
var out: [Block] = []
|
||||
|
||||
var i = 0
|
||||
while (i < bs.num_blocks) {
|
||||
let block = bs.blocks[i]
|
||||
|
||||
if let converted = Block(block, tags: tags) {
|
||||
out.append(converted)
|
||||
}
|
||||
|
||||
i += 1
|
||||
}
|
||||
|
||||
let words = Int(bs.words)
|
||||
blocks_free(&bs)
|
||||
|
||||
return Blocks(words: words, blocks: out)
|
||||
|
||||
}
|
||||
|
||||
func parse_note_content(content: NoteContent) -> Blocks {
|
||||
var bs = note_blocks()
|
||||
bs.num_blocks = 0;
|
||||
|
||||
blocks_init(&bs)
|
||||
|
||||
switch content {
|
||||
case .content(let s, let tags):
|
||||
return s.withCString { cptr in
|
||||
damus_parse_content(&bs, cptr)
|
||||
return parsed_blocks_finish(bs: &bs, tags: tags)
|
||||
}
|
||||
case .note(let note):
|
||||
damus_parse_content(&bs, note.content_raw)
|
||||
return parsed_blocks_finish(bs: &bs, tags: note.tags)
|
||||
}
|
||||
}
|
||||
|
||||
func interpret_event_refs(tags: TagsSequence) -> ThreadReply? {
|
||||
// migration is long over, lets just do this to fix tests
|
||||
return interpret_event_refs_ndb(tags: tags)
|
||||
@@ -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
|
||||
@@ -0,0 +1,376 @@
|
||||
//
|
||||
// Mentions.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-05-04.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MentionType: AsciiCharacter, TagKey {
|
||||
case p
|
||||
case e
|
||||
case a
|
||||
case r
|
||||
|
||||
var keychar: AsciiCharacter {
|
||||
self.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
extension UnsafePointer<UInt8> {
|
||||
func as_data(size: Int) -> Data {
|
||||
return Data(bytes: self, count: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
|
||||
let nip19: Bech32Object
|
||||
|
||||
static func pubkey(_ pubkey: Pubkey) -> MentionRef {
|
||||
self.init(nip19: .npub(pubkey))
|
||||
}
|
||||
|
||||
static func note(_ note_id: NoteId) -> MentionRef {
|
||||
return self.init(nip19: .note(note_id))
|
||||
}
|
||||
|
||||
init?(block: ndb_mention_bech32_block) {
|
||||
guard let bech32_obj = Bech32Object.init(block: block) else {
|
||||
return nil
|
||||
}
|
||||
self.nip19 = bech32_obj
|
||||
}
|
||||
|
||||
init(nip19: Bech32Object) {
|
||||
self.nip19 = nip19
|
||||
}
|
||||
|
||||
var key: MentionType {
|
||||
switch self.nip19 {
|
||||
case .note, .nevent: return .e
|
||||
case .nprofile, .npub: return .p
|
||||
case .nrelay: return .r
|
||||
case .naddr: return .a
|
||||
case .nscript: return .a
|
||||
case .nsec: return .p
|
||||
}
|
||||
}
|
||||
|
||||
var bech32: String {
|
||||
return Bech32Object.encode(toBech32Object())
|
||||
}
|
||||
|
||||
init?(bech32_str: String) {
|
||||
guard let obj = Bech32Object.parse(bech32_str) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.nip19 = obj
|
||||
}
|
||||
|
||||
var pubkey: Pubkey? {
|
||||
switch self.nip19 {
|
||||
case .npub(let pubkey): return pubkey
|
||||
case .note: return nil
|
||||
case .nevent(let nevent): return nevent.author
|
||||
case .nprofile(let nprofile): return nprofile.author
|
||||
case .nrelay: return nil
|
||||
case .naddr: return nil
|
||||
case .nsec(let prv): return privkey_to_pubkey(privkey: prv)
|
||||
case .nscript(_): return nil
|
||||
}
|
||||
}
|
||||
|
||||
var tag: [String] {
|
||||
switch self.nip19 {
|
||||
case .npub(let pubkey): return ["p", pubkey.hex()]
|
||||
case .note(let noteId): return ["e", noteId.hex()]
|
||||
case .nevent(let nevent):
|
||||
var tagBuilder = ["e", nevent.noteid.hex()]
|
||||
|
||||
let relay = nevent.relays.first
|
||||
if let author = nevent.author?.hex() {
|
||||
tagBuilder.append(relay?.absoluteString ?? "")
|
||||
tagBuilder.append(author)
|
||||
} else if let relay {
|
||||
tagBuilder.append(relay.absoluteString)
|
||||
}
|
||||
|
||||
return tagBuilder
|
||||
case .nprofile(let nprofile):
|
||||
var tagBuilder = ["p", nprofile.author.hex()]
|
||||
|
||||
if let relay = nprofile.relays.first {
|
||||
tagBuilder.append(relay.absoluteString)
|
||||
}
|
||||
|
||||
return tagBuilder
|
||||
case .nrelay(let url): return ["r", url]
|
||||
case .naddr(let naddr):
|
||||
var tagBuilder = ["a", "\(naddr.kind.description):\(naddr.author.hex()):\(naddr.identifier.string())"]
|
||||
|
||||
if let relay = naddr.relays.first {
|
||||
tagBuilder.append(relay.absoluteString)
|
||||
}
|
||||
|
||||
return tagBuilder
|
||||
case .nsec(_):
|
||||
return []
|
||||
case .nscript(_):
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
|
||||
var i = tag.makeIterator()
|
||||
|
||||
guard let t0 = i.next(),
|
||||
let chr = t0.single_char,
|
||||
let mention_type = MentionType(rawValue: chr),
|
||||
let element = i.next()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let relayHints = tag.relayHints
|
||||
|
||||
switch mention_type {
|
||||
case .p:
|
||||
guard let data = element.id() else { return nil }
|
||||
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 }
|
||||
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: ":")
|
||||
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: relayHints, kind: kind)))
|
||||
case .r:
|
||||
return .init(nip19: .nrelay(element.string()))
|
||||
}
|
||||
}
|
||||
|
||||
func toBech32Object() -> Bech32Object {
|
||||
self.nip19
|
||||
}
|
||||
}
|
||||
|
||||
protocol URLEncodable {
|
||||
func url() -> URL?
|
||||
}
|
||||
|
||||
struct Mention<T: Equatable>: Equatable {
|
||||
let index: Int?
|
||||
let ref: T
|
||||
|
||||
static func any(_ mention_id: MentionRef, index: Int? = nil) -> Mention<MentionRef> {
|
||||
return Mention<MentionRef>(index: index, ref: mention_id)
|
||||
}
|
||||
|
||||
static func noteref(_ id: NoteRef, index: Int? = nil) -> Mention<NoteRef> {
|
||||
return Mention<NoteRef>(index: index, ref: id)
|
||||
}
|
||||
|
||||
static func note(_ id: NoteId, index: Int? = nil) -> Mention<NoteId> {
|
||||
return Mention<NoteId>(index: index, ref: id)
|
||||
}
|
||||
|
||||
static func pubkey(_ pubkey: Pubkey, index: Int? = nil) -> Mention<Pubkey> {
|
||||
return Mention<Pubkey>(index: index, ref: pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
typealias Invoice = LightningInvoice<Amount>
|
||||
typealias ZapInvoice = LightningInvoice<Int64>
|
||||
|
||||
enum InvoiceDescription {
|
||||
case description(String)
|
||||
case description_hash(Data)
|
||||
}
|
||||
|
||||
struct LightningInvoice<T> {
|
||||
let description: InvoiceDescription
|
||||
let amount: T
|
||||
let string: String
|
||||
let expiry: UInt64
|
||||
let created_at: UInt64
|
||||
|
||||
var abbreviated: String {
|
||||
return self.string.prefix(8) + "…" + self.string.suffix(8)
|
||||
}
|
||||
|
||||
var description_string: String {
|
||||
switch description {
|
||||
case .description(let string):
|
||||
return string
|
||||
case .description_hash:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
static func from(string: String) -> Invoice? {
|
||||
// This feels a bit hacky at first, but it is actually clean
|
||||
// because it reuses the same well-tested parsing logic as the rest of the app,
|
||||
// avoiding code duplication and utilizing the guarantees acquired from age and testing.
|
||||
// We could also use the C function `parse_invoice`, but it requires extra C bridging logic.
|
||||
// NDBTODO: This may need updating on the nostrdb upgrade.
|
||||
guard let parsedBlocks = parse_note_content(content: .content(string,nil))?.blocks else { return nil }
|
||||
guard parsedBlocks.count == 1 else { return nil }
|
||||
return parsedBlocks[0].asInvoice
|
||||
}
|
||||
}
|
||||
|
||||
func maybe_pointee<T>(_ p: UnsafeMutablePointer<T>?) -> T? {
|
||||
guard let p else {
|
||||
return nil
|
||||
}
|
||||
return p.pointee
|
||||
}
|
||||
|
||||
enum Amount: Equatable {
|
||||
case any
|
||||
case specific(Int64)
|
||||
|
||||
func amount_sats_str() -> String {
|
||||
switch self {
|
||||
case .any:
|
||||
return NSLocalizedString("Any", comment: "Any amount of sats")
|
||||
case .specific(let amt):
|
||||
return format_msats(amt)
|
||||
}
|
||||
}
|
||||
|
||||
func amount_sats() -> Int64? {
|
||||
switch self {
|
||||
case .any: nil
|
||||
case .specific(let amount): amount / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func format_msats_abbrev(_ msats: Int64) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.positiveSuffix = "m"
|
||||
formatter.positivePrefix = ""
|
||||
formatter.minimumFractionDigits = 0
|
||||
formatter.maximumFractionDigits = 3
|
||||
formatter.roundingMode = .down
|
||||
formatter.roundingIncrement = 0.1
|
||||
formatter.multiplier = 1
|
||||
|
||||
let sats = NSNumber(value: (Double(msats) / 1000.0))
|
||||
|
||||
if msats >= 1_000_000*1000 {
|
||||
formatter.positiveSuffix = "m"
|
||||
formatter.multiplier = 0.000001
|
||||
} else if msats >= 1000*1000 {
|
||||
formatter.positiveSuffix = "k"
|
||||
formatter.multiplier = 0.001
|
||||
} else {
|
||||
return sats.stringValue
|
||||
}
|
||||
|
||||
return formatter.string(from: sats) ?? sats.stringValue
|
||||
}
|
||||
|
||||
func format_msats(_ msat: Int64, locale: Locale = Locale.current) -> String {
|
||||
let numberFormatter = NumberFormatter()
|
||||
numberFormatter.numberStyle = .decimal
|
||||
numberFormatter.minimumFractionDigits = 0
|
||||
numberFormatter.maximumFractionDigits = 3
|
||||
numberFormatter.roundingMode = .down
|
||||
numberFormatter.locale = locale
|
||||
|
||||
let sats = NSNumber(value: (Double(msat) / 1000.0))
|
||||
let formattedSats = numberFormatter.string(from: sats) ?? sats.stringValue
|
||||
|
||||
let format = localizedStringFormat(key: "sats_count", locale: locale)
|
||||
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats)
|
||||
}
|
||||
|
||||
/// 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 .description("")
|
||||
}
|
||||
|
||||
func find_tag_ref(type: String, id: String, tags: [[String]]) -> Int? {
|
||||
var i: Int = 0
|
||||
for tag in tags {
|
||||
if tag.count >= 2 {
|
||||
if tag[0] == type && tag[1] == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
struct PostTags {
|
||||
let blocks: [Block]
|
||||
let tags: [[String]]
|
||||
}
|
||||
|
||||
/// Convert
|
||||
func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
|
||||
var new_tags = tags
|
||||
|
||||
for post_block in post_blocks {
|
||||
switch post_block {
|
||||
case .mention(let mention):
|
||||
switch(mention.ref.nip19) {
|
||||
case .note, .nevent:
|
||||
continue
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
new_tags.append(mention.ref.tag)
|
||||
case .hashtag(let hashtag):
|
||||
new_tags.append(["t", hashtag.lowercased()])
|
||||
case .text: break
|
||||
case .invoice: break
|
||||
case .relay: break
|
||||
case .url(let url):
|
||||
new_tags.append(["r", url.absoluteString])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return PostTags(blocks: post_blocks, tags: new_tags)
|
||||
}
|
||||
|
||||
@@ -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,7 +375,32 @@ 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.decode(from: txt) else { return nil }
|
||||
guard verify_nostr_response(response: response) == true else { return nil }
|
||||
return response
|
||||
}
|
||||
|
||||
func verify_nostr_response(response: borrowing NostrResponse) -> Bool {
|
||||
switch response {
|
||||
case .event(_, let event):
|
||||
return event.verify()
|
||||
case .notice(_):
|
||||
return true
|
||||
case .eose(_):
|
||||
return true
|
||||
case .ok(_):
|
||||
return true
|
||||
case .auth(_):
|
||||
return true
|
||||
case .negentropyError(subscriptionId: let subscriptionId, reasonCodeString: let reasonCodeString):
|
||||
return true
|
||||
case .negentropyMessage(subscriptionId: let subscriptionId, hexEncodedData: let hexEncodedData):
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func encode_json<T: Encodable>(_ val: T) -> String? {
|
||||
@@ -448,17 +517,26 @@ func random_bytes(count: Int) -> Data {
|
||||
return Data(bytes: bytes, count: count)
|
||||
}
|
||||
|
||||
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent) -> NostrEvent? {
|
||||
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent, relayURL: RelayURL?) -> NostrEvent? {
|
||||
var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag })
|
||||
|
||||
tags.append(["e", boosted.id.hex(), "", "root"])
|
||||
tags.append(["p", boosted.pubkey.hex()])
|
||||
var eTagBuilder = ["e", boosted.id.hex()]
|
||||
var pTagBuilder = ["p", boosted.pubkey.hex()]
|
||||
|
||||
let relayURLString = relayURL?.absoluteString
|
||||
if let relayURLString {
|
||||
pTagBuilder.append(relayURLString)
|
||||
}
|
||||
eTagBuilder.append(contentsOf: [relayURLString ?? "", "root", boosted.pubkey.hex()])
|
||||
|
||||
tags.append(eTagBuilder)
|
||||
tags.append(pTagBuilder)
|
||||
|
||||
let content = event_to_json(ev: boosted)
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 6, tags: tags)
|
||||
}
|
||||
|
||||
func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙") -> NostrEvent? {
|
||||
func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙", relayURL: RelayURL?) -> NostrEvent? {
|
||||
var tags = liked.tags.reduce(into: [[String]]()) { ts, tag in
|
||||
guard tag.count >= 2,
|
||||
(tag[0].matches_char("e") || tag[0].matches_char("p")) else {
|
||||
@@ -467,12 +545,30 @@ func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String =
|
||||
ts.append(tag.strings())
|
||||
}
|
||||
|
||||
tags.append(["e", liked.id.hex()])
|
||||
tags.append(["p", liked.pubkey.hex()])
|
||||
var eTagBuilder = ["e", liked.id.hex()]
|
||||
var pTagBuilder = ["p", liked.pubkey.hex()]
|
||||
|
||||
let relayURLString = relayURL?.absoluteString
|
||||
if let relayURLString {
|
||||
pTagBuilder.append(relayURLString)
|
||||
}
|
||||
eTagBuilder.append(contentsOf: [relayURLString ?? "", liked.pubkey.hex()])
|
||||
|
||||
tags.append(eTagBuilder)
|
||||
tags.append(pTagBuilder)
|
||||
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 7, tags: tags)
|
||||
}
|
||||
|
||||
func make_live_chat_event(keypair: FullKeypair, content: String, root: String, dtag: String, relayURL: RelayURL?) -> NostrEvent? {
|
||||
//var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag })
|
||||
var aTagBuilder = ["a", "30311:\(root):\(dtag)"]
|
||||
|
||||
var tags: [[String]] = [aTagBuilder]
|
||||
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 1311, tags: tags)
|
||||
}
|
||||
|
||||
func generate_private_keypair(our_privkey: Privkey, id: NoteId, created_at: UInt32) -> FullKeypair? {
|
||||
let to_hash = our_privkey.hex() + id.hex() + String(created_at)
|
||||
guard let dat = to_hash.data(using: .utf8) else {
|
||||
@@ -500,6 +596,15 @@ func uniq<T: Hashable>(_ xs: [T]) -> [T] {
|
||||
return ys
|
||||
}
|
||||
|
||||
func gather_quote_ids(our_pubkey: Pubkey, from: NostrEvent) -> [RefId] {
|
||||
var ids: [RefId] = [.quote(from.id.quote_id)]
|
||||
if from.pubkey != our_pubkey {
|
||||
ids.append(.pubkey(from.pubkey))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
|
||||
func gather_reply_ids(our_pubkey: Pubkey, from: NostrEvent) -> [RefId] {
|
||||
var ids: [RefId] = from.referenced_ids.first.map({ ref in [ .event(ref) ] }) ?? []
|
||||
|
||||
@@ -520,14 +625,6 @@ func gather_reply_ids(our_pubkey: Pubkey, from: NostrEvent) -> [RefId] {
|
||||
return ids
|
||||
}
|
||||
|
||||
func gather_quote_ids(our_pubkey: Pubkey, from: NostrEvent) -> [RefId] {
|
||||
var ids: [RefId] = [.quote(from.id.quote_id)]
|
||||
if from.pubkey != our_pubkey {
|
||||
ids.append(.pubkey(from.pubkey))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func event_from_json(dat: String) -> NostrEvent? {
|
||||
return NostrEvent.owned_from_json(json: dat)
|
||||
}
|
||||
@@ -746,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(ev: NostrEvent, keypair: Keypair) -> Mention<NoteId>? {
|
||||
let blocks = ev.blocks(keypair).blocks.filter { block in
|
||||
guard case .mention(let mention) = block else {
|
||||
return false
|
||||
func first_eref_mention(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> Mention<NoteId>? {
|
||||
return try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
|
||||
return blockGroup.forEachBlock({ index, block in
|
||||
switch block {
|
||||
case .mention(let mention):
|
||||
guard let mention = MentionRef(block: mention) else { return .loopContinue }
|
||||
switch mention.nip19 {
|
||||
case .note(let noteId):
|
||||
return .loopReturn(Mention<NoteId>.note(noteId, index: index))
|
||||
case .nevent(let nEvent):
|
||||
return .loopReturn(Mention<NoteId>.note(nEvent.noteid, index: index))
|
||||
default:
|
||||
return .loopContinue
|
||||
}
|
||||
default:
|
||||
return .loopContinue
|
||||
}
|
||||
|
||||
switch mention.ref {
|
||||
case .note, .nevent:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// MARK: - Preview
|
||||
if let firstBlock = blocks.first,
|
||||
case .mention(let mention) = firstBlock {
|
||||
switch mention.ref {
|
||||
case .note(let note_id):
|
||||
return .note(note_id)
|
||||
case .nevent(let nevent):
|
||||
return .note(nevent.noteid)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func separate_invoices(ev: NostrEvent, keypair: Keypair) -> [Invoice]? {
|
||||
let invoiceBlocks: [Invoice] = ev.blocks(keypair).blocks.reduce(into: []) { invoices, block in
|
||||
guard case .invoice(let invoice) = block else {
|
||||
return
|
||||
}
|
||||
invoices.append(invoice)
|
||||
/// 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
|
||||
}
|
||||
return invoiceBlocks.isEmpty ? nil : invoiceBlocks
|
||||
|
||||
// Fall back to q tags (NIP-10/NIP-18 quote reposts)
|
||||
guard let quoteRef = ev.referenced_quote_refs.first else {
|
||||
return nil
|
||||
}
|
||||
#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
|
||||
}
|
||||
return .loopContinue
|
||||
})) ?? []
|
||||
return invoiceBlocks.isEmpty ? nil : invoiceBlocks
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -832,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,8 +18,10 @@ enum NostrKind: UInt32, Codable {
|
||||
case boost = 6
|
||||
case like = 7
|
||||
case chat = 42
|
||||
case live_chat = 1311
|
||||
case mute_list = 10000
|
||||
case relay_list = 10002
|
||||
case interest_list = 10015
|
||||
case list_deprecated = 30000
|
||||
case draft = 31234
|
||||
case longform = 30023
|
||||
@@ -29,5 +31,8 @@ enum NostrKind: UInt32, Codable {
|
||||
case nwc_request = 23194
|
||||
case nwc_response = 23195
|
||||
case http_auth = 27235
|
||||
case live = 30311
|
||||
case status = 30315
|
||||
case contact_card = 30_382
|
||||
case follow_list = 39089
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//
|
||||
// NostrRequest.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-04-12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct NostrSubscribe {
|
||||
let filters: [NostrFilter]
|
||||
let sub_id: String
|
||||
}
|
||||
|
||||
/// Models a request/message that is sent to a Nostr relay
|
||||
enum NostrRequestType {
|
||||
/// A standard nostr request
|
||||
case typical(NostrRequest)
|
||||
/// A customized nostr request. Generally used in the context of a nostrscript.
|
||||
case custom(String)
|
||||
|
||||
/// Whether this request is meant to write data to a relay
|
||||
var is_write: Bool {
|
||||
guard case .typical(let req) = self else {
|
||||
return true
|
||||
}
|
||||
|
||||
return req.is_write
|
||||
}
|
||||
|
||||
/// Whether this request is meant to read data from a relay
|
||||
var is_read: Bool {
|
||||
guard case .typical(let req) = self else {
|
||||
return true
|
||||
}
|
||||
|
||||
return req.is_read
|
||||
}
|
||||
}
|
||||
|
||||
/// Models a standard request/message that is sent to a Nostr relay.
|
||||
enum NostrRequest {
|
||||
/// Subscribes to receive information from the relay
|
||||
case subscribe(NostrSubscribe)
|
||||
/// Unsubscribes from an existing subscription, addressed by its id
|
||||
case unsubscribe(String)
|
||||
/// Posts an event
|
||||
case event(NostrEvent)
|
||||
/// Authenticate with the relay
|
||||
case auth(NostrEvent)
|
||||
/// 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 {
|
||||
switch self {
|
||||
case .subscribe:
|
||||
return false
|
||||
case .unsubscribe:
|
||||
return false
|
||||
case .event:
|
||||
return true
|
||||
case .auth:
|
||||
return false
|
||||
case .negentropyOpen:
|
||||
return false
|
||||
case .negentropyMessage:
|
||||
return false
|
||||
case .negentropyClose:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this request is meant to read data from a relay
|
||||
var is_read: Bool {
|
||||
return !is_write
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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)\"]"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//
|
||||
// NostrResponse.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-04-11.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct CommandResult {
|
||||
let event_id: NoteId
|
||||
let ok: Bool
|
||||
let msg: String
|
||||
}
|
||||
|
||||
enum MaybeResponse {
|
||||
case bad
|
||||
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)
|
||||
case eose(String)
|
||||
case ok(CommandResult)
|
||||
/// An [NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md) `auth` challenge.
|
||||
///
|
||||
/// 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 {
|
||||
case .ok:
|
||||
return nil
|
||||
case .event(let sub_id, _):
|
||||
return sub_id
|
||||
case .eose(let sub_id):
|
||||
return sub_id
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if data == nil {
|
||||
let r: NostrResponse? = nil
|
||||
return r
|
||||
}
|
||||
//guard var json_cstr = json.cString(using: .utf8) else { return nil }
|
||||
|
||||
//json_cs
|
||||
var tce = ndb_tce()
|
||||
|
||||
let len = ndb_ws_event_from_json(cstr, Int32(json.utf8.count), &tce, data, Int32(bufsize), nil)
|
||||
if len <= 0 {
|
||||
free(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch tce.evtype {
|
||||
case NDB_TCE_OK:
|
||||
defer { free(data) }
|
||||
|
||||
guard let evid_str = sized_cstr(cstr: tce.subid, len: tce.subid_len),
|
||||
let evid = hex_decode_noteid(evid_str),
|
||||
let msg = sized_cstr(cstr: tce.command_result.msg, len: tce.command_result.msglen) else {
|
||||
return nil
|
||||
}
|
||||
let cr = CommandResult(event_id: evid, ok: tce.command_result.ok == 1, msg: msg)
|
||||
|
||||
return .ok(cr)
|
||||
case NDB_TCE_EOSE:
|
||||
defer { free(data) }
|
||||
|
||||
guard let subid = sized_cstr(cstr: tce.subid, len: tce.subid_len) else {
|
||||
return nil
|
||||
}
|
||||
return .eose(subid)
|
||||
case NDB_TCE_EVENT:
|
||||
|
||||
// Create new Data with just the valid bytes
|
||||
guard let note_data = realloc(data, Int(len)) else {
|
||||
free(data)
|
||||
return nil
|
||||
}
|
||||
let new_note = ndb_note_ptr(ptr: OpaquePointer(note_data))
|
||||
let note = NdbNote(note: new_note, size: Int(len), owned: true, key: nil)
|
||||
|
||||
guard let subid = sized_cstr(cstr: tce.subid, len: tce.subid_len) else {
|
||||
free(data)
|
||||
return nil
|
||||
}
|
||||
return .event(subid, note)
|
||||
case NDB_TCE_NOTICE:
|
||||
free(data)
|
||||
return .notice("")
|
||||
case NDB_TCE_AUTH:
|
||||
defer { free(data) }
|
||||
|
||||
guard let challenge_string = sized_cstr(cstr: tce.subid, len: tce.subid_len) else {
|
||||
return nil
|
||||
}
|
||||
return .auth(challenge_string)
|
||||
default:
|
||||
free(data)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sized_cstr(cstr: UnsafePointer<CChar>, len: Int32) -> String? {
|
||||
let msgbuf = Data(bytes: cstr, count: Int(len))
|
||||
return String(data: msgbuf, encoding: .utf8)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ class Profiles {
|
||||
@MainActor
|
||||
private var profiles: [Pubkey: ProfileData] = [:]
|
||||
|
||||
// Map of validated NIP-05 address to pubkey.
|
||||
@MainActor
|
||||
var nip05_pubkey: [String: Pubkey] = [:]
|
||||
|
||||
@@ -73,31 +74,45 @@ class Profiles {
|
||||
profile_data(pubkey).zapper
|
||||
}
|
||||
|
||||
func lookup_with_timestamp(_ pubkey: Pubkey) -> NdbTxn<ProfileRecord?>? {
|
||||
ndb.lookup_profile(pubkey)
|
||||
func lookup_with_timestamp<T>(_ pubkey: Pubkey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T {
|
||||
return try ndb.lookup_profile(pubkey, borrow: lendingFunction)
|
||||
}
|
||||
|
||||
func lookup_lnurl(_ pubkey: Pubkey) throws -> String? {
|
||||
return try lookup_with_timestamp(pubkey, borrow: { pr in
|
||||
switch pr {
|
||||
case .some(let pr): return pr.lnurl
|
||||
case .none: return nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func lookup_by_key(key: ProfileKey) -> NdbTxn<ProfileRecord?>? {
|
||||
ndb.lookup_profile_by_key(key: key)
|
||||
func lookup_by_key<T>(key: ProfileKey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T {
|
||||
return try ndb.lookup_profile_by_key(key: key, borrow: lendingFunction)
|
||||
}
|
||||
|
||||
func search<Y>(_ query: String, limit: Int, txn: NdbTxn<Y>) -> [Pubkey] {
|
||||
ndb.search_profile(query, limit: limit, txn: txn)
|
||||
func search(_ query: String, limit: Int) throws -> [Pubkey] {
|
||||
try ndb.search_profile(query, limit: limit)
|
||||
}
|
||||
|
||||
func lookup(id: Pubkey, txn_name: String? = nil) -> NdbTxn<Profile?>? {
|
||||
guard let txn = ndb.lookup_profile(id, txn_name: txn_name) else {
|
||||
return nil
|
||||
}
|
||||
return txn.map({ pr in pr?.profile })
|
||||
func lookup(id: Pubkey) throws -> Profile? {
|
||||
return try ndb.lookup_profile(id, borrow: { pr in
|
||||
switch pr {
|
||||
case .none:
|
||||
return nil
|
||||
case .some(let profileRecord):
|
||||
// This will clone the value to make it owned and safe to return.
|
||||
return profileRecord.profile
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func lookup_key_by_pubkey(_ pubkey: Pubkey) -> ProfileKey? {
|
||||
ndb.lookup_profile_key(pubkey)
|
||||
func lookup_key_by_pubkey(_ pubkey: Pubkey) throws -> ProfileKey? {
|
||||
try ndb.lookup_profile_key(pubkey)
|
||||
}
|
||||
|
||||
func has_fresh_profile<Y>(id: Pubkey, txn: NdbTxn<Y>) -> Bool {
|
||||
guard let fetched_at = ndb.read_profile_last_fetched(txn: txn, pubkey: id)
|
||||
func has_fresh_profile(id: Pubkey) throws -> Bool {
|
||||
guard let fetched_at = try ndb.read_profile_last_fetched(pubkey: id)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -0,0 +1,370 @@
|
||||
//
|
||||
// NostrConnection.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-04-02.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Negentropy
|
||||
|
||||
enum NostrConnectionEvent {
|
||||
/// Other non-message websocket events
|
||||
case ws_connection_event(WSConnectionEvent)
|
||||
/// A nostr response
|
||||
case nostr_event(NostrResponse)
|
||||
|
||||
/// Models non-messaging websocket events
|
||||
///
|
||||
/// Implementation note: Messaging events should use `.nostr_event` in `NostrConnectionEvent`
|
||||
enum WSConnectionEvent {
|
||||
case connected
|
||||
case disconnected(URLSessionWebSocketTask.CloseCode, String?)
|
||||
case error(Error)
|
||||
|
||||
static func from(full_ws_event: WebSocketEvent) -> Self? {
|
||||
switch full_ws_event {
|
||||
case .connected:
|
||||
return .connected
|
||||
case .message(_):
|
||||
return nil
|
||||
case .disconnected(let closeCode, let string):
|
||||
return .disconnected(closeCode, string)
|
||||
case .error(let error):
|
||||
return .error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var subId: String? {
|
||||
switch self {
|
||||
case .ws_connection_event(_):
|
||||
return nil
|
||||
case .nostr_event(let event):
|
||||
return event.subid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class RelayConnection: ObservableObject {
|
||||
@Published private(set) var isConnected = false
|
||||
@Published private(set) var isConnecting = false
|
||||
private var isDisabled = false
|
||||
|
||||
private(set) var last_connection_attempt: TimeInterval = 0
|
||||
private(set) var last_pong: Date? = nil
|
||||
private(set) var backoff: TimeInterval = 1.0
|
||||
private lazy var socket = WebSocket(relay_url.url)
|
||||
private var subscriptionToken: AnyCancellable?
|
||||
|
||||
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) 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() {
|
||||
socket.ping { [weak self] err in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
self.last_pong = .now
|
||||
Log.info("Got pong from '%s'", for: .networking, self.relay_url.absoluteString)
|
||||
self.log?.add("Successful ping")
|
||||
} else {
|
||||
Log.info("Ping failed, reconnecting to '%s'", for: .networking, self.relay_url.absoluteString)
|
||||
self.isConnected = false
|
||||
self.isConnecting = false
|
||||
self.reconnect_with_backoff()
|
||||
self.log?.add("Ping failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connect(force: Bool = false) {
|
||||
if !force && (isConnected || isConnecting) {
|
||||
return
|
||||
}
|
||||
|
||||
isConnecting = true
|
||||
last_connection_attempt = Date().timeIntervalSince1970
|
||||
|
||||
subscriptionToken = socket.subject
|
||||
.receive(on: DispatchQueue.global(qos: .default))
|
||||
.sink { [weak self] completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
Task { await self?.wsEventQueue.add(item: .error(error)) }
|
||||
case .finished:
|
||||
Task { await self?.wsEventQueue.add(item: .disconnected(.normalClosure, nil)) }
|
||||
}
|
||||
} receiveValue: { [weak self] event in
|
||||
Task { await self?.wsEventQueue.add(item: event) }
|
||||
}
|
||||
|
||||
socket.connect()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
socket.disconnect()
|
||||
subscriptionToken = nil
|
||||
|
||||
isConnected = false
|
||||
isConnecting = false
|
||||
}
|
||||
|
||||
func disablePermanently() {
|
||||
isDisabled = true
|
||||
}
|
||||
|
||||
func send_raw(_ req: String) {
|
||||
socket.send(.string(req))
|
||||
}
|
||||
|
||||
func send(_ req: NostrRequestType, callback: ((String) -> Void)? = nil) {
|
||||
switch req {
|
||||
case .typical(let req):
|
||||
guard let req = make_nostr_req(req) else {
|
||||
print("failed to encode nostr req: \(req)")
|
||||
return
|
||||
}
|
||||
send_raw(req)
|
||||
callback?(req)
|
||||
|
||||
case .custom(let req):
|
||||
send_raw(req)
|
||||
callback?(req)
|
||||
}
|
||||
}
|
||||
|
||||
private func receive(event: WebSocketEvent) async {
|
||||
assert(!Thread.isMainThread, "This code must not be executed on the main thread")
|
||||
processEvent(event)
|
||||
switch event {
|
||||
case .connected:
|
||||
DispatchQueue.main.async {
|
||||
self.backoff = 1.0
|
||||
self.isConnected = true
|
||||
self.isConnecting = false
|
||||
}
|
||||
case .message(let 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))
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.isConnected = false
|
||||
self.isConnecting = false
|
||||
self.reconnect()
|
||||
}
|
||||
case .error(let error):
|
||||
Log.error("⚠️ Warning: RelayConnection (%s) error: %s", for: .networking, self.relay_url.absoluteString, error.localizedDescription)
|
||||
let nserr = error as NSError
|
||||
if nserr.domain == NSPOSIXErrorDomain && nserr.code == 57 {
|
||||
// ignore socket not connected?
|
||||
return
|
||||
}
|
||||
if nserr.domain == NSURLErrorDomain && nserr.code == -999 {
|
||||
// these aren't real error, it just means task was cancelled
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.isConnected = false
|
||||
self.isConnecting = false
|
||||
self.reconnect_with_backoff()
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func reconnect_with_backoff() {
|
||||
self.backoff *= 2.0
|
||||
self.reconnect_in(after: self.backoff)
|
||||
}
|
||||
|
||||
func reconnect() {
|
||||
guard !isConnecting && !isDisabled else {
|
||||
self.log?.add("Cancelling reconnect, already connecting")
|
||||
return // we're already trying to connect or we're disabled
|
||||
}
|
||||
|
||||
guard !self.isConnected else {
|
||||
self.log?.add("Cancelling reconnect, already connected")
|
||||
return
|
||||
}
|
||||
|
||||
disconnect()
|
||||
connect()
|
||||
log?.add("Reconnecting...")
|
||||
}
|
||||
|
||||
func reconnect_in(after: TimeInterval) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + after) {
|
||||
self.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
await self.handleEvent(.nostr_event(ev))
|
||||
if let negentropyResponse = ev.negentropyResponse {
|
||||
await self.negentropyStreams[negentropyResponse.subscriptionId]?.yield(negentropyResponse)
|
||||
}
|
||||
return
|
||||
}
|
||||
print("\(self.relay_url): failed to decode event \(messageString)")
|
||||
case .data(let messageData):
|
||||
if let messageString = String(data: messageData, encoding: .utf8) {
|
||||
await receive(message: .string(messageString))
|
||||
}
|
||||
@unknown default:
|
||||
print("An unexpected URLSessionWebSocketTask.Message was received.")
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,918 @@
|
||||
//
|
||||
// RelayPool.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-04-11.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import Negentropy
|
||||
|
||||
struct RelayHandler {
|
||||
let sub_id: String
|
||||
/// The filters that this handler will handle. Set this to `nil` if you want your handler to receive all events coming from the relays.
|
||||
let filters: [NostrFilter]?
|
||||
let to: [RelayURL]?
|
||||
var handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation
|
||||
}
|
||||
|
||||
struct QueuedRequest {
|
||||
let req: NostrRequestType
|
||||
let relay: RelayURL
|
||||
let skip_ephemeral: Bool
|
||||
}
|
||||
|
||||
struct SeenEvent: Hashable {
|
||||
let relay_id: RelayURL
|
||||
let evid: NoteId
|
||||
}
|
||||
|
||||
@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?
|
||||
/// 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()
|
||||
|
||||
/// 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() async {
|
||||
await disconnect()
|
||||
await clearRelays()
|
||||
open = false
|
||||
handlers = []
|
||||
request_queue = []
|
||||
await clearSeen()
|
||||
counts = [:]
|
||||
keypair = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func clearRelays() {
|
||||
relays = []
|
||||
}
|
||||
|
||||
private func clearSeen() {
|
||||
seen.removeAll()
|
||||
}
|
||||
|
||||
nonisolated init(ndb: Ndb?, keypair: Keypair? = nil) {
|
||||
self.ndb = ndb
|
||||
self.keypair = keypair
|
||||
|
||||
network_monitor.pathUpdateHandler = { [weak self] path in
|
||||
Task { await self?.pathUpdateHandler(path: path) }
|
||||
}
|
||||
network_monitor.start(queue: network_monitor_queue)
|
||||
}
|
||||
|
||||
private func pathUpdateHandler(path: NWPath) async {
|
||||
if (path.status == .satisfied || path.status == .requiresConnection) && self.last_network_status != path.status {
|
||||
await self.connect_to_disconnected()
|
||||
}
|
||||
|
||||
if path.status != self.last_network_status {
|
||||
for relay in await self.relays {
|
||||
relay.connection.log?.add("Network state: \(path.status)")
|
||||
}
|
||||
}
|
||||
|
||||
self.last_network_status = path.status
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var our_descriptors: [RelayDescriptor] {
|
||||
return all_descriptors.filter { d in !d.ephemeral }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var all_descriptors: [RelayDescriptor] {
|
||||
relays.map { r in r.descriptor }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var num_connected: Int {
|
||||
return relays.reduce(0) { n, r in n + (r.connection.isConnected ? 1 : 0) }
|
||||
}
|
||||
|
||||
func remove_handler(sub_id: String) {
|
||||
self.handlers = handlers.filter {
|
||||
if $0.sub_id != sub_id {
|
||||
return true
|
||||
}
|
||||
else {
|
||||
$0.handler.finish()
|
||||
return false
|
||||
}
|
||||
}
|
||||
Log.debug("Removing %s handler, current: %d", for: .networking, sub_id, handlers.count)
|
||||
}
|
||||
|
||||
func ping() async {
|
||||
Log.info("Pinging %d relays", for: .networking, await relays.count)
|
||||
for relay in await relays {
|
||||
relay.connection.ping()
|
||||
}
|
||||
}
|
||||
|
||||
func register_handler(sub_id: String, filters: [NostrFilter]?, to relays: [RelayURL]? = nil, handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation) async {
|
||||
while handlers.count > Self.MAX_CONCURRENT_SUBSCRIPTION_LIMIT {
|
||||
Log.debug("%s: Too many subscriptions, waiting for subscription pool to clear", for: .networking, sub_id)
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
}
|
||||
Log.debug("%s: Subscription pool cleared", for: .networking, sub_id)
|
||||
handlers = handlers.filter({ handler in
|
||||
if handler.sub_id == sub_id {
|
||||
Log.error("Duplicate handler detected for the same subscription ID. Overriding.", for: .networking)
|
||||
handler.handler.finish()
|
||||
return false
|
||||
}
|
||||
else {
|
||||
return true
|
||||
}
|
||||
})
|
||||
self.handlers.append(RelayHandler(sub_id: sub_id, filters: filters, to: relays, handler: handler))
|
||||
Log.debug("Registering %s handler, current: %d", for: .networking, sub_id, self.handlers.count)
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 await get_relay(relay_id) != nil {
|
||||
throw RelayError.RelayAlreadyExists
|
||||
}
|
||||
let conn = RelayConnection(url: desc.url, handleEvent: { event in
|
||||
await self.handle_event(relay_id: relay_id, event: event)
|
||||
}, processUnverifiedWSEvent: { wsev in
|
||||
guard case .message(let msg) = wsev,
|
||||
case .string(let str) = msg
|
||||
else { return }
|
||||
|
||||
#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)
|
||||
}
|
||||
|
||||
/// 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)")
|
||||
|
||||
await get_relay(relay_id)?.connection.log = log
|
||||
}
|
||||
|
||||
/// This is used to retry dead connections
|
||||
func connect_to_disconnected() async {
|
||||
for relay in await relays {
|
||||
let c = relay.connection
|
||||
|
||||
let is_connecting = c.isConnecting
|
||||
|
||||
if is_connecting && (Date.now.timeIntervalSince1970 - c.last_connection_attempt) > 5 {
|
||||
print("stale connection detected (\(relay.descriptor.url.absoluteString)). retrying...")
|
||||
relay.connection.reconnect()
|
||||
} else if relay.is_broken || is_connecting || c.isConnected {
|
||||
continue
|
||||
} else {
|
||||
relay.connection.reconnect()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func reconnect(to targetRelays: [RelayURL]? = nil) async {
|
||||
let relays = await getRelays(targetRelays: targetRelays)
|
||||
for relay in relays {
|
||||
// don't try to reconnect to broken relays
|
||||
relay.connection.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
func connect(to targetRelays: [RelayURL]? = nil) async {
|
||||
let relays = await getRelays(targetRelays: targetRelays)
|
||||
for relay in relays {
|
||||
relay.connection.connect()
|
||||
}
|
||||
// Mark as open last, to prevent other classes from pulling data before the relays are actually connected
|
||||
// Only mark as open when connecting ALL relays (not specific ones)
|
||||
if targetRelays == nil {
|
||||
open = true
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect(to targetRelays: [RelayURL]? = nil) async {
|
||||
// Mark as closed first, to prevent other classes from pulling data while the relays are being disconnected
|
||||
// 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) async {
|
||||
if to == nil {
|
||||
self.remove_handler(sub_id: sub_id)
|
||||
}
|
||||
await self.send(.unsubscribe(sub_id), to: to)
|
||||
}
|
||||
|
||||
func subscribe(sub_id: String, filters: [NostrFilter], handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation, to: [RelayURL]? = nil) {
|
||||
Task {
|
||||
await register_handler(sub_id: sub_id, filters: filters, to: to, handler: handler)
|
||||
|
||||
// When the caller specifies no relays, it is implied that the user wants to use the ones in the user relay list. Skip ephemeral relays in that case.
|
||||
// When the caller specifies specific relays, do not skip ephemeral relays to respect the exact list given by the caller.
|
||||
let shouldSkipEphemeralRelays = to == nil ? true : false
|
||||
|
||||
await send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to, skip_ephemeral: shouldSkipEphemeralRelays)
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribes to data from the `RelayPool` based on a filter and a list of desired relays.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - filters: The filters specifying the desired content.
|
||||
/// - desiredRelays: The desired relays which to subsctibe to. If `nil`, it defaults to the `RelayPool`'s default list
|
||||
/// - eoseTimeout: The maximum timeout which to give up waiting for the eoseSignal
|
||||
/// 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 id = id ?? UUID()
|
||||
let sub_id = id.uuidString
|
||||
var seenEvents: Set<NoteId> = []
|
||||
var relaysWhoFinishedInitialResults: Set<RelayURL> = []
|
||||
var eoseSent = false
|
||||
let upstreamStream = AsyncStream<(RelayURL, NostrConnectionEvent)> { upstreamContinuation in
|
||||
self.subscribe(sub_id: sub_id, filters: filters, handler: upstreamContinuation, to: desiredRelays.map({ $0.descriptor.url }))
|
||||
}
|
||||
let upstreamStreamingTask = Task {
|
||||
for await (relayUrl, connectionEvent) in upstreamStream {
|
||||
try Task.checkCancellation()
|
||||
switch connectionEvent {
|
||||
case .ws_connection_event(let ev):
|
||||
// Websocket events such as connect/disconnect/error are already handled in `RelayConnection`. Do not perform any handling here.
|
||||
// For the future, perhaps we should abstract away `.ws_connection_event` in `RelayPool`? Seems like something to be handled on the `RelayConnection` layer.
|
||||
break
|
||||
case .nostr_event(let nostrResponse):
|
||||
guard nostrResponse.subid == sub_id else { return } // Do not stream items that do not belong in this subscription
|
||||
switch nostrResponse {
|
||||
case .event(_, let nostrEvent):
|
||||
if seenEvents.contains(nostrEvent.id) { break } // Don't send two of the same events.
|
||||
continuation.yield(with: .success(.event(nostrEvent)))
|
||||
seenEvents.insert(nostrEvent.id)
|
||||
case .notice(let note):
|
||||
break // We do not support handling these yet
|
||||
case .eose(_):
|
||||
relaysWhoFinishedInitialResults.insert(relayUrl)
|
||||
let desiredAndConnectedRelays = desiredRelays.filter({ $0.connection.isConnected }).map({ $0.descriptor.url })
|
||||
Log.debug("RelayPool subscription %s: EOSE from %s. EOSE count: %d/%d. Elapsed: %.2f seconds.", for: .networking, id.uuidString, relayUrl.absoluteString, relaysWhoFinishedInitialResults.count, Set(desiredAndConnectedRelays).count, CFAbsoluteTimeGetCurrent() - startTime)
|
||||
if relaysWhoFinishedInitialResults == Set(desiredAndConnectedRelays) {
|
||||
continuation.yield(with: .success(.eose))
|
||||
eoseSent = true
|
||||
}
|
||||
case .ok(_): break // No need to handle this, we are not sending an event to the relay
|
||||
case .auth(_): break // Handled in a separate function in RelayPool
|
||||
case .negentropyError(subscriptionId: _, reasonCodeString: _): break // Not handled in regular subscriptions
|
||||
case .negentropyMessage(subscriptionId: _, hexEncodedData: _): break // Not handled in regular subscriptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let timeoutTask = Task {
|
||||
try? await Task.sleep(for: eoseTimeout)
|
||||
if !eoseSent { continuation.yield(with: .success(.eose)) }
|
||||
}
|
||||
continuation.onTermination = { @Sendable termination in
|
||||
switch termination {
|
||||
case .finished:
|
||||
Log.debug("RelayPool subscription %s finished. Closing.", for: .networking, sub_id)
|
||||
case .cancelled:
|
||||
Log.debug("RelayPool subscription %s cancelled. Closing.", for: .networking, sub_id)
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
Task {
|
||||
await self.unsubscribe(sub_id: sub_id, to: desiredRelays.map({ $0.descriptor.url }))
|
||||
await self.remove_handler(sub_id: sub_id)
|
||||
}
|
||||
timeoutTask.cancel()
|
||||
upstreamStreamingTask.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/// The "end of stored events" signal
|
||||
case eose
|
||||
}
|
||||
|
||||
func subscribe_to(sub_id: String, filters: [NostrFilter], to: [RelayURL]?, handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation) {
|
||||
Task {
|
||||
await register_handler(sub_id: sub_id, filters: filters, to: to, handler: handler)
|
||||
|
||||
await send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
|
||||
}
|
||||
}
|
||||
|
||||
func count_queued(relay: RelayURL) -> Int {
|
||||
var c = 0
|
||||
for request in request_queue {
|
||||
if request.relay == relay {
|
||||
c += 1
|
||||
}
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func queue_req(r: NostrRequestType, relay: RelayURL, skip_ephemeral: Bool) {
|
||||
let count = count_queued(relay: relay)
|
||||
guard count <= 10 else {
|
||||
print("can't queue, too many queued events for \(relay)")
|
||||
return
|
||||
}
|
||||
|
||||
print("queueing request for \(relay)")
|
||||
request_queue.append(QueuedRequest(req: r, relay: relay, skip_ephemeral: skip_ephemeral))
|
||||
}
|
||||
|
||||
func send_raw_to_local_ndb(_ req: NostrRequestType) {
|
||||
// send to local relay (nostrdb)
|
||||
switch req {
|
||||
case .typical(let r):
|
||||
if case .event = r, let rstr = make_nostr_req(r) {
|
||||
let _ = ndb?.process_client_event(rstr)
|
||||
}
|
||||
case .custom(let string):
|
||||
let _ = ndb?.process_client_event(string)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
for relay in relays {
|
||||
if req.is_read && !(relay.descriptor.info.canRead) {
|
||||
continue // Do not send read requests to relays that are not READ relays
|
||||
}
|
||||
|
||||
if req.is_write && !(relay.descriptor.info.canWrite) {
|
||||
continue // Do not send write requests to relays that are not WRITE relays
|
||||
}
|
||||
|
||||
if relay.descriptor.ephemeral && skip_ephemeral {
|
||||
continue // Do not send requests to ephemeral relays if we want to skip them
|
||||
}
|
||||
|
||||
guard relay.connection.isConnected else {
|
||||
Task { await queue_req(r: req, relay: relay.id, skip_ephemeral: skip_ephemeral) }
|
||||
continue
|
||||
}
|
||||
|
||||
relay.connection.send(req, callback: { str in
|
||||
#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) async {
|
||||
await send_raw(.typical(req), to: to, skip_ephemeral: skip_ephemeral)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func get_relays(_ ids: [RelayURL]) -> [Relay] {
|
||||
// don't include ephemeral relays in the default list to query
|
||||
relays.filter { ids.contains($0.id) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func get_relay(_ id: RelayURL) -> Relay? {
|
||||
relays.first(where: { $0.id == id })
|
||||
}
|
||||
|
||||
func run_queue(_ relay_id: RelayURL) {
|
||||
self.request_queue = request_queue.reduce(into: Array<QueuedRequest>()) { (q, req) in
|
||||
guard req.relay == relay_id else {
|
||||
q.append(req)
|
||||
return
|
||||
}
|
||||
|
||||
print("running queueing request: \(req.req) for \(relay_id)")
|
||||
Task { await self.send_raw(req.req, to: [relay_id], skip_ephemeral: false) }
|
||||
}
|
||||
}
|
||||
|
||||
func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) {
|
||||
if case .nostr_event(let ev) = event {
|
||||
if case .event(_, let nev) = ev {
|
||||
if seen[nev.id]?.contains(relay_id) == true {
|
||||
return
|
||||
}
|
||||
seen[nev.id, default: Set()].insert(relay_id)
|
||||
counts[relay_id, default: 0] += 1
|
||||
notify(.update_stats(note_id: nev.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resubscribeAll(relayId: RelayURL) async {
|
||||
for handler in self.handlers {
|
||||
guard let filters = handler.filters else { continue }
|
||||
// When the caller specifies no relays, it is implied that the user wants to use the ones in the user relay list. Skip ephemeral relays in that case.
|
||||
// When the caller specifies specific relays, do not skip ephemeral relays to respect the exact list given by the caller.
|
||||
let shouldSkipEphemeralRelays = handler.to == nil ? true : false
|
||||
|
||||
if let handlerTargetRelays = handler.to,
|
||||
!handlerTargetRelays.contains(where: { $0 == relayId }) {
|
||||
// Not part of the target relays, skip
|
||||
continue
|
||||
}
|
||||
|
||||
Log.debug("%s: Sending resubscribe request to %s", for: .networking, handler.sub_id, relayId.absoluteString)
|
||||
await send(.subscribe(.init(filters: filters, sub_id: handler.sub_id)), to: [relayId], skip_ephemeral: shouldSkipEphemeralRelays)
|
||||
}
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, event: NostrConnectionEvent) async {
|
||||
record_seen(relay_id: relay_id, event: event)
|
||||
|
||||
// When we reconnect, do two things
|
||||
// - Send messages that were stored in the queue
|
||||
// - Re-subscribe to filters we had subscribed before
|
||||
if case .ws_connection_event(let ws) = event {
|
||||
if case .connected = ws {
|
||||
run_queue(relay_id)
|
||||
await self.resubscribeAll(relayId: relay_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle auth
|
||||
if case let .nostr_event(nostrResponse) = event,
|
||||
case let .auth(challenge_string) = nostrResponse {
|
||||
if let relay = await get_relay(relay_id) {
|
||||
print("received auth request from \(relay.descriptor.url.id)")
|
||||
relay.authentication_state = .pending
|
||||
if let keypair {
|
||||
if let fullKeypair = keypair.to_full() {
|
||||
if let authRequest = make_auth_request(keypair: fullKeypair, challenge_string: challenge_string, relay: relay) {
|
||||
await send(.auth(authRequest), to: [relay_id], skip_ephemeral: false)
|
||||
relay.authentication_state = .verified
|
||||
} else {
|
||||
print("failed to make auth request")
|
||||
}
|
||||
} else {
|
||||
print("keypair provided did not contain private key, can not sign auth request")
|
||||
relay.authentication_state = .error(.no_private_key)
|
||||
}
|
||||
} else {
|
||||
print("no keypair to reply to auth request")
|
||||
relay.authentication_state = .error(.no_key)
|
||||
}
|
||||
} else {
|
||||
print("no relay found for \(relay_id)")
|
||||
}
|
||||
}
|
||||
|
||||
for handler in handlers {
|
||||
// We send data to the handlers if:
|
||||
// - the subscription ID matches, or
|
||||
// - the handler filters is `nil`, which is used in some cases as a blanket "give me all notes" (e.g. during signup)
|
||||
guard handler.sub_id == event.subId || handler.filters == nil else { continue }
|
||||
logStreamPipelineStats("RelayPool_\(relay_id.absoluteString)", "RelayPool_Handler_\(handler.sub_id)")
|
||||
handler.handler.yield((relay_id, event))
|
||||
}
|
||||
}
|
||||
|
||||
// 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) 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
|
||||
@@ -36,13 +37,16 @@ class DamusState: HeadlessDamusState {
|
||||
var purple: DamusPurple
|
||||
var push_notification_client: PushNotificationClient
|
||||
let emoji_provider: EmojiProvider
|
||||
let favicon_cache: FaviconCache
|
||||
private(set) var nostrNetwork: NostrNetworkManager
|
||||
var snapshotManager: DatabaseSnapshotManager
|
||||
|
||||
init(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) {
|
||||
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
|
||||
@@ -68,15 +72,19 @@ class DamusState: HeadlessDamusState {
|
||||
self.quote_reposts = quote_reposts
|
||||
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
|
||||
self.emoji_provider = emoji_provider
|
||||
|
||||
self.favicon_cache = FaviconCache()
|
||||
|
||||
let networkManagerDelegate = NostrNetworkManagerDelegate(settings: settings, contacts: contacts, ndb: ndb, keypair: keypair, relayModelCache: relay_model_cache, relayFilters: relay_filters)
|
||||
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")
|
||||
@@ -107,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,
|
||||
@@ -120,13 +129,14 @@ 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(),
|
||||
ndb: ndb,
|
||||
quote_reposts: .init(our_pubkey: pubkey),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||
favicon_cache: FaviconCache()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -155,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
|
||||
@@ -175,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),
|
||||
@@ -194,7 +216,8 @@ class DamusState: HeadlessDamusState {
|
||||
video: DamusVideoCoordinator(),
|
||||
ndb: .empty,
|
||||
quote_reposts: .init(our_pubkey: empty_pub),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||
favicon_cache: FaviconCache()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -212,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
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,11 @@
|
||||
// Block.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Kyle Roucis on 2023-08-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
fileprivate extension String {
|
||||
/// Failable initializer to build a Swift.String from a C-backed `str_block_t`.
|
||||
init?(_ s: str_block_t) {
|
||||
let len = s.end - s.start
|
||||
let bytes = Data(bytes: s.start, count: len)
|
||||
self.init(bytes: bytes, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a block of data stored by the NOSTR protocol. This can be
|
||||
/// Represents a block of data stored in nostrdb. This can be
|
||||
/// simple text, a hashtag, a url, a relay reference, a mention ref and
|
||||
/// potentially more in the future.
|
||||
enum Block: Equatable {
|
||||
@@ -38,22 +27,6 @@ enum Block: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
var is_previewable: Bool {
|
||||
switch self {
|
||||
case .mention(let m):
|
||||
switch m.ref {
|
||||
case .note, .nevent: return true
|
||||
default: return false
|
||||
}
|
||||
case .invoice:
|
||||
return true
|
||||
case .url:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
case text(String)
|
||||
case mention(Mention<MentionRef>)
|
||||
case hashtag(String)
|
||||
@@ -67,61 +40,55 @@ struct Blocks: Equatable {
|
||||
let blocks: [Block]
|
||||
}
|
||||
|
||||
extension ndb_str_block {
|
||||
func as_str() -> String {
|
||||
let buf = UnsafeBufferPointer(start: self.str, count: Int(self.len))
|
||||
let uint8Buf = buf.map { UInt8(bitPattern: $0) }
|
||||
return String(decoding: uint8Buf, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
extension ndb_block_ptr {
|
||||
func as_str() -> String {
|
||||
guard let str_block = ndb_block_str(self.ptr) else {
|
||||
return ""
|
||||
}
|
||||
return str_block.pointee.as_str()
|
||||
}
|
||||
|
||||
var block: ndb_block.__Unnamed_union_block {
|
||||
self.ptr.pointee.block
|
||||
}
|
||||
}
|
||||
|
||||
extension Block {
|
||||
/// Failable initializer for the C-backed type `block_t`. This initializer will inspect
|
||||
/// the underlying block type and build the appropriate enum value as needed.
|
||||
init?(_ block: block_t, tags: TagsSequence? = nil) {
|
||||
switch block.type {
|
||||
init?(block: ndb_block_ptr, tags: TagsSequence?) {
|
||||
switch ndb_get_block_type(block.ptr) {
|
||||
case BLOCK_HASHTAG:
|
||||
guard let str = String(block.block.str) else {
|
||||
return nil
|
||||
}
|
||||
self = .hashtag(str)
|
||||
self = .hashtag(block.as_str())
|
||||
case BLOCK_TEXT:
|
||||
guard let str = String(block.block.str) else {
|
||||
return nil
|
||||
}
|
||||
self = .text(str)
|
||||
self = .text(block.as_str())
|
||||
case BLOCK_MENTION_INDEX:
|
||||
guard let b = Block(index: Int(block.block.mention_index), tags: tags) else {
|
||||
return nil
|
||||
}
|
||||
self = b
|
||||
case BLOCK_URL:
|
||||
guard let b = Block(block.block.str) else {
|
||||
return nil
|
||||
}
|
||||
self = b
|
||||
guard let url = URL(string: block.as_str()) else { return nil }
|
||||
self = .url(url)
|
||||
case BLOCK_INVOICE:
|
||||
guard let b = Block(invoice: block.block.invoice) else {
|
||||
return nil
|
||||
}
|
||||
self = b
|
||||
self = Block(invoice: block.block.invoice)
|
||||
case BLOCK_MENTION_BECH32:
|
||||
guard let b = Block(bech32: block.block.mention_bech32) else {
|
||||
return nil
|
||||
}
|
||||
guard let b = Block(bech32: block.block.mention_bech32) else { return nil }
|
||||
self = b
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
fileprivate extension Block {
|
||||
/// Failable initializer for the C-backed type `str_block_t`.
|
||||
init?(_ b: str_block_t) {
|
||||
guard let str = String(b) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let url = URL(string: str) {
|
||||
self = .url(url)
|
||||
}
|
||||
else {
|
||||
self = .text(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
fileprivate extension Block {
|
||||
/// Failable initializer for a block index and a tag sequence.
|
||||
init?(index: Int, tags: TagsSequence? = nil) {
|
||||
@@ -143,34 +110,28 @@ fileprivate extension Block {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension Block {
|
||||
/// Failable initializer for the C-backed type `invoice_block_t`.
|
||||
init?(invoice: invoice_block_t) {
|
||||
guard let invstr = String(invoice.invstr) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard var b11 = maybe_pointee(invoice.bolt11) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let description = convert_invoice_description(b11: b11) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let amount: Amount = maybe_pointee(b11.msat).map { .specific(Int64($0.millisatoshis)) } ?? .any
|
||||
let payment_hash = Data(bytes: &b11.payment_hash, count: 32)
|
||||
let created_at = b11.timestamp
|
||||
|
||||
tal_free(invoice.bolt11)
|
||||
self = .invoice(Invoice(description: description, amount: amount, string: invstr, expiry: b11.expiry, payment_hash: payment_hash, created_at: created_at))
|
||||
/// Initializer for the C-backed type `invoice_block_t`.
|
||||
init(invoice: ndb_invoice_block) {
|
||||
self = .invoice(invoice_block_as_invoice(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
|
||||
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 {
|
||||
/// Failable initializer for the C-backed type `mention_bech32_block_t`. This initializer will inspect the
|
||||
/// bech32 type code and build the appropriate enum type.
|
||||
init?(bech32 b: mention_bech32_block_t) {
|
||||
init?(bech32 b: ndb_mention_bech32_block) {
|
||||
guard let decoded = decodeCBech32(b.bech32) else {
|
||||
return nil
|
||||
}
|
||||
@@ -180,6 +141,7 @@ fileprivate extension Block {
|
||||
self = .mention(.any(ref))
|
||||
}
|
||||
}
|
||||
|
||||
extension Block {
|
||||
var asString: String {
|
||||
switch self {
|
||||
@@ -202,3 +164,13 @@ extension Block {
|
||||
}
|
||||
}
|
||||
}
|
||||
extension Block {
|
||||
var asInvoice: Invoice? {
|
||||
switch self {
|
||||
case .invoice(let invoice):
|
||||
return invoice
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,4 +51,15 @@ struct NoteId: IdType, TagKey, TagConvertible {
|
||||
|
||||
return note_id
|
||||
}
|
||||
|
||||
func withUnsafePointer<T>(_ body: (UnsafePointer<UInt8>) throws -> T) rethrows -> T {
|
||||
return try self.id.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in
|
||||
guard let baseAddress = bytes.baseAddress else {
|
||||
fatalError("Cannot get base address")
|
||||
}
|
||||
return try baseAddress.withMemoryRebound(to: UInt8.self, capacity: bytes.count) { ptr in
|
||||
return try body(ptr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,5 +44,14 @@ struct Pubkey: IdType, TagKey, TagConvertible, Identifiable {
|
||||
return pubkey
|
||||
}
|
||||
|
||||
func withUnsafePointer<T>(_ body: (UnsafePointer<UInt8>) throws -> T) rethrows -> T {
|
||||
return try self.id.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in
|
||||
guard let baseAddress = bytes.baseAddress else {
|
||||
fatalError("Cannot get base address")
|
||||
}
|
||||
return try baseAddress.withMemoryRebound(to: UInt8.self, capacity: bytes.count) { ptr in
|
||||
return try body(ptr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -25,12 +25,13 @@ class ActionBarModel: ObservableObject {
|
||||
@Published private(set) var zaps: Int
|
||||
@Published var zap_total: Int64
|
||||
@Published var replies: Int
|
||||
|
||||
@Published var relays: Int
|
||||
|
||||
static func empty() -> ActionBarModel {
|
||||
return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil)
|
||||
}
|
||||
|
||||
init(likes: Int = 0, boosts: Int = 0, zaps: Int = 0, zap_total: Int64 = 0, replies: Int = 0, our_like: NostrEvent? = nil, our_boost: NostrEvent? = nil, our_zap: Zapping? = nil, our_reply: NostrEvent? = nil, our_quote_repost: NostrEvent? = nil, quote_reposts: Int = 0) {
|
||||
init(likes: Int = 0, boosts: Int = 0, zaps: Int = 0, zap_total: Int64 = 0, replies: Int = 0, our_like: NostrEvent? = nil, our_boost: NostrEvent? = nil, our_zap: Zapping? = nil, our_reply: NostrEvent? = nil, our_quote_repost: NostrEvent? = nil, quote_reposts: Int = 0, relays: Int = 0) {
|
||||
self.likes = likes
|
||||
self.boosts = boosts
|
||||
self.zaps = zaps
|
||||
@@ -42,9 +43,11 @@ class ActionBarModel: ObservableObject {
|
||||
self.our_reply = our_reply
|
||||
self.our_quote_repost = our_quote_repost
|
||||
self.quote_reposts = quote_reposts
|
||||
self.relays = relays
|
||||
}
|
||||
|
||||
func update(damus: DamusState, evid: NoteId) {
|
||||
@MainActor
|
||||
func update(damus: DamusState, evid: NoteId) async {
|
||||
self.likes = damus.likes.counts[evid] ?? 0
|
||||
self.boosts = damus.boosts.counts[evid] ?? 0
|
||||
self.zaps = damus.zaps.event_counts[evid] ?? 0
|
||||
@@ -56,11 +59,12 @@ class ActionBarModel: ObservableObject {
|
||||
self.our_zap = damus.zaps.our_zaps[evid]?.first
|
||||
self.our_reply = damus.replies.our_reply(evid)
|
||||
self.our_quote_repost = damus.quote_reposts.our_events[evid]
|
||||
self.relays = (await damus.nostrNetwork.relayURLsThatSawNote(id: evid) ?? []).count
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
|
||||
var is_empty: Bool {
|
||||
return likes == 0 && boosts == 0 && zaps == 0
|
||||
return likes == 0 && boosts == 0 && zaps == 0 && quote_reposts == 0 && relays == 0
|
||||
}
|
||||
|
||||
var liked: Bool {
|
||||
+79
-37
@@ -36,10 +36,15 @@ struct EventActionBar: View {
|
||||
self.swipe_context = swipe_context
|
||||
}
|
||||
|
||||
var lnurl: String? {
|
||||
damus_state.profiles.lookup_with_timestamp(event.pubkey)?.map({ pr in
|
||||
pr?.lnurl
|
||||
}).value
|
||||
@State var lnurl: String? = nil
|
||||
|
||||
// Fetching an LNURL is expensive enough that it can cause a hitch. Use a special backgroundable function to fetch the value.
|
||||
// Fetch on `.onAppear`
|
||||
nonisolated func fetchLNURL() {
|
||||
let lnurl = try? damus_state.profiles.lookup_lnurl(event.pubkey)
|
||||
DispatchQueue.main.async {
|
||||
self.lnurl = lnurl
|
||||
}
|
||||
}
|
||||
|
||||
var show_like: Bool {
|
||||
@@ -82,8 +87,10 @@ struct EventActionBar: View {
|
||||
|
||||
var like_swipe_button: some View {
|
||||
SwipeAction(image: "shaka", backgroundColor: DamusColors.adaptableGrey) {
|
||||
send_like(emoji: damus_state.settings.default_emoji_reaction)
|
||||
self.swipe_context?.state.wrappedValue = .closed
|
||||
Task {
|
||||
await send_like(emoji: damus_state.settings.default_emoji_reaction)
|
||||
self.swipe_context?.state.wrappedValue = .closed
|
||||
}
|
||||
}
|
||||
.swipeButtonStyle()
|
||||
.accessibilityLabel(NSLocalizedString("React with default reaction emoji", comment: "Accessibility label for react button"))
|
||||
@@ -131,7 +138,7 @@ struct EventActionBar: View {
|
||||
if bar.liked {
|
||||
//notify(.delete, bar.our_like)
|
||||
} else {
|
||||
send_like(emoji: emoji)
|
||||
Task { await send_like(emoji: emoji) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,32 +183,42 @@ struct EventActionBar: View {
|
||||
let should_hide_repost = hide_items_without_activity && bar.boosts == 0
|
||||
let should_hide_reactions = hide_items_without_activity && bar.likes == 0
|
||||
let zap_model = self.damus_state.events.get_cache_data(self.event.id).zaps_model
|
||||
let should_hide_zap = hide_items_without_activity && zap_model.zap_total > 0
|
||||
let should_hide_zap = hide_items_without_activity && zap_model.zap_total == 0
|
||||
let should_hide_share_button = hide_items_without_activity
|
||||
// Only render the bar if at least one action is visible; avoids empty overlays/dots.
|
||||
let has_any_action = (!should_hide_chat_bubble && damus_state.keypair.privkey != nil)
|
||||
|| !should_hide_repost
|
||||
|| (show_like && !should_hide_reactions)
|
||||
|| (!should_hide_zap && self.lnurl != nil)
|
||||
|| !should_hide_share_button
|
||||
|
||||
return HStack(spacing: options.contains(.no_spread) ? 10 : 0) {
|
||||
if damus_state.keypair.privkey != nil && !should_hide_chat_bubble {
|
||||
self.reply_button
|
||||
}
|
||||
|
||||
if !should_hide_repost {
|
||||
self.space_if_spread
|
||||
self.repost_button
|
||||
}
|
||||
|
||||
if show_like && !should_hide_reactions {
|
||||
self.space_if_spread
|
||||
self.like_button
|
||||
}
|
||||
|
||||
if let lnurl = self.lnurl, !should_hide_zap {
|
||||
self.space_if_spread
|
||||
NoteZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: zap_model)
|
||||
}
|
||||
|
||||
if !should_hide_share_button {
|
||||
self.space_if_spread
|
||||
self.share_button
|
||||
return Group {
|
||||
if has_any_action {
|
||||
HStack(spacing: options.contains(.no_spread) ? 10 : 0) {
|
||||
if damus_state.keypair.privkey != nil && !should_hide_chat_bubble {
|
||||
self.reply_button
|
||||
}
|
||||
|
||||
if !should_hide_repost {
|
||||
self.space_if_spread
|
||||
self.repost_button
|
||||
}
|
||||
|
||||
if show_like && !should_hide_reactions {
|
||||
self.space_if_spread
|
||||
self.like_button
|
||||
}
|
||||
|
||||
if let lnurl = self.lnurl, !should_hide_zap {
|
||||
self.space_if_spread
|
||||
NoteZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: zap_model)
|
||||
}
|
||||
|
||||
if !should_hide_share_button {
|
||||
self.space_if_spread
|
||||
self.share_button
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,11 +234,31 @@ struct EventActionBar: View {
|
||||
AnyView(self.action_bar_content)
|
||||
}
|
||||
}
|
||||
|
||||
@State var event_relay_url_strings: [RelayURL] = []
|
||||
|
||||
func updateEventRelayURLStrings() async {
|
||||
let newValue = await fetchEventRelayURLStrings()
|
||||
self.event_relay_url_strings = newValue
|
||||
}
|
||||
|
||||
func fetchEventRelayURLStrings() async -> [RelayURL] {
|
||||
let relays = await damus_state.nostrNetwork.relaysForEvent(event: event)
|
||||
if !relays.isEmpty {
|
||||
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0 }
|
||||
}
|
||||
|
||||
return userProfile.getCappedRelays()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
self.content
|
||||
.onAppear {
|
||||
self.bar.update(damus: damus_state, evid: self.event.id)
|
||||
Task.detached(priority: .background, operation: {
|
||||
await self.bar.update(damus: damus_state, evid: self.event.id)
|
||||
self.fetchLNURL()
|
||||
await self.updateEventRelayURLStrings()
|
||||
})
|
||||
}
|
||||
.sheet(isPresented: $show_share_action, onDismiss: { self.show_share_action = false }) {
|
||||
if #available(iOS 16.0, *) {
|
||||
@@ -233,7 +270,9 @@ struct EventActionBar: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $show_share_sheet, onDismiss: { self.show_share_sheet = false }) {
|
||||
ShareSheet(activityItems: [URL(string: "https://damus.io/" + event.id.bech32)!])
|
||||
if let url = URL(string: "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))) {
|
||||
ShareSheet(activityItems: [url])
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $show_repost_action, onDismiss: { self.show_repost_action = false }) {
|
||||
|
||||
@@ -247,7 +286,10 @@ struct EventActionBar: View {
|
||||
}
|
||||
.onReceive(handle_notify(.update_stats)) { target in
|
||||
guard target == self.event.id else { return }
|
||||
self.bar.update(damus: self.damus_state, evid: target)
|
||||
Task {
|
||||
await self.bar.update(damus: self.damus_state, evid: target)
|
||||
await self.updateEventRelayURLStrings()
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.liked)) { liked in
|
||||
if liked.id != event.id {
|
||||
@@ -260,9 +302,9 @@ struct EventActionBar: View {
|
||||
}
|
||||
}
|
||||
|
||||
func send_like(emoji: String) {
|
||||
func send_like(emoji: String) async {
|
||||
guard let keypair = damus_state.keypair.to_full(),
|
||||
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
|
||||
let like_ev = await make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -270,7 +312,7 @@ struct EventActionBar: View {
|
||||
|
||||
generator.impactOccurred()
|
||||
|
||||
damus_state.nostrNetwork.postbox.send(like_ev)
|
||||
await damus_state.nostrNetwork.postbox.send(like_ev)
|
||||
}
|
||||
|
||||
// MARK: Helper structures
|
||||
+22
@@ -13,6 +13,7 @@ struct EventDetailBar: View {
|
||||
let target_pk: Pubkey
|
||||
|
||||
@ObservedObject var bar: ActionBarModel
|
||||
@State var relays: [RelayURL] = []
|
||||
|
||||
init(state: DamusState, target: NoteId, target_pk: Pubkey) {
|
||||
self.state = state
|
||||
@@ -59,7 +60,28 @@ struct EventDetailBar: View {
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
if bar.relays > 0 {
|
||||
NavigationLink(value: Route.UserRelays(relays: relays)) {
|
||||
let nounString = pluralizedString(key: "relays_count", count: bar.relays)
|
||||
let noun = Text(nounString).foregroundColor(.gray)
|
||||
Text("\(Text(verbatim: bar.relays.formatted()).font(.body.bold())) \(noun)", comment: "Sentence composed of 2 variables to describe how many relays a note was found on. In source English, the first variable is the number of relays, and the second variable is 'Relay' or 'Relays'.")
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task { await self.updateSeenRelays() }
|
||||
}
|
||||
.onReceive(handle_notify(.update_stats)) { noteId in
|
||||
guard noteId == target else { return }
|
||||
Task { await self.updateSeenRelays() }
|
||||
}
|
||||
}
|
||||
|
||||
func updateSeenRelays() async {
|
||||
let relays = await Array(state.nostrNetwork.relayURLsThatSawNote(id: target) ?? [])
|
||||
self.relays = relays
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -26,7 +26,23 @@ struct ShareAction: View {
|
||||
self.userProfile = userProfile
|
||||
self._show_share = show_share
|
||||
}
|
||||
|
||||
@State var event_relay_url_strings: [RelayURL] = []
|
||||
|
||||
func updateEventRelayURLStrings() async {
|
||||
let newValue = await fetchEventRelayURLStrings()
|
||||
self.event_relay_url_strings = newValue
|
||||
}
|
||||
|
||||
func fetchEventRelayURLStrings() async -> [RelayURL] {
|
||||
let relays = await userProfile.damus.nostrNetwork.relaysForEvent(event: event)
|
||||
if !relays.isEmpty {
|
||||
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0 }
|
||||
}
|
||||
|
||||
return userProfile.getCappedRelays()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
VStack {
|
||||
@@ -40,7 +56,7 @@ struct ShareAction: View {
|
||||
|
||||
ShareActionButton(img: "link", text: NSLocalizedString("Copy Link", comment: "Button to copy link to note")) {
|
||||
dismiss()
|
||||
UIPasteboard.general.string = "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(noteid: event.id, relays: userProfile.getCappedRelayStrings())))
|
||||
UIPasteboard.general.string = "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(noteid: event.id, relays: userProfile.getCappedRelays())))
|
||||
}
|
||||
|
||||
let bookmarkImg = isBookmarked ? "bookmark.fill" : "bookmark"
|
||||
@@ -71,8 +87,13 @@ struct ShareAction: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.update_stats), perform: { noteId in
|
||||
guard noteId == event.id else { return }
|
||||
Task { await self.updateEventRelayURLStrings() }
|
||||
})
|
||||
.onAppear() {
|
||||
userProfile.subscribeToFindRelays()
|
||||
Task { await self.updateEventRelayURLStrings() }
|
||||
}
|
||||
.onDisappear() {
|
||||
userProfile.unsubscribeFindRelays()
|
||||
+3
-3
@@ -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: {
|
||||
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// QuoteRepostsView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2024-03-16.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct QuoteRepostsView: View {
|
||||
let damus_state: DamusState
|
||||
@ObservedObject var model: EventsModel
|
||||
|
||||
var body: some View {
|
||||
TimelineView(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true, filter: ContentFilters.default_filters(damus_state: damus_state).filter(ev:)) {
|
||||
ZStack(alignment: .leading) {
|
||||
DamusBackground(maxHeight: 250)
|
||||
.mask(LinearGradient(gradient: Gradient(colors: [.black, .black, .black, .clear]), startPoint: .top, endPoint: .bottom))
|
||||
Text("Quotes", comment: "Navigation bar title for Quote Reposts view.")
|
||||
.foregroundStyle(DamusLogoGradient.gradient)
|
||||
.font(.title.bold())
|
||||
.padding(.leading, 30)
|
||||
.padding(.top, 30)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.padding(.bottom, tabHeight)
|
||||
.onAppear {
|
||||
model.subscribe()
|
||||
}
|
||||
.onDisappear {
|
||||
model.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct QuoteRepostsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let state = test_damus_state
|
||||
QuoteRepostsView(damus_state: state, model: .reposts(state: state, target: test_note.id))
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -19,13 +19,15 @@ struct RepostAction: View {
|
||||
|
||||
Button {
|
||||
dismiss()
|
||||
|
||||
guard let keypair = self.damus_state.keypair.to_full(),
|
||||
let boost = make_boost_event(keypair: keypair, boosted: self.event) else {
|
||||
return
|
||||
|
||||
Task {
|
||||
guard let keypair = self.damus_state.keypair.to_full(),
|
||||
let boost = await make_boost_event(keypair: keypair, boosted: self.event, relayURL: damus_state.nostrNetwork.relaysForEvent(event: self.event).first) else {
|
||||
return
|
||||
}
|
||||
|
||||
await damus_state.nostrNetwork.postbox.send(boost)
|
||||
}
|
||||
|
||||
damus_state.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)
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user