Compare commits

..

145 Commits

Author SHA1 Message Date
tyiu f4cda3b8dd Fix note content rendering to not remove whitespace before hashtag
Changelog-Fixed: Fixed note content rendering to not remove whitespace before hashtag

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes: #3012

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes: #2997

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This introduces no user-facing changes.

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

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

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

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

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

Closes: https://github.com/damus-io/damus/issues/2615
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-14 19:10:46 -07:00
tyiu 78a62c8ef0 Clean up code in ProfileName.name_choice
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-14 18:38:51 -07:00
Daniel D’Aquino 8b96b9f4e6 Merge pull request #2973 from damus-io/translations
Translations
2025-04-14 18:35:34 -07:00
Daniel D’Aquino 649a857c3a Update Kingfisher to 8.3.1
Changelog-Changed: Updated image cache for better stability
Closes: https://github.com/damus-io/damus/issues/2899
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-14 17:56:23 -07:00
transifex-integration[bot] cdae2c7558 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-08 11:52:23 +00:00
transifex-integration[bot] 3639110c51 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-04-08 08:39:51 +00:00
tyiu 186668512e Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-07 20:39:27 -04:00
tyiu f63666fae2 Add missing localized string comment
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-07 20:39:08 -04:00
transifex-integration[bot] 68d25059b1 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:30 -04:00
transifex-integration[bot] 9aef6b7f5b Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:30 -04:00
transifex-integration[bot] d2e712575f Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:30 -04:00
transifex-integration[bot] bf9674e6e4 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:30 -04:00
transifex-integration[bot] 4815390cbe Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:30 -04:00
transifex-integration[bot] 6ce903f1f6 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:30 -04:00
transifex-integration[bot] b2c91ffce4 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:30 -04:00
transifex-integration[bot] ae335b18bf Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:29 -04:00
transifex-integration[bot] 6391819fb2 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:29 -04:00
transifex-integration[bot] 5d0e56b7c7 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:29 -04:00
transifex-integration[bot] 50ccc7bd7f Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:29 -04:00
transifex-integration[bot] b3a6bcf3b2 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:29 -04:00
transifex-integration[bot] 38b2988bbe Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-04-07 20:25:29 -04:00
transifex-integration[bot] 446c541dcb Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2025-04-07 20:25:29 -04:00
transifex-integration[bot] 31fd48ee52 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2025-04-07 20:25:28 -04:00
209 changed files with 10818 additions and 1890 deletions
+1
View File
@@ -6,3 +6,4 @@ damus.xcodeproj/xcshareddata/xcbaselines
TODO.bak
tags
build-git-hash.txt
.build
+48
View File
@@ -1,3 +1,51 @@
## [1.14] - 2025-05-25
### Added
- Added safety reminder to wallets with higher balance (Daniel DAquino)
- Added one-click Coinos wallet setup (Daniel DAquino)
- Add notification setting to hide hellthreads (Terry Yiu)
- Added separated first aid option for relay lists that does not need a contact list reset (Daniel DAquino)
- Added NIP-65 relay list support (Daniel DAquino)
- Added Unicode 16 emoji reactions for iOS 18.4+ by upgrading EmojiPicker (Terry Yiu)
- Added a search interface to the settings screen (SanjaySiddharth)
- Added view introducing users to Zaps (ericholguin)
- Added new wallet view with balance and transactions list (ericholguin)
- Added copy technical info button to user visible errors, so that users can more easily share errors with developers (Daniel DAquino)
- Add dismiss button to wallet high balance reminders (Daniel DAquino)
- Zap receiver information now included for outgoing zaps (Daniel DAquino)
- Added inline note rendering of invoices to pull up wallet selector sheet (Terry Yiu)
- Added route to profile page from wallet tx list (ericholguin)
### Changed
- Added additional information on top of blurred images (SanjaySiddharth)
- Improved robustness of relay list handling (Daniel DAquino)
- Updated image cache for better stability (Daniel DAquino)
- Improved integration with Nostr Wallet Connect wallets (ericholguin)
- Added relay connectivity information to NWC settings (Daniel DAquino)
- Improved handling around NWC responses (Daniel DAquino)
- Added more human visible errors on NWC wallets to aid with troubleshooting (Daniel DAquino)
- Re-enabled note zaps as permitted by the new App Store guidelines (Daniel DAquino)
### Fixed
- Hide future notes from timeline (Terry Yiu)
- Fixed issue where profiles with a NIP-65 relay list would not display on Damus (Daniel DAquino)
- Fix quote notes to include missing q tag (Terry Yiu)
- Fixed issue where the side menu would close when copying the npub (SanjaySiddharth)
- Fixed issue where cached images would be backed up to iCloud (Daniel DAquino)
- Optimized classify_url function (Terry Yiu)
- Fixed note rendering for those that contain previewable items or leading and trailing whitespaces (Terry Yiu)
- Fixed issue where some videos would become unplayable after some time using the app (Daniel DAquino)
[1.14]: https://github.com/damus-io/damus/releases/tag/v1.14
## [1.13.1] - 2025-03-21
### Fixed
@@ -18,7 +18,7 @@ struct NotificationExtensionState: HeadlessDamusState {
let lnurls: LNUrls
init?() {
guard let ndb = try? Ndb(owns_db_file: false) else { return nil }
guard let ndb = Ndb(owns_db_file: false) else { return nil }
self.ndb = ndb
guard let keypair = get_saved_keypair() else { return nil }
+19 -3
View File
@@ -1,10 +1,26 @@
[![Run Test Suite](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml/badge.svg?branch=master)](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml)
<div align="center">
# damus
<img src="./damus/Assets.xcassets/damus-home.imageset/damus-home@2x.png" alt="Damus Logo" title="Damus logo" width=""/>
# Damus
The social network you control
A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS.
<img src="./ss.png" width="50%" height="50%" />
[![License: GPL-3.0](https://img.shields.io/github/license/damus-io/damus?labelColor=27303D&color=0877d2)](/LICENSE)
## Download and Install
[![Apple](https://img.shields.io/badge/Apple-%23000000.svg?style=for-the-badge&logo=apple&logoColor=white)](https://apps.apple.com/us/app/damus/id1628663131)
## Supported Platforms
iOS 16.0+ • macOS 13.0+
<img src="./demo1.png" width="70%" height="50%" />
</div>
[nostr]: https://github.com/fiatjaf/nostr
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
{
"originHash" : "06318d35ee2e6bd681b95591e67da33a9461b48a3c652e58bd9d1a6f0d82bdac",
"originHash" : "1fc7e0b44329ba72cd285eeb022b5b92582cd01586b920d243cb0485c2e69dcc",
"pins" : [
{
"identity" : "codescanner",
@@ -35,6 +35,15 @@
"version" : "0.2.0"
}
},
{
"identity" : "faviconfinder",
"kind" : "remoteSourceControl",
"location" : "https://github.com/will-lumley/FaviconFinder.git",
"state" : {
"revision" : "9279f4371f4877ca302ba3bf1015f3f58ae4a56c",
"version" : "5.1.4"
}
},
{
"identity" : "gsplayer",
"kind" : "remoteSourceControl",
@@ -49,8 +58,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher",
"state" : {
"revision" : "415b1d97fb38bda1e5a6b2dde63354720832110b",
"version" : "7.6.1"
"revision" : "4c6b067f96953ee19526e49e4189403a2be21fb3",
"version" : "8.3.1"
}
},
{
@@ -105,6 +114,15 @@
"version" : "0.1.2"
}
},
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup.git",
"state" : {
"revision" : "bba848db50462894e7fc0891d018dfecad4ef11e",
"version" : "2.8.7"
}
},
{
"identity" : "swiftycrop",
"kind" : "remoteSourceControl",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

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

After

Width:  |  Height:  |  Size: 115 KiB

Before

Width:  |  Height:  |  Size: 216 B

After

Width:  |  Height:  |  Size: 216 B

+45 -9
View File
@@ -162,6 +162,7 @@ class CarouselModel: ObservableObject {
// Upon updating information, update the carousel fill size if the size for the current url has changed
if oldValue[current_url] != media_size_information[current_url] {
self.refresh_current_item_fill()
self.refresh_first_item_height()
}
}
}
@@ -186,6 +187,13 @@ class CarouselModel: ObservableObject {
/// and is automatically updated upon changes to these properties.
@Published private(set) var current_item_fill: ImageFill?
/// Holds the ideal fill dimensions for the first item in the carousel.
/// This is used to maintain a consistent height for the carousel when swiping between images.
/// **Usage note:** This property is automatically updated when other properties are set, and should not be set directly.
/// **Implementation note:** This property ensures the carousel maintains a consistent height based on the first image,
/// preventing the UI from "jumping" when swiping between images of different aspect ratios.
@Published private(set) var first_image_fill: ImageFill?
// MARK: Initialization and de-initialization
@@ -207,6 +215,7 @@ class CarouselModel: ObservableObject {
self.observe_video_sizes()
Task {
self.refresh_current_item_fill()
self.refresh_first_item_height()
}
}
@@ -241,10 +250,17 @@ class CarouselModel: ObservableObject {
/// **Usage note:** This is private, do not call this directly from outside the class.
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the fill
private func refresh_current_item_fill() {
if let current_url,
let item_size = self.media_size_information[current_url],
self.current_item_fill = self.compute_item_fill(url: current_url)
}
/// Computes the image fill properties for a given URL without side effects.
/// This is a pure function that calculates the appropriate fill dimensions based on image size and container constraints.
/// **Usage note:** This is a helper method used by both `refresh_current_item_fill` and `refresh_first_item_height`.
private func compute_item_fill(url: URL?) -> ImageFill? {
if let url,
let item_size = self.media_size_information[url],
let geo_size {
self.current_item_fill = ImageFill.calculate_image_fill(
return ImageFill.calculate_image_fill(
geo_size: geo_size,
img_size: item_size,
maxHeight: self.max_height,
@@ -252,9 +268,26 @@ class CarouselModel: ObservableObject {
)
}
else {
self.current_item_fill = nil // Not enough information to compute the proper fill. Default to nil
return nil // Not enough information to compute the proper fill. Default to nil
}
}
/// This function refreshes the first item height based on the current state of the model
/// **Usage note:** This is private, do not call this directly from outside the class.
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the height.
/// When the first image dimensions change, this ensures the carousel maintains consistent dimensions.
private func refresh_first_item_height() {
self.first_image_fill = self.compute_first_item_fill()
}
/// Computes the first item fill with no side-effects.
/// **Usage note:** Not to be used outside the class. Use the `first_image_fill` property instead.
/// **Implementation note:** This retrieves the first URL from the carousel and computes its fill properties
/// to establish a consistent height for the entire carousel.
private func compute_first_item_fill() -> ImageFill? {
guard let first_url = urls[safe: 0] else { return nil }
return self.compute_item_fill(url: first_url.url)
}
}
// MARK: - Image Carousel
@@ -286,13 +319,15 @@ struct ImageCarousel<Content: View>: View {
self.content = content
}
var filling: Bool {
model.current_item_fill?.filling == true
}
/// Determines if the image should fill its container.
/// Always returns true to ensure images consistently fill the width of the container.
/// This simplifies the layout behavior and prevents inconsistent sizing between carousel items.
var filling: Bool { true }
var height: CGFloat {
// Use the calculated fill height if available, otherwise use the default fill height
model.current_item_fill?.height ?? model.default_fill_height
// Use the first image height (to prevent height from jumping when swiping), then default to the default fill height
// This prioritization ensures consistent carousel height regardless of which image is currently displayed
model.first_image_fill?.height ?? model.default_fill_height
}
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
@@ -376,6 +411,7 @@ struct ImageCarousel<Content: View>: View {
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.frame(height: height)
.clipped() // Prevents content from overflowing the frame, ensuring clean edges in the carousel
.onChange(of: model.selectedIndex) { value in
model.selectedIndex = value
}
+10 -6
View File
@@ -94,26 +94,30 @@ enum OpenWalletError: Error {
}
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
let url = try getUrlToOpen(invoice: invoice, with: wallet)
this_app.open(url)
}
func getUrlToOpen(invoice: String, with wallet: Wallet.Model) throws(OpenWalletError) -> URL {
if let url = URL(string: "\(wallet.link)\(invoice)"), this_app.canOpenURL(url) {
this_app.open(url)
return url
} else {
guard let store_link = wallet.appStoreLink else {
throw OpenWalletError.no_wallet_to_open
throw .no_wallet_to_open
}
guard let url = URL(string: store_link) else {
throw OpenWalletError.store_link_invalid
throw .store_link_invalid
}
guard this_app.canOpenURL(url) else {
throw OpenWalletError.system_cannot_open_store_link
throw .system_cannot_open_store_link
}
this_app.open(url)
return url
}
}
let test_invoice = Invoice(description: .description("this is a description"), amount: .specific(10000), string: "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r", expiry: 604800, payment_hash: Data(), created_at: 1666139119)
struct InvoiceView_Previews: PreviewProvider {
+38 -23
View File
@@ -5,27 +5,27 @@
// Created by William Casarin on 2023-01-11.
//
import FaviconFinder
import Kingfisher
import SwiftUI
struct NIP05Badge: View {
let nip05: NIP05
let pubkey: Pubkey
let contacts: Contacts
let damus_state: DamusState
let show_domain: Bool
let profiles: Profiles
let nip05_domain_favicon: FaviconURL?
@Environment(\.openURL) var openURL
init(nip05: NIP05, pubkey: Pubkey, contacts: Contacts, show_domain: Bool, profiles: Profiles) {
init(nip05: NIP05, pubkey: Pubkey, damus_state: DamusState, show_domain: Bool, nip05_domain_favicon: FaviconURL?) {
self.nip05 = nip05
self.pubkey = pubkey
self.contacts = contacts
self.damus_state = damus_state
self.show_domain = show_domain
self.profiles = profiles
self.nip05_domain_favicon = nip05_domain_favicon
}
var nip05_color: Bool {
return use_nip05_color(pubkey: pubkey, contacts: contacts)
return use_nip05_color(pubkey: pubkey, contacts: damus_state.contacts)
}
var Seal: some View {
@@ -44,8 +44,23 @@ struct NIP05Badge: View {
}
}
var domainBadge: some View {
Group {
if let nip05_domain_favicon {
KFImage(nip05_domain_favicon.source)
.imageContext(.favicon, disable_animation: true)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 18, height: 18)
.clipped()
} else {
EmptyView()
}
}
}
var username_matches_nip05: Bool {
guard let name = profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
guard let name = damus_state.profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
else {
return false
}
@@ -65,14 +80,18 @@ struct NIP05Badge: View {
HStack(spacing: 2) {
Seal
if show_domain {
Text(nip05_string)
.nip05_colorized(gradient: nip05_color)
.onTapGesture {
if let nip5url = nip05.siteUrl {
openURL(nip5url)
}
}
Group {
if show_domain {
Text(nip05_string)
.nip05_colorized(gradient: nip05_color)
}
if nip05_domain_favicon != nil {
domainBadge
}
}
.onTapGesture {
damus_state.nav.push(route: Route.NIP05DomainEvents(events: NIP05DomainEventsModel(state: damus_state, domain: nip05.host), nip05_domain_favicon: nip05_domain_favicon))
}
}
@@ -98,13 +117,9 @@ struct NIP05Badge_Previews: PreviewProvider {
static var previews: some View {
let test_state = test_damus_state
VStack {
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, damus_state: test_state, show_domain: true, nip05_domain_favicon: nil)
NIP05Badge(nip05: NIP05(username: "_", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: Contacts(our_pubkey: test_pubkey), show_domain: true, profiles: test_state.profiles)
NIP05Badge(nip05: NIP05(username: "_", host: "jb55.com"), pubkey: test_state.pubkey, damus_state: test_state, show_domain: true, nip05_domain_favicon: nil)
}
}
}
+4 -4
View File
@@ -84,7 +84,7 @@ struct NoteZapButton: View {
print("cancel_zap: we already have a real zap, can't cancel")
break
case .pending(let pzap):
guard let res = cancel_zap(zap: pzap, box: damus_state.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else {
guard let res = cancel_zap(zap: pzap, box: damus_state.nostrNetwork.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
return
@@ -179,7 +179,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
}
// Only take the first 10 because reasons
let relays = Array(damus_state.pool.our_descriptors.prefix(10))
let relays = Array(damus_state.nostrNetwork.pool.our_descriptors.prefix(10))
let content = comment ?? ""
guard let mzapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else {
@@ -232,7 +232,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
flusher = .once({ pe in
// send donation zap when the pending zap is flushed, this allows user to cancel and not send a donation
Task { @MainActor in
await WalletConnect.send_donation_zap(pool: damus_state.pool, postbox: damus_state.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat)
await WalletConnect.send_donation_zap(pool: damus_state.nostrNetwork.pool, postbox: damus_state.nostrNetwork.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat)
}
})
}
@@ -240,7 +240,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
// we don't have a delay on one-tap nozaps (since this will be from customize zap view)
let delay = damus_state.settings.nozaps ? nil : 5.0
let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, delay: delay, on_flush: flusher)
let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, invoice: inv, zap_request: zapreq, delay: delay, on_flush: flusher)
guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else {
print("nwc: failed to send nwc request for zapreq \(reqid.reqid)")
+37 -14
View File
@@ -94,12 +94,12 @@ struct SelectableText: View {
case show_mute_word_view(highlighted_text: String)
func should_show_highlight_post_view() -> Bool {
guard case .show_highlight_post_view(let highlighted_text) = self else { return false }
guard case .show_highlight_post_view = self else { return false }
return true
}
func should_show_mute_word_view() -> Bool {
guard case .show_mute_word_view(let highlighted_text) = self else { return false }
guard case .show_mute_word_view = self else { return false }
return true
}
@@ -119,16 +119,23 @@ struct SelectableText: View {
fileprivate class TextView: UITextView {
var postHighlight: (String) -> Void
var muteWord: (String) -> Void
private let enableHighlighting: Bool
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void) {
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void, enableHighlighting: Bool) {
self.postHighlight = postHighlight
self.muteWord = muteWord
self.enableHighlighting = enableHighlighting
super.init(frame: frame, textContainer: textContainer)
if enableHighlighting {
self.delegate = self
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
fatalError("init(coder:) has not been implemented")
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(highlightText(_:)) {
@@ -142,23 +149,44 @@ fileprivate class TextView: UITextView {
return super.canPerformAction(action, withSender: sender)
}
func getSelectedText() -> String? {
private func getSelectedText() -> String? {
guard let selectedRange = self.selectedTextRange else { return nil }
return self.text(in: selectedRange)
}
@objc public func highlightText(_ sender: Any?) {
@objc private func highlightText(_ sender: Any?) {
guard let selectedText = self.getSelectedText() else { return }
self.postHighlight(selectedText)
}
@objc public func muteText(_ sender: Any?) {
@objc private func muteText(_ sender: Any?) {
guard let selectedText = self.getSelectedText() else { return }
self.muteWord(selectedText)
}
}
extension TextView: UITextViewDelegate {
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
guard enableHighlighting,
let selectedTextRange = self.selectedTextRange,
let selectedText = self.text(in: selectedTextRange),
!selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
let highlightAction = UIAction(title: NSLocalizedString("Highlight", comment: "Context menu action to highlight the selected text as context to draft a new note."), image: UIImage(systemName: "highlighter")) { [weak self] _ in
self?.postHighlight(selectedText)
}
let muteAction = UIAction(title: NSLocalizedString("Mute", comment: "Context menu action to mute the selected word."), image: UIImage(systemName: "speaker.slash")) { [weak self] _ in
self?.muteWord(selectedText)
}
return UIMenu(children: suggestedActions + [highlightAction, muteAction])
}
}
fileprivate struct TextViewRepresentable: UIViewRepresentable {
let attributedString: AttributedString
@@ -172,7 +200,7 @@ fileprivate struct TextViewRepresentable: UIViewRepresentable {
@Binding var height: CGFloat
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord)
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord, enableHighlighting: enableHighlighting)
view.isEditable = false
view.dataDetectorTypes = .all
view.isSelectable = true
@@ -183,11 +211,6 @@ fileprivate struct TextViewRepresentable: UIViewRepresentable {
view.textContainerInset.right = 1.0
view.textAlignment = textAlignment
let menuController = UIMenuController.shared
let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:)))
let muteItem = UIMenuItem(title: "Mute", action: #selector(view.muteText(_:)))
menuController.menuItems = self.enableHighlighting ? [highlightItem, muteItem] : []
return view
}
@@ -213,6 +213,6 @@ struct UserStatusSheet: View {
struct UserStatusSheet_Previews: PreviewProvider {
static var previews: some View {
UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.postbox, keypair: test_keypair, status: .init())
UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.nostrNetwork.postbox, keypair: test_keypair, status: .init())
}
}
+1 -1
View File
@@ -160,7 +160,7 @@ func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, set
// Render translated note
let translated_blocks = parse_note_content(content: .content(translated_note, event.tags))
let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles)
let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles, can_hide_last_previewable_refs: true)
// and cache it
return .translated(Translated(artifacts: artifacts, language: note_lang))
+65 -50
View File
@@ -9,6 +9,7 @@ import SwiftUI
import AVKit
import MediaPlayer
import EmojiPicker
import TipKit
struct ZapSheet {
let target: ZapTarget
@@ -178,7 +179,7 @@ struct ContentView: View {
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
case .dms:
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle)
}
}
.background(DamusColors.adaptableWhite)
@@ -199,7 +200,7 @@ struct ContentView: View {
func MaybeReportView(target: ReportTarget) -> some View {
Group {
if let keypair = damus_state.keypair.to_full() {
ReportView(postbox: damus_state.postbox, target: target, keypair: keypair)
ReportView(postbox: damus_state.nostrNetwork.postbox, target: target, keypair: keypair)
} else {
EmptyView()
}
@@ -317,7 +318,7 @@ struct ContentView: View {
case .post(let action):
PostView(action: action, damus_state: damus_state!)
case .user_status:
UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.nostrNetwork.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
.presentationDragIndicator(.visible)
case .event:
EventDetailView()
@@ -333,7 +334,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 +370,7 @@ struct ContentView: View {
self.hide_bar = !show
}
.onReceive(timer) { n in
self.damus_state?.postbox.try_flushing_events()
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,10 +381,6 @@ struct ContentView: View {
self.confirm_mute = true
}
.onReceive(handle_notify(.attached_wallet)) { nwc in
// Ensure to add NWC relay to the pool and connect it.
try? damus_state.pool.add_relay(.nwc(url: nwc.relay))
damus_state.pool.connect(to: [nwc.relay])
// update the lightning address on our profile when we attach a
// wallet with an associated
guard let ds = self.damus_state,
@@ -391,12 +401,12 @@ struct ContentView: View {
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: profile.reactions)
guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
ds.postbox.send(ev)
ds.nostrNetwork.postbox.send(ev)
}
.onReceive(handle_notify(.broadcast)) { ev in
guard let ds = self.damus_state else { return }
ds.postbox.send(ev)
ds.nostrNetwork.postbox.send(ev)
}
.onReceive(handle_notify(.unfollow)) { target in
guard let state = self.damus_state else { return }
@@ -418,7 +428,7 @@ struct ContentView: View {
return
}
if !handle_post_notification(keypair: keypair, postbox: state.postbox, events: state.events, post: post) {
if !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post) {
self.active_sheet = nil
}
}
@@ -462,7 +472,7 @@ struct ContentView: View {
}
}
.onReceive(handle_notify(.disconnect_relays)) { () in
damus_state.pool.disconnect()
damus_state.nostrNetwork.pool.disconnect()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
print("txn: 📙 DAMUS ACTIVE NOTIFY")
@@ -508,7 +518,7 @@ struct ContentView: View {
break
case .active:
print("txn: 📙 DAMUS ACTIVE")
damus_state.pool.ping()
damus_state.nostrNetwork.pool.ping()
@unknown default:
break
}
@@ -527,7 +537,7 @@ struct ContentView: View {
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: !hide)
guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
ds.postbox.send(profile_ev)
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.")) {
@@ -559,7 +569,7 @@ struct ContentView: View {
}
ds.mutelist_manager.set_mutelist(mutelist)
ds.postbox.send(mutelist)
ds.nostrNetwork.postbox.send(mutelist)
confirm_overwrite_mutelist = false
confirm_mute = false
@@ -591,7 +601,7 @@ struct ContentView: View {
}
ds.mutelist_manager.set_mutelist(ev)
ds.postbox.send(ev)
ds.nostrNetwork.postbox.send(ev)
}
}
}, message: {
@@ -632,7 +642,7 @@ struct ContentView: View {
func handleNotification(notification: LossyLocalNotification) {
Log.info("ContentView is handling a notification", for: .push_notifications)
guard let damus_state else {
guard damus_state != nil else {
// This should never happen because `listenAndHandleLocalNotifications` is called after damus state is initialized in `onAppear`
assertionFailure("DamusState not loaded when ContentView (new handler) was handling a notification")
Log.error("DamusState not loaded when ContentView (new handler) was handling a notification", for: .push_notifications)
@@ -660,28 +670,14 @@ struct ContentView: View {
guard let ndb = mndb else { return }
let pool = RelayPool(ndb: ndb, keypair: keypair)
let model_cache = RelayModelCache()
let relay_filters = RelayFilters(our_pubkey: pubkey)
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
let new_relay_filters = load_relay_filters(pubkey) == nil
for relay in bootstrap_relays {
let descriptor = RelayDescriptor(url: relay, info: .rw)
add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode)
}
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
if let nwc_str = settings.nostr_wallet_connect,
let nwc = WalletConnectURL(str: nwc_str) {
try? pool.add_relay(.nwc(url: nwc.relay))
}
self.damus_state = DamusState(pool: pool,
keypair: keypair,
self.damus_state = DamusState(keypair: keypair,
likes: EventCounter(our_pubkey: pubkey),
boosts: EventCounter(our_pubkey: pubkey),
contacts: Contacts(our_pubkey: pubkey),
@@ -697,8 +693,6 @@ struct ContentView: View {
drafts: Drafts(),
events: EventCache(ndb: ndb),
bookmarks: BookmarksManager(pubkey: pubkey),
postbox: PostBox(pool: pool),
bootstrap_relays: bootstrap_relays,
replies: ReplyCounter(our_pubkey: pubkey),
wallet: WalletModel(settings: settings),
nav: self.navigationCoordinator,
@@ -706,7 +700,8 @@ 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!
@@ -722,7 +717,23 @@ struct ContentView: View {
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
}
pool.connect()
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)
}
}
}
func music_changed(_ state: MusicState) {
@@ -745,7 +756,7 @@ struct ContentView: View {
pdata.status.music = music
guard let ev = music.to_note(keypair: kp) else { return }
damus_state.postbox.send(ev)
damus_state.nostrNetwork.postbox.send(ev)
}
}
@@ -761,6 +772,8 @@ struct ContentView: View {
case route(Route)
/// Open a sheet
case sheet(Sheets)
/// Open an external URL
case external_url(URL)
/// Do nothing.
///
/// ## Implementation notes
@@ -777,6 +790,8 @@ struct ContentView: View {
navigationCoordinator.push(route: route)
case .sheet(let sheet):
self.active_sheet = sheet
case .external_url(let url):
this_app.open(url)
case .no_action:
return
}
@@ -994,7 +1009,7 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
var has_event = false
guard let filter else { return }
state.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in
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
}
@@ -1008,7 +1023,7 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
break
case .event(_, let ev):
has_event = true
state.pool.unsubscribe(sub_id: subid)
state.nostrNetwork.pool.unsubscribe(sub_id: subid)
switch query {
case .profile:
@@ -1021,11 +1036,11 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
case .eose:
if !has_event {
attempts += 1
if attempts >= state.pool.our_descriptors.count {
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.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose
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:
@@ -1044,15 +1059,15 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
/// - naddr: the `naddr` address
/// - callback: A function to handle the found event
func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (NostrEvent?) -> ()) {
var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
let nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
let subid = UUID().description
damus_state.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in
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.pool.unsubscribe(sub_id: subid, to: [relay_id])
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
return
}
@@ -1060,14 +1075,14 @@ func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (Nos
for tag in ev.tags {
if(tag.count >= 2 && tag[0].string() == "d"){
if (tag[1].string() == naddr.identifier){
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
callback(ev)
return
}
}
}
}
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
}
}
@@ -1115,7 +1130,7 @@ func handle_unfollow(state: DamusState, unfollow: FollowRef) -> Bool {
let old_contacts = state.contacts.event
guard let ev = unfollow_reference(postbox: state.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
guard let ev = unfollow_reference(postbox: state.nostrNetwork.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
else {
return false
}
@@ -1141,7 +1156,7 @@ func handle_follow(state: DamusState, follow: FollowRef) -> Bool {
return false
}
guard let ev = follow_reference(box: state.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
guard let ev = follow_reference(box: state.nostrNetwork.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
else {
return false
}
@@ -1216,7 +1231,7 @@ extension LossyLocalNotification {
case .nprofile(let nProfile):
// TODO: Improve this by implementing a profile route that handles nprofiles with their relay hints.
return .route(.ProfileByKey(pubkey: nProfile.author))
case .nrelay(let string):
case .nrelay:
// We do not need to implement `nrelay` support, it has been deprecated.
// See https://github.com/nostr-protocol/nips/blob/6e7a618e7f873bb91e743caacc3b09edab7796a0/BREAKING.md?plain=1#L21
return .sheet(.error(ErrorView.UserPresentableError(
+77
View File
@@ -0,0 +1,77 @@
//
// Interests.swift
// damus
//
// Created by Daniel DAquino on 2025-06-25.
//
import Foundation
struct DIP06 {
/// Standard general interest topics.
/// See https://github.com/damus-io/dips/pull/3
enum Interest: String, CaseIterable {
/// Bitcoin-related topics (e.g. Bitcoin, Lightning, e-cash etc)
case bitcoin = "bitcoin"
/// Any non-Bitcoin technology-related topic (e.g. Linux, new releases, software development, supersonic flight, etc)
case technology = "technology"
/// Any science-related topic (e.g. astronomy, biology, physics, etc)
case science = "science"
/// Lifestyle topics (e.g. Worldschooling, Digital nomading, vagabonding, homesteading, digital minimalism, life hacks, etc)
case lifestyle = "lifestyle"
/// Travel-related topics (e.g. Information about locations to visit, travel logs, etc)
case travel = "travel"
/// Any art-related topic (e.g. poetry, painting, sculpting, photography, etc)
case art = "art"
/// Topics focused on improving human health (e.g. advances in medicine, exercising, nutrition, meditation, sleep, etc)
case health = "health"
/// Any music-related topic (e.g. Bands, fan pages, instruments, classical music theory, etc)
case music = "music"
/// Any topic related to food (e.g. Cooking, recipes, meal planning, nutrition)
case food = "food"
/// Any topic related to sports (e.g. Athlete fan pages, general sports information, sports news, sports equipment, etc)
case sports = "sports"
/// Any topic related to religion, spirituality, or faith (e.g. Christianity, Judaism, Buddhism, Islamism, Hinduism, Taoism, general meditation practice, etc)
case religionSpirituality = "religion-spirituality"
/// General humanities topics (e.g. philosophy, sociology, culture, etc)
case humanities = "humanities"
/// General topics about politics
case politics = "politics"
/// Other miscellaneous topics that do not fit in any of the previous items of the list
case other = "other"
var label: String {
switch self {
case .bitcoin:
return NSLocalizedString("₿ Bitcoin", comment: "Interest topic label")
case .technology:
return NSLocalizedString("💻 Tech", comment: "Interest topic label")
case .science:
return NSLocalizedString("🔭 Science", comment: "Interest topic label")
case .lifestyle:
return NSLocalizedString("🏝️ Lifestyle", comment: "Interest topic label")
case .travel:
return NSLocalizedString("✈️ Travel", comment: "Interest topic label")
case .art:
return NSLocalizedString("🎨 Art", comment: "Interest topic label")
case .health:
return NSLocalizedString("🏃 Health", comment: "Interest topic label")
case .music:
return NSLocalizedString("🎶 Music", comment: "Interest topic label")
case .food:
return NSLocalizedString("🍱 Food", comment: "Interest topic label")
case .sports:
return NSLocalizedString("⚾️ Sports", comment: "Interest topic label")
case .religionSpirituality:
return NSLocalizedString("🛐 Religion", comment: "Interest topic label")
case .humanities:
return NSLocalizedString("📚 Humanities", comment: "Interest topic label")
case .politics:
return NSLocalizedString("🏛️ Politics", comment: "Interest topic label")
case .other:
return NSLocalizedString("♾️ Other", comment: "Interest topic label")
}
}
}
}
@@ -1,32 +0,0 @@
//
// CameraService+Extensions.swift
// damus
//
// Created by Suhail Saqan on 8/5/23.
//
import Foundation
import UIKit
import AVFoundation
extension AVCaptureVideoOrientation {
init?(deviceOrientation: UIDeviceOrientation) {
switch deviceOrientation {
case .portrait: self = .portrait
case .portraitUpsideDown: self = .portraitUpsideDown
case .landscapeLeft: self = .landscapeRight
case .landscapeRight: self = .landscapeLeft
default: return nil
}
}
init?(interfaceOrientation: UIInterfaceOrientation) {
switch interfaceOrientation {
case .portrait: self = .portrait
case .portraitUpsideDown: self = .portraitUpsideDown
case .landscapeLeft: self = .landscapeLeft
case .landscapeRight: self = .landscapeRight
default: return nil
}
}
}
+1 -54
View File
@@ -63,44 +63,10 @@ func follow_user_event(our_contacts: NostrEvent?, keypair: FullKeypair, follow:
}
func decode_json_relays(_ content: String) -> [RelayURL: RelayInfo]? {
func decode_json_relays(_ content: String) -> [RelayURL: LegacyKind3RelayRWConfiguration]? {
return decode_json(content)
}
func remove_relay(ev: NostrEvent, current_relays: [RelayDescriptor], keypair: FullKeypair, relay: RelayURL) -> NostrEvent?{
var relays = ensure_relay_info(relays: current_relays, content: ev.content)
relays.removeValue(forKey: relay)
guard let content = encode_json(relays) else {
return nil
}
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 3, tags: ev.tags.strings())
}
func add_relay(ev: NostrEvent, keypair: FullKeypair, current_relays: [RelayDescriptor], relay: RelayURL, info: RelayInfo) -> NostrEvent? {
var relays = ensure_relay_info(relays: current_relays, content: ev.content)
// If kind:3 content is empty, or if the relay doesn't exist in the list,
// we want to create a kind:3 event with the new relay
guard ev.content.isEmpty || relays.index(forKey: relay) == nil else {
return nil
}
relays[relay] = info
guard let content = encode_json(relays) else {
return nil
}
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 3, tags: ev.tags.strings())
}
func ensure_relay_info(relays: [RelayDescriptor], content: String) -> [RelayURL: RelayInfo] {
return decode_json_relays(content) ?? make_contact_relays(relays)
}
func is_already_following(contacts: NostrEvent, follow: FollowRef) -> Bool {
return contacts.references.contains { ref in
switch (ref, follow) {
@@ -128,22 +94,3 @@ func follow_with_existing_contacts(keypair: FullKeypair, our_contacts: NostrEven
return NostrEvent(content: our_contacts.content, keypair: keypair.to_keypair(), kind: kind, tags: tags)
}
func make_contact_relays(_ relays: [RelayDescriptor]) -> [RelayURL: RelayInfo] {
return relays.reduce(into: [:]) { acc, relay in
acc[relay.url] = relay.info
}
}
func make_relay_metadata(relays: [RelayDescriptor], keypair: FullKeypair) -> NostrEvent? {
let tags = relays.compactMap { r -> [String]? in
var tag = ["r", r.url.absoluteString]
if (r.info.read ?? true) != (r.info.write ?? true) {
tag += r.info.read == true ? ["read"] : ["write"]
}
if ((r.info.read ?? true) || (r.info.write ?? true)) && r.variant == .regular {
return tag;
}
return nil
}
return NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 10_002, tags: tags)
}
+4
View File
@@ -38,6 +38,10 @@ class Contacts {
return friends
}
func get_friend_of_friends_list() -> Set<Pubkey> {
return friend_of_friends
}
func get_followed_hashtags() -> Set<String> {
guard let ev = self.event else { return Set() }
return Set(ev.referenced_hashtags.map({ $0.hashtag }))
+11 -1
View File
@@ -13,6 +13,7 @@ enum FilterState : Int {
case posts = 0
case posts_and_replies = 1
case conversations = 2
case follow_list = 3
func filter(ev: NostrEvent) -> Bool {
switch self {
@@ -22,13 +23,15 @@ enum FilterState : Int {
return true
case .conversations:
return true
case .follow_list:
return ev.known_kind == .follow_list
}
}
}
/// Simple filter to determine whether to show posts with #nsfw tags
func nsfw_tag_filter(ev: NostrEvent) -> Bool {
return ev.referenced_hashtags.first(where: { t in t.hashtag == "nsfw" }) == nil
return ev.referenced_hashtags.first(where: { t in t.hashtag.caseInsensitiveCompare("nsfw") == .orderedSame }) == nil
}
func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEvent) -> Bool) {
@@ -40,6 +43,12 @@ func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEv
}
}
func timestamp_filter(ev: NostrEvent) -> Bool {
// Allow notes that are created no more than 3 seconds in the future
// to account for natural clock skew between sender and receiver.
ev.age >= -3
}
/// Generic filter with various tweakable settings
struct ContentFilters {
var filters: [(NostrEvent) -> Bool]
@@ -66,6 +75,7 @@ extension ContentFilters {
filters.append(nsfw_tag_filter)
}
filters.append(get_repost_of_muted_user_filter(damus_state: damus_state))
filters.append(timestamp_filter)
return filters
}
}
+4
View File
@@ -27,6 +27,10 @@ class CreateAccountModel: ObservableObject {
return Keypair(pubkey: self.pubkey, privkey: self.privkey)
}
var full_keypair: FullKeypair {
return FullKeypair(pubkey: self.pubkey, privkey: self.privkey)
}
init(display_name: String = "", name: String = "", about: String = "") {
let keypair = generate_new_keypair()
self.pubkey = keypair.pubkey
+38 -29
View File
@@ -10,7 +10,6 @@ import LinkPresentation
import EmojiPicker
class DamusState: HeadlessDamusState {
let pool: RelayPool
let keypair: Keypair
let likes: EventCounter
let boosts: EventCounter
@@ -28,8 +27,6 @@ class DamusState: HeadlessDamusState {
let drafts: Drafts
let events: EventCache
let bookmarks: BookmarksManager
let postbox: PostBox
let bootstrap_relays: [RelayURL]
let replies: ReplyCounter
let wallet: WalletModel
let nav: NavigationCoordinator
@@ -39,9 +36,10 @@ class DamusState: HeadlessDamusState {
var purple: DamusPurple
var push_notification_client: PushNotificationClient
let emoji_provider: EmojiProvider
let favicon_cache: FaviconCache
private(set) var nostrNetwork: NostrNetworkManager
init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, postbox: PostBox, bootstrap_relays: [RelayURL], replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
self.pool = pool
init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, 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) {
self.keypair = keypair
self.likes = likes
self.boosts = boosts
@@ -58,8 +56,6 @@ class DamusState: HeadlessDamusState {
self.drafts = drafts
self.events = events
self.bookmarks = bookmarks
self.postbox = postbox
self.bootstrap_relays = bootstrap_relays
self.replies = replies
self.wallet = wallet
self.nav = nav
@@ -73,6 +69,10 @@ 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)
}
@MainActor
@@ -98,27 +98,13 @@ class DamusState: HeadlessDamusState {
guard let ndb = mndb else { return nil }
let pubkey = keypair.pubkey
let pool = RelayPool(ndb: ndb, keypair: keypair)
let model_cache = RelayModelCache()
let relay_filters = RelayFilters(our_pubkey: pubkey)
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
let new_relay_filters = load_relay_filters(pubkey) == nil
for relay in bootstrap_relays {
let descriptor = RelayDescriptor(url: relay, info: .rw)
add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode)
}
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
if let nwc_str = settings.nostr_wallet_connect,
let nwc = WalletConnectURL(str: nwc_str) {
try? pool.add_relay(.nwc(url: nwc.relay))
}
self.init(
pool: pool,
keypair: keypair,
likes: EventCounter(our_pubkey: pubkey),
boosts: EventCounter(our_pubkey: pubkey),
@@ -135,8 +121,6 @@ class DamusState: HeadlessDamusState {
drafts: Drafts(),
events: EventCache(ndb: ndb),
bookmarks: BookmarksManager(pubkey: pubkey),
postbox: PostBox(pool: pool),
bootstrap_relays: bootstrap_relays,
replies: ReplyCounter(our_pubkey: pubkey),
wallet: WalletModel(settings: settings),
nav: navigationCoordinator,
@@ -144,7 +128,8 @@ class DamusState: HeadlessDamusState {
video: DamusVideoCoordinator(),
ndb: ndb,
quote_reposts: .init(our_pubkey: pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: FaviconCache()
)
}
@@ -179,7 +164,7 @@ class DamusState: HeadlessDamusState {
try await self.push_notification_client.revoke_token()
}
wallet.disconnect()
pool.close()
nostrNetwork.pool.close()
ndb.close()
}
@@ -189,7 +174,6 @@ class DamusState: HeadlessDamusState {
let kp = Keypair(pubkey: empty_pub, privkey: nil)
return DamusState.init(
pool: RelayPool(ndb: .empty),
keypair: Keypair(pubkey: empty_pub, privkey: empty_sec),
likes: EventCounter(our_pubkey: empty_pub),
boosts: EventCounter(our_pubkey: empty_pub),
@@ -206,8 +190,6 @@ class DamusState: HeadlessDamusState {
drafts: Drafts(),
events: EventCache(ndb: .empty),
bookmarks: BookmarksManager(pubkey: empty_pub),
postbox: PostBox(pool: RelayPool(ndb: .empty)),
bootstrap_relays: [],
replies: ReplyCounter(our_pubkey: empty_pub),
wallet: WalletModel(settings: UserSettingsStore()),
nav: NavigationCoordinator(),
@@ -215,7 +197,34 @@ class DamusState: HeadlessDamusState {
video: DamusVideoCoordinator(),
ndb: .empty,
quote_reposts: .init(our_pubkey: empty_pub),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: FaviconCache()
)
}
}
fileprivate extension DamusState {
struct NostrNetworkManagerDelegate: NostrNetworkManager.Delegate {
let settings: UserSettingsStore
let contacts: Contacts
var ndb: Ndb
var keypair: Keypair
var latestRelayListEventIdHex: String? {
get { self.settings.latestRelayListEventIdHex }
set { self.settings.latestRelayListEventIdHex = newValue }
}
var latestContactListEvent: NostrEvent? { self.contacts.event }
var bootstrapRelays: [RelayURL] { get_default_bootstrap_relays() }
var developerMode: Bool { self.settings.developer_mode }
var relayModelCache: RelayModelCache
var relayFilters: RelayFilters
var nwcWallet: WalletConnectURL? {
guard let nwcString = self.settings.nostr_wallet_connect else { return nil }
return WalletConnectURL(str: nwcString)
}
}
}
+1 -1
View File
@@ -251,7 +251,7 @@ class Drafts: ObservableObject {
// TODO: Once it is time to implement draft syncing with relays, please consider the following:
// - Privacy: Sending drafts to the network leaks metadata about app activity, and may break user expectations
// - Down-sync conflict resolution: Consider how to solve conflicts for different draft versions holding the same ID (e.g. edited in Damus, then another client, then Damus again)
damus_state.pool.send_raw_to_local_ndb(.typical(.event(draft_event)))
damus_state.nostrNetwork.pool.send_raw_to_local_ndb(.typical(.event(draft_event)))
}
damus_state.settings.draft_event_ids = draft_events.map({ $0.id.hex() })
+2 -2
View File
@@ -68,13 +68,13 @@ class EventsModel: ObservableObject {
}
func subscribe() {
state.pool.subscribe(sub_id: sub_id,
state.nostrNetwork.pool.subscribe(sub_id: sub_id,
filters: [get_filter()],
handler: handle_nostr_event)
}
func unsubscribe() {
state.pool.unsubscribe(sub_id: sub_id)
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
}
private func handle_event(relay_id: RelayURL, ev: NostrEvent) {
+44
View File
@@ -0,0 +1,44 @@
//
// FollowPackEvent.swift
// damus
//
// Created by eric on 4/30/25.
//
import Foundation
struct FollowPackEvent: Hashable {
let event: NostrEvent
var title: String? = nil
var uuid: String? = nil
var image: URL? = nil
var description: String? = nil
var publicKeys: [Pubkey] = []
var interests: Set<DIP06.Interest> = []
static func parse(from ev: NostrEvent) -> FollowPackEvent {
var followlist = FollowPackEvent(event: ev)
for tag in ev.tags {
guard tag.count >= 2 else { continue }
switch tag[0].string() {
case "title": followlist.title = tag[1].string()
case "d": followlist.uuid = tag[1].string()
case "image": followlist.image = URL(string: tag[1].string())
case "description": followlist.description = tag[1].string()
case "p":
followlist.publicKeys.append(Pubkey(Data(hex: tag[1].string())))
case "t":
if let interest = DIP06.Interest(rawValue: tag[1].string()) {
followlist.interests.insert(interest)
}
default:
break
}
}
return followlist
}
}
+77
View File
@@ -0,0 +1,77 @@
//
// FollowPackModel.swift
// damus
//
// Created by eric on 6/5/25.
//
import Foundation
class FollowPackModel: ObservableObject {
var events: EventHolder
@Published var loading: Bool = false
let damus_state: DamusState
let subid = UUID().description
let limit: UInt32 = 500
init(damus_state: DamusState) {
self.damus_state = damus_state
self.events = EventHolder(on_queue: { ev in
preload_events(state: damus_state, events: [ev])
})
}
func subscribe(follow_pack_users: [Pubkey]) {
loading = true
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
var filter = NostrFilter(kinds: [.text, .chat])
filter.until = UInt32(Date.now.timeIntervalSince1970)
filter.authors = follow_pack_users
filter.limit = 500
damus_state.nostrNetwork.pool.subscribe(sub_id: subid, filters: [filter], handler: handle_event, to: to_relays)
}
func unsubscribe(to: RelayURL? = nil) {
loading = false
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: to.map { [$0] })
}
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
guard case .nostr_event(let event) = conn_ev else {
return
}
switch event {
case .event(let sub_id, let ev):
guard sub_id == self.subid else {
return
}
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
{
if self.events.insert(ev) {
self.objectWillChange.send()
}
}
case .notice(let msg):
print("follow pack notice: \(msg)")
case .ok:
break
case .eose(let sub_id):
loading = false
if sub_id == self.subid {
unsubscribe(to: relay_id)
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
}
break
case .auth:
break
}
}
}
+4 -4
View File
@@ -37,11 +37,11 @@ class FollowersModel: ObservableObject {
let filter = get_filter()
let filters = [filter]
//print_filters(relay_id: "following", filters: [filters])
self.damus_state.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
}
func unsubscribe() {
self.damus_state.pool.unsubscribe(sub_id: sub_id)
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
}
func handle_contact_event(_ ev: NostrEvent) {
@@ -61,7 +61,7 @@ class FollowersModel: ObservableObject {
let filter = NostrFilter(kinds: [.metadata],
authors: authors)
damus_state.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event)
damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event)
}
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
@@ -86,7 +86,7 @@ class FollowersModel: ObservableObject {
guard let txn = NdbTxn(ndb: self.damus_state.ndb) else { return }
load_profiles(relay_id: relay_id, txn: txn)
} else if sub_id == self.profiles_id {
damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
}
case .ok:
+2 -2
View File
@@ -42,7 +42,7 @@ class FollowingModel {
}
let filters = [filter]
//print_filters(relay_id: "following", filters: [filters])
self.damus_state.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
}
func unsubscribe() {
@@ -50,7 +50,7 @@ class FollowingModel {
return
}
print("unsubscribing from following \(sub_id)")
self.damus_state.pool.unsubscribe(sub_id: sub_id)
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
}
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
+2 -2
View File
@@ -35,9 +35,9 @@ enum FriendFilter: String, StringCodable {
func description() -> String {
switch self {
case .all:
return NSLocalizedString("All", comment: "Human-readable short description of the 'friends filter' when it is set to 'all'")
return NSLocalizedString("All", comment: "Human-readable short description of the 'trusted network filter' when it is disabled, and therefore is showing all content.")
case .friends_of_friends:
return NSLocalizedString("Friends of friends", comment: "Human-readable short description of the 'friends filter' when it is set to 'friends-of-friends'")
return NSLocalizedString("Trusted Network", comment: "Human-readable short description of the 'trusted network filter' when it is enabled, and therefore showing content from only the trusted network.")
}
}
}
+36 -95
View File
@@ -95,13 +95,13 @@ class HomeModel: ContactsDelegate {
}
var pool: RelayPool {
return damus_state.pool
self.damus_state.nostrNetwork.pool
}
var dms: DirectMessagesModel {
return damus_state.dms
}
func has_sub_id_event(sub_id: String, ev_id: NoteId) -> Bool {
if !has_event.keys.contains(sub_id) {
has_event[sub_id] = Set()
@@ -225,6 +225,12 @@ class HomeModel: ContactsDelegate {
// TODO: Implement draft syncing with relays. We intentionally do not support that as of writing. See `DraftsModel.swift` for other details
// try? damus_state.drafts.load(wrapped_draft_note: ev, with: damus_state)
break
case .relay_list:
break // This will be handled by `UserRelayListManager`
case .follow_list:
break
case .interest_list:
break // Don't care for now
}
}
@@ -259,34 +265,41 @@ class HomeModel: ContactsDelegate {
Task { @MainActor in
// TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time
guard let nwc_str = damus_state.settings.nostr_wallet_connect,
let nwc = WalletConnectURL(str: nwc_str),
let resp = await WalletConnect.FullWalletResponse(from: ev, nwc: nwc) else {
let nwc = WalletConnectURL(str: nwc_str) else {
return
}
guard nwc.relay == relay else { return } // Don't process NWC responses coming from relays other than our designated one
guard ev.referenced_pubkeys.first == nwc.keypair.pubkey else {
return // This message is not for us. Ignore it.
}
var resp: WalletConnect.FullWalletResponse? = nil
do {
resp = try await WalletConnect.FullWalletResponse(from: ev, nwc: nwc)
} catch {
Log.error("HomeModel: Error on NWC wallet response handling: %s", for: .nwc, error.localizedDescription)
if let initError = error as? WalletConnect.FullWalletResponse.InitializationError,
let humanReadableError = initError.humanReadableError {
present_sheet(.error(humanReadableError))
}
}
guard let resp else { return }
// since command results are not returned for ephemeral events,
// remove the request from the postbox which is likely failing over and over
if damus_state.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) {
print("nwc: got response, removed \(resp.req_id) from the postbox [\(relay)]")
if damus_state.nostrNetwork.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) {
Log.debug("HomeModel: got NWC response, removed %s from the postbox [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString)
} else {
print("nwc: \(resp.req_id) not found in the postbox, nothing to remove [\(relay)]")
Log.debug("HomeModel: got NWC response, %s not found in the postbox, nothing to remove [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString)
}
damus_state.wallet.handle_nwc_response(response: resp) // This can handle success or error cases
guard resp.response.error == nil else {
print("nwc error: \(resp.response)")
Log.error("HomeModel: NWC wallet raised an error: %s", for: .nwc, String(describing: resp.response))
WalletConnect.handle_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp)
return
}
if resp.response.result_type == .list_transactions {
Log.info("Received NWC transaction list from %s", for: .nwc, relay.absoluteString)
damus_state.wallet.handle_nwc_response(response: resp)
return
}
if resp.response.result_type == .get_balance {
Log.info("Received NWC balance information from %s", for: .nwc, relay.absoluteString)
damus_state.wallet.handle_nwc_response(response: resp)
return
}
@@ -478,7 +491,7 @@ class HomeModel: ContactsDelegate {
break
}
update_signal_from_pool(signal: self.signal, pool: damus_state.pool)
update_signal_from_pool(signal: self.signal, pool: damus_state.nostrNetwork.pool)
case .nostr_event(let ev):
switch ev {
case .event(let sub_id, let ev):
@@ -948,7 +961,6 @@ func load_our_stuff(state: DamusState, ev: NostrEvent) {
state.contacts.event = ev
load_our_contacts(state: state, m_old_ev: m_old_ev, ev: ev)
load_our_relays(state: state, m_old_ev: m_old_ev, ev: ev)
}
func process_contact_event(state: DamusState, ev: NostrEvent) {
@@ -956,78 +968,6 @@ func process_contact_event(state: DamusState, ev: NostrEvent) {
add_contact_if_friend(contacts: state.contacts, ev: ev)
}
func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
let bootstrap_dict: [RelayURL: RelayInfo] = [:]
let old_decoded = m_old_ev.flatMap { decode_json_relays($0.content) } ?? state.bootstrap_relays.reduce(into: bootstrap_dict) { (d, r) in
d[r] = .rw
}
guard let decoded: [RelayURL: RelayInfo] = decode_json_relays(ev.content) else {
return
}
var changed = false
var new = Set<RelayURL>()
for key in decoded.keys {
new.insert(key)
}
var old = Set<RelayURL>()
for key in old_decoded.keys {
old.insert(key)
}
let diff = old.symmetricDifference(new)
let new_relay_filters = load_relay_filters(state.pubkey) == nil
for d in diff {
changed = true
if new.contains(d) {
let descriptor = RelayDescriptor(url: d, info: decoded[d] ?? .rw)
add_new_relay(model_cache: state.relay_model_cache, relay_filters: state.relay_filters, pool: state.pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: state.settings.developer_mode)
} else {
state.pool.remove_relay(d)
}
}
if changed {
save_bootstrap_relays(pubkey: state.pubkey, relays: Array(new))
state.pool.connect()
notify(.relays_changed)
}
}
func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) {
try? pool.add_relay(descriptor)
let url = descriptor.url
let relay_id = url
guard model_cache.model(withURL: url) == nil else {
return
}
Task.detached(priority: .background) {
guard let meta = try? await fetch_relay_metadata(relay_id: relay_id) else {
return
}
await MainActor.run {
let model = RelayModel(url, metadata: meta)
model_cache.insert(model: model)
if logging_enabled {
pool.setLog(model.log, for: relay_id)
}
// if this is the first time adding filters, we should filter non-paid relays
if new_relay_filters && !meta.is_paid {
relay_filters.insert(timeline: .search, relay_id: relay_id)
}
}
}
}
func fetch_relay_metadata(relay_id: RelayURL) async throws -> RelayMetadata? {
var urlString = relay_id.absoluteString.replacingOccurrences(of: "wss://", with: "https://")
urlString = urlString.replacingOccurrences(of: "ws://", with: "http://")
@@ -1250,3 +1190,4 @@ func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale:
}
}
}
+50 -3
View File
@@ -64,10 +64,35 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
switch self {
case .pubkey(let pubkey): return ["p", pubkey.hex()]
case .note(let noteId): return ["e", noteId.hex()]
case .nevent(let nevent): return ["e", nevent.noteid.hex()]
case .nprofile(let nprofile): return ["p", nprofile.author.hex()]
case .nevent(let nevent):
var tagBuilder = ["e", nevent.noteid.hex()]
let relay = nevent.relays.first
if let author = nevent.author?.hex() {
tagBuilder.append(relay ?? "")
tagBuilder.append(author)
} else if let relay {
tagBuilder.append(relay)
}
return tagBuilder
case .nprofile(let nprofile):
var tagBuilder = ["p", nprofile.author.hex()]
if let relay = nprofile.relays.first {
tagBuilder.append(relay)
}
return tagBuilder
case .nrelay(let url): return ["r", url]
case .naddr(let naddr): return ["a", naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier.string()]
case .naddr(let naddr):
var tagBuilder = ["a", "\(naddr.kind.description):\(naddr.author.hex()):\(naddr.identifier.string())"]
if let relay = naddr.relays.first {
tagBuilder.append(relay)
}
return tagBuilder
}
}
@@ -163,6 +188,10 @@ struct LightningInvoice<T> {
let payment_hash: Data
let created_at: UInt64
var abbreviated: String {
return self.string.prefix(8) + "" + self.string.suffix(8)
}
var description_string: String {
switch description {
case .description(let string):
@@ -171,6 +200,17 @@ struct LightningInvoice<T> {
return ""
}
}
static func from(string: String) -> Invoice? {
// This feels a bit hacky at first, but it is actually clean
// because it reuses the same well-tested parsing logic as the rest of the app,
// avoiding code duplication and utilizing the guarantees acquired from age and testing.
// We could also use the C function `parse_invoice`, but it requires extra C bridging logic.
// NDBTODO: This may need updating on the nostrdb upgrade.
let parsedBlocks = parse_note_content(content: .content(string,nil)).blocks
guard parsedBlocks.count == 1 else { return nil }
return parsedBlocks[0].asInvoice
}
}
func maybe_pointee<T>(_ p: UnsafeMutablePointer<T>!) -> T? {
@@ -192,6 +232,13 @@ enum Amount: Equatable {
return format_msats(amt)
}
}
func amount_sats() -> Int64? {
switch self {
case .any: nil
case .specific(let amount): amount / 1000
}
}
}
func format_msats_abbrev(_ msats: Int64) -> String {
+10 -10
View File
@@ -47,16 +47,16 @@ enum MuteItem: Hashable, Equatable {
// rhs is the item we want to check against (ie. the item in the mute list)
switch (lhs, rhs) {
case (.user(let lhs_pubkey, _), .user(let rhs_pubkey, let rhs_expiration_date)):
return lhs_pubkey == rhs_pubkey && !rhs.is_expired()
case (.hashtag(let lhs_hashtag, _), .hashtag(let rhs_hashtag, let rhs_expiration_date)):
return lhs_hashtag == rhs_hashtag && !rhs.is_expired()
case (.word(let lhs_word, _), .word(let rhs_word, let rhs_expiration_date)):
return lhs_word == rhs_word && !rhs.is_expired()
case (.thread(let lhs_thread, _), .thread(let rhs_thread, let rhs_expiration_date)):
return lhs_thread == rhs_thread && !rhs.is_expired()
default:
return false
case (.user(let lhs_pubkey, _), .user(let rhs_pubkey, _)):
return lhs_pubkey == rhs_pubkey && !rhs.is_expired()
case (.hashtag(let lhs_hashtag, _), .hashtag(let rhs_hashtag, _)):
return lhs_hashtag == rhs_hashtag && !rhs.is_expired()
case (.word(let lhs_word, _), .word(let rhs_word, _)):
return lhs_word == rhs_word && !rhs.is_expired()
case (.thread(let lhs_thread, _), .thread(let rhs_thread, _)):
return lhs_thread == rhs_thread && !rhs.is_expired()
default:
return false
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ func migrate_old_muted_threads_to_new_mutelist(keypair: Keypair, damus_state: Da
let previous_mute_list_event = damus_state.mutelist_manager.event
guard let new_mutelist_event = create_or_update_mutelist(keypair: fullKeypair, mprev: previous_mute_list_event, to_add: Set(mutedThreads.map { MuteItem.thread($0, nil) })) else { return }
damus_state.mutelist_manager.set_mutelist(new_mutelist_event)
damus_state.postbox.send(new_mutelist_event)
damus_state.nostrNetwork.postbox.send(new_mutelist_event)
// Set existing muted threads to an empty array
UserDefaults.standard.set([], forKey: getMutedThreadsKey(pubkey: keypair.pubkey))
}
+97
View File
@@ -0,0 +1,97 @@
//
// NIP05DomainEventsModel.swift
// damus
//
// Created by Terry Yiu on 4/11/25.
//
import FaviconFinder
import Foundation
class NIP05DomainEventsModel: ObservableObject {
let state: DamusState
var events: EventHolder
@Published var loading: Bool = false
let domain: String
var filter: NostrFilter
let sub_id = UUID().description
let profiles_subid = UUID().description
let limit: UInt32 = 500
init(state: DamusState, domain: String) {
self.state = state
self.domain = domain
self.events = EventHolder(on_queue: { ev in
preload_events(state: state, events: [ev])
})
self.filter = NostrFilter()
}
@MainActor func subscribe() {
filter.limit = self.limit
filter.kinds = [.text, .longform, .highlight]
var authors = Set<Pubkey>()
for pubkey in state.contacts.get_friend_of_friends_list() {
let profile_txn = state.profiles.lookup(id: pubkey)
guard let profile = profile_txn?.unsafeUnownedValue,
let nip05_str = profile.nip05,
let nip05 = NIP05.parse(nip05_str),
nip05.host.caseInsensitiveCompare(domain) == .orderedSame else {
continue
}
authors.insert(pubkey)
}
if authors.isEmpty {
return
}
filter.authors = Array(authors)
print("subscribing to notes from friends of friends with '\(domain)' NIP-05 domain with sub_id \(sub_id)")
state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: handle_event)
loading = true
state.nostrNetwork.pool.send(.subscribe(.init(filters: [filter], sub_id: sub_id)))
}
func unsubscribe() {
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
loading = false
print("unsubscribing from notes from friends of friends with '\(domain)' NIP-05 domain with sub_id \(sub_id)")
}
func add_event(_ ev: NostrEvent) {
if !event_matches_filter(ev, filter: filter) {
return
}
guard should_show_event(state: state, ev: ev) else {
return
}
if self.events.insert(ev) {
objectWillChange.send()
}
}
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
let (sub_id, done) = handle_subid_event(pool: state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
if sub_id == self.sub_id && ev.is_textlike && ev.should_show_event {
self.add_event(ev)
}
}
guard done else {
return
}
self.loading = false
if sub_id == self.sub_id {
guard let txn = NdbTxn(ndb: state.ndb) else { return }
load_profiles(context: "search", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(self.events.all_events), damus_state: state, txn: txn)
}
}
}
@@ -0,0 +1,105 @@
//
// NostrNetworkManager.swift
// damus
//
// Created by Daniel DAquino on 2025-02-26.
//
import Foundation
/// Manages interactions with the Nostr Network.
///
/// This delineates a layer that is responsible for doing mid-level management of interactions with the Nostr network, controlling lower-level classes that perform more network/DB specific code, and providing an easier to use and more semantic interfaces for the rest of the app.
///
/// This is responsible for:
/// - Managing the user's relay list
/// - Establishing a `RelayPool` and maintaining it in sync with the user's relay list as it changes
/// - Abstracting away complexities of interacting with the nostr network, providing an easier-to-use interface to fetch and send content related to the Nostr network
///
/// This is **NOT** responsible for:
/// - Doing actual storage of relay list (delegated via the delegate
/// - Handling low-level relay logic (this will be delegated to lower level classes used in RelayPool/RelayConnection)
class NostrNetworkManager {
/// The relay pool that we manage
///
/// ## Implementation notes
///
/// - This will be marked `private` in the future to prevent other code from accessing the relay pool directly. Code outside this layer should use a higher level interface
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
init(delegate: Delegate) {
self.delegate = delegate
let pool = RelayPool(ndb: delegate.ndb, keypair: delegate.keypair)
self.pool = pool
let reader = SubscriptionManager(pool: pool, ndb: delegate.ndb)
let userRelayList = UserRelayListManager(delegate: delegate, pool: pool, reader: reader)
self.reader = reader
self.userRelayList = userRelayList
self.postbox = PostBox(pool: pool)
}
// MARK: - Control functions
/// Connects the app to the Nostr network
func connect() {
self.userRelayList.connect()
}
func relaysForEvent(event: NostrEvent) -> [RelayURL] {
// TODO(tyiu) Ideally this list would be sorted by the event author's outbox relay preferences
// and reliability of relays to maximize chances of others finding this event.
if let relays = pool.seen[event.id] {
return Array(relays)
}
return []
}
}
// 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.
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 }
/// 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,70 @@
//
// SubscriptionManager.swift
// damus
//
// Created by Daniel DAquino on 2025-03-25.
//
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
init(pool: RelayPool, ndb: Ndb) {
self.pool = pool
self.ndb = ndb
}
// MARK: - Reading data from Nostr
/// Subscribes to data from the user's relays
///
/// ## Implementation notes
///
/// - When we migrate to the local relay model, we should modify this function to stream directly from NostrDB
///
/// - Parameter filters: The nostr filters to specify what kind of data to subscribe to
/// - Returns: An async stream of nostr data
func subscribe(filters: [NostrFilter]) -> AsyncStream<StreamItem> {
return AsyncStream<StreamItem> { continuation in
let streamTask = Task {
for await item in self.pool.subscribe(filters: filters) {
switch item {
case .eose: continuation.yield(.eose)
case .event(let nostrEvent):
// At this point of the pipeline, if the note is valid it should have been processed and verified by NostrDB,
// in which case we should pull the note from NostrDB to ensure validity.
// However, NdbNotes are unowned, so we return a function where our callers can temporarily borrow the NostrDB note
let noteId = nostrEvent.id
let lender: NdbNoteLender = { lend in
guard let ndbNoteTxn = self.ndb.lookup_note(noteId) else {
throw NdbNoteLenderError.errorLoadingNote
}
guard let unownedNote = UnownedNdbNote(ndbNoteTxn) else {
throw NdbNoteLenderError.errorLoadingNote
}
lend(unownedNote)
}
continuation.yield(.event(borrow: lender))
}
}
}
continuation.onTermination = { @Sendable _ in
streamTask.cancel() // Close the RelayPool stream when caller stops streaming
}
}
}
}
enum StreamItem {
/// An event which can be borrowed from NostrDB
case event(borrow: NdbNoteLender)
/// The end of stored events
case eose
}
}
@@ -0,0 +1,85 @@
//
// UserRelayListErrors.swift
// damus
//
// Created by Daniel DAquino on 2025-02-27.
//
import Foundation
extension NostrNetworkManager.UserRelayListManager {
/// Models an error that may occur when performing operations that change the user's relay list.
///
/// Callers to functions that throw this error SHOULD handle them in order to provide a better user experience.
enum UpdateError: Error {
/// The user is not authorized to change relay list, usually because the private key is missing.
case notAuthorizedToChangeRelayList
/// An error occurred when forming the relay list Nostr event.
case cannotFormRelayListEvent
/// Cannot add item to the relay list because the relay is already present in the list.
case relayAlreadyExists
/// Cannot update the relay list because we do not have the user's previous relay list.
///
/// Implementers must be careful not to overwrite the user's existing relay list if it exists somewhere else.
case noInitialRelayList
/// Cannot remove or update a specific relay because it is not on the relay list
case noSuchRelay
/// Convert `RelayPool.RelayError` into `UserRelayListUpdateError`
static func from(_ relayPoolError: RelayPool.RelayError) -> Self {
switch relayPoolError {
case .RelayAlreadyExists: return .relayAlreadyExists
}
}
var humanReadableError: ErrorView.UserPresentableError {
switch self {
case .notAuthorizedToChangeRelayList:
ErrorView.UserPresentableError(
user_visible_description: NSLocalizedString("You do not have permission to alter this relay list.", comment: "Human readable error description"),
tip: NSLocalizedString("Please make sure you have logged-in with your private key.", comment: "Human readable tip for error"),
technical_info: nil
)
case .cannotFormRelayListEvent:
ErrorView.UserPresentableError(
user_visible_description: NSLocalizedString("There was a problem creating the relay list event.", comment: "Human readable error description"),
tip: NSLocalizedString("Please try again later or contact support if the issue persists.", comment: "Human readable tip for error"),
technical_info: "Failed forming Nostr event for the relay list update."
)
case .relayAlreadyExists:
ErrorView.UserPresentableError(
user_visible_description: NSLocalizedString("This relay is already in your list.", comment: "Human readable tip for error"),
tip: NSLocalizedString("Check the address and/or the relay list.", comment: "Human readable tip for error"),
technical_info: nil
)
case .noInitialRelayList:
ErrorView.UserPresentableError(
user_visible_description: NSLocalizedString("No initial relay list available to update.", comment: "Human readable error description"),
tip: NSLocalizedString("Please go to Settings > First Aid > Repair relay list, or contact support.", comment: "Human readable tip for error"),
technical_info: "Missing initial relay list data for reference during update."
)
case .noSuchRelay:
ErrorView.UserPresentableError(
user_visible_description: NSLocalizedString("The specified relay that you are trying to udpate was not found in your relay list.", comment: "Human readable error description"),
tip: NSLocalizedString("This is an unexpected error, please contact support.", comment: "Human readable tip for error"),
technical_info: nil
)
}
}
}
enum LoadingError: Error {
case relayListParseError
var humanReadableError: ErrorView.UserPresentableError {
switch self {
case .relayListParseError:
return ErrorView.UserPresentableError(
user_visible_description: NSLocalizedString("Your relay list appears to be broken, so we cannot connect you to your Nostr network.", comment: "Human readable error description for a failure to parse the relay list due to a bad relay list"),
tip: NSLocalizedString("Please contact support for further help.", comment: "Human readable tips for what to do for a failure to find the relay list"),
technical_info: "Relay list could not be parsed."
)
}
}
}
}
@@ -0,0 +1,311 @@
//
// UserRelayListManager.swift
// damus
//
// Created by Daniel DAquino on 2025-02-27.
//
import Foundation
import Combine
extension NostrNetworkManager {
/// Manages the user's relay list
///
/// - It can compute the user's current relay list
/// - It can compute the best relay list to connect to
/// - It can edit the user's relay list
class UserRelayListManager {
private var delegate: Delegate
private let pool: RelayPool
private let reader: SubscriptionManager
private var relayListObserverTask: Task<Void, Never>? = nil
private var walletUpdatesObserverTask: AnyCancellable? = nil
init(delegate: Delegate, pool: RelayPool, reader: SubscriptionManager) {
self.delegate = delegate
self.pool = pool
self.reader = reader
}
// MARK: - Computing the relays to connect to
private func relaysToConnectTo() -> [RelayPool.RelayDescriptor] {
return self.computeRelaysToConnectTo(with: self.getBestEffortRelayList())
}
private func computeRelaysToConnectTo(with relayList: NIP65.RelayList) -> [RelayPool.RelayDescriptor] {
let regularRelayDescriptorList = relayList.toRelayDescriptors()
if let nwcWallet = delegate.nwcWallet {
return regularRelayDescriptorList + [.nwc(url: nwcWallet.relay)]
}
return regularRelayDescriptorList
}
// MARK: - Getting the user's relay list
/// Gets the "best effort" relay list.
///
/// It attempts to get a relay list from the user. If one is not available, it uses the default bootstrap list.
///
/// This is always guaranteed to return a relay list.
func getBestEffortRelayList() -> NIP65.RelayList {
guard let userCurrentRelayList = self.getUserCurrentRelayList() else {
return NIP65.RelayList(relays: delegate.bootstrapRelays)
}
return userCurrentRelayList
}
/// Gets the user's current relay list.
///
/// It attempts to get a NIP-65 relay list from the local database, or falls back to a legacy list.
func getUserCurrentRelayList() -> NIP65.RelayList? {
if let latestRelayListEvent = try? self.getLatestNIP65RelayList() { return latestRelayListEvent }
if let latestRelayListEvent = try? self.getLatestKind3RelayList() { return latestRelayListEvent }
if let latestRelayListEvent = try? self.getLatestUserDefaultsRelayList() { return latestRelayListEvent }
return nil
}
/// Gets the latest NIP-65 relay list from NostrDB.
///
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
///
/// - Returns: The latest NIP-65 relay list object
private func getLatestNIP65RelayList() throws(LoadingError) -> NIP65.RelayList? {
guard let latestRelayListEvent = self.getLatestNIP65RelayListEvent() else { return nil }
guard let list = try? NIP65.RelayList(event: latestRelayListEvent) else { throw .relayListParseError }
return list
}
/// Gets the latest NIP-65 relay list event from NostrDB.
///
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
///
/// It is recommended to use this function only if the NostrEvent metadata is needed. For cases where only the relay list info is needed, use `getLatestNIP65RelayList` instead.
///
/// - Returns: The latest NIP-65 relay list NdbNote
private func getLatestNIP65RelayListEvent() -> NdbNote? {
guard let latestRelayListEventId = delegate.latestRelayListEventIdHex else { return nil }
guard let latestRelayListEventId = NoteId(hex: latestRelayListEventId) else { return nil }
return delegate.ndb.lookup_note(latestRelayListEventId)?.unsafeUnownedValue?.to_owned()
}
/// 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.
private func getLatestKind3RelayList() throws(LoadingError) -> NIP65.RelayList? {
guard let latestContactListEvent = delegate.latestContactListEvent else { return nil }
guard let legacyContactList = try? NIP65.RelayList.fromLegacyContactList(latestContactListEvent) else { throw .relayListParseError }
return legacyContactList
}
/// Gets the latest relay list from `UserDefaults`
///
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
private func getLatestUserDefaultsRelayList() throws(LoadingError) -> NIP65.RelayList? {
let key = bootstrap_relays_setting_key(pubkey: delegate.keypair.pubkey)
guard let relays = UserDefaults.standard.stringArray(forKey: key) else { return nil }
let relayUrls = relays.compactMap({ RelayURL($0) })
if relayUrls.count == 0 { return nil }
return NIP65.RelayList(relays: relayUrls)
}
// MARK: - Getting metadata from the user's relay list
/// Gets the creation date of the user's current relay list, with preference to NIP-65 relay lists
/// - Returns: The current relay list's creation date
private func getUserCurrentRelayListCreationDate() -> UInt32? {
if let latestNIP65RelayListEvent = self.getLatestNIP65RelayListEvent() { return latestNIP65RelayListEvent.created_at }
if let latestKind3RelayListEvent = delegate.latestContactListEvent { return latestKind3RelayListEvent.created_at }
return nil
}
// MARK: - Listening to and handling relay updates from the network
func connect() {
self.load()
self.relayListObserverTask?.cancel()
self.relayListObserverTask = Task { await self.listenAndHandleRelayUpdates() }
self.walletUpdatesObserverTask?.cancel()
self.walletUpdatesObserverTask = handle_notify(.attached_wallet).sink { _ in 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
}
}
}
// 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 }
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)))
}
func insert(relay: NIP65.RelayList.RelayItem, force: Bool = false) throws(UpdateError) {
guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
guard currentUserRelayList.relays[relay.url] == nil else { throw .relayAlreadyExists }
try 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 }
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)))
}
func set(userRelayList: NIP65.RelayList) 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))
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())
}
/// Loads a new relay list into the active relay pool, making sure it matches the specified relay list.
///
/// - Parameters:
/// - state: The state of the app
/// - newRelayList: The new relay list to be applied
///
///
/// ## Implementation notes
///
/// - This is `private` because syncing the user's saved relay list with the relay pool is `NostrNetworkManager`'s responsibility,
/// so we do not want other classes to forcibly load this.
private func apply(newRelayList: [RelayPool.RelayDescriptor]) {
let currentRelayList = self.pool.relays.map({ $0.descriptor })
var changed = false
let new_relay_filters = load_relay_filters(delegate.keypair.pubkey) == nil
for index in self.pool.relays.indices {
guard let newDescriptor = newRelayList.first(where: { $0.url == self.pool.relays[index].descriptor.url }) else { continue }
self.pool.relays[index].descriptor.info = newDescriptor.info
// Relay read-write configuration change does not need reconnection to the relay, so we do not set the `changed` flag.
}
// Working with URL Sets for difference analysis
let currentRelayURLs = Set(currentRelayList.map { $0.url })
let newRelayURLs = Set(newRelayList.map { $0.url })
// Analyzing which relays to add or remove
let relaysToRemove = currentRelayURLs.subtracting(newRelayURLs)
let relaysToAdd = newRelayURLs.subtracting(currentRelayURLs)
// Remove relays not in the new list
relaysToRemove.forEach { url in
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
}
if changed {
pool.connect()
notify(.relays_changed)
}
}
}
}
// MARK: - Helper extensions
fileprivate extension NIP65.RelayList.RelayItem {
func toRelayDescriptor() -> RelayPool.RelayDescriptor {
return RelayPool.RelayDescriptor(url: self.url, info: self.rwConfiguration, variant: .regular) // NIP-65 relays are regular by definition.
}
}
fileprivate extension NIP65.RelayList {
func toRelayDescriptors() -> [RelayPool.RelayDescriptor] {
return self.relays.values.map({ $0.toRelayDescriptor() })
}
}
// MARK: - Helper functions
/// Adds a new relay, taking care of other tangential concerns, such as updating the relay model cache, configuring logging, etc
///
/// ## Implementation notes
///
/// 1. This function used to be in `HomeModel.swift` and moved here when `UserRelayListManager` was first implemented
/// 2. This is `fileprivate` because only `UserRelayListManager` should be able to manage the user's relay list and apply them to the `RelayPool`
///
/// - Parameters:
/// - model_cache: The relay model cache, that keeps metadata cached
/// - relay_filters: Relay filters
/// - pool: The relay pool to add this in
/// - descriptor: The description of the relay being added
/// - new_relay_filters: Whether to insert new relay filters
/// - logging_enabled: Whether logging is enabled
fileprivate func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayPool.RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) {
try? pool.add_relay(descriptor)
let url = descriptor.url
let relay_id = url
guard model_cache.model(withURL: url) == nil else {
return
}
Task.detached(priority: .background) {
guard let meta = try? await fetch_relay_metadata(relay_id: relay_id) else {
return
}
await MainActor.run {
let model = RelayModel(url, metadata: meta)
model_cache.insert(model: model)
if logging_enabled {
pool.setLog(model.log, for: relay_id)
}
// if this is the first time adding filters, we should filter non-paid relays
if new_relay_filters && !meta.is_paid {
relay_filters.insert(timeline: .search, relay_id: relay_id)
}
}
}
}
+138 -60
View File
@@ -73,85 +73,156 @@ func render_note_content(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -
return .longform(LongformContent(ev.content))
}
return .separated(render_blocks(blocks: blocks, profiles: profiles))
return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
}
func render_blocks(blocks bs: Blocks, profiles: Profiles) -> NoteArtifactsSeparated {
// FIXME(tyiu): There are a lot of hacks to get this function to render the blocks correctly.
// However, the entire note content rendering logic just needs to be rewritten.
// Block previews should actually be rendered in the position of the note content where it was found.
// Currently, we put some previews at the bottom of the note, which is incorrect as they take things out of
// the author's intended context.
func render_blocks(blocks bs: Blocks, profiles: Profiles, can_hide_last_previewable_refs: Bool = false) -> NoteArtifactsSeparated {
var invoices: [Invoice] = []
var urls: [UrlType] = []
let blocks = bs.blocks
let one_note_ref = blocks
.filter({
if case .mention(let mention) = $0,
case .note = mention.ref {
return true
var end_mention_count = 0
var end_url_count = 0
// Search backwards until we find the beginning index of the chain of previewables that reach the end of the content.
var hide_text_index = blocks.endIndex
if can_hide_last_previewable_refs {
outerLoop: for (i, block) in blocks.enumerated().reversed() {
if block.is_previewable {
switch block {
case .mention:
end_mention_count += 1
// If there is more than one previewable mention,
// do not hide anything because we allow rich rendering of only one mention currently.
// This should be fixed in the future to show events inline instead.
if end_mention_count > 1 {
hide_text_index = blocks.endIndex
break outerLoop
}
case .url(let url):
let url_type = classify_url(url)
if case .link = url_type {
end_url_count += 1
// If there is more than one link, do not hide anything because we allow rich rendering of only
// one link.
if end_url_count > 1 {
hide_text_index = blocks.endIndex
break outerLoop
}
}
default:
break
}
hide_text_index = i
} else if case .text(let txt) = block, txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
// We should hide whitespace at the end sequence.
hide_text_index = i
} else if case .hashtag = block {
// SPECIAL CASE:
// We should keep hashtags at the end sequence but hide all the other previewables around it.
hide_text_index = i
} else {
break
}
else {
return false
}
})
.count == 1
}
}
var ind: Int = -1
let txt: CompatibleText = blocks.reduce(CompatibleText()) { str, block in
ind = ind + 1
// Add the rendered previewable blocks to their type-specific lists.
switch block {
case .mention(let m):
if case .note = m.ref, one_note_ref {
case .invoice(let invoice):
invoices.append(invoice)
case .url(let url):
let url_type = classify_url(url)
urls.append(url_type)
default:
break
}
if can_hide_last_previewable_refs {
// If there are previewable blocks that occur before the consecutive sequence of them at the end of the content,
// we should not hide the text representation of any previewable block to avoid altering the format of the note.
if ind < hide_text_index && block.is_previewable {
hide_text_index = blocks.endIndex
}
// No need to show the text representation of the block if the only previewables are the sequence of them
// found at the end of the content.
// This is to save unnecessary use of screen space.
// The only exception is that if there are hashtags embedded in the end sequence, which is not uncommon,
// then we still want to show those hashtags but hide everything else that is previewable in the end sequence.
if ind >= hide_text_index {
if case .text(let txt) = block, txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if case .hashtag = blocks[safe: ind+1] {
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: -1, txt: txt))
}
} else if case .hashtag(let htag) = block {
return str + hashtag_str(htag)
}
return str
}
}
switch block {
case .mention(let m):
return str + mention_str(m, profiles: profiles)
case .text(let txt):
return str + CompatibleText(stringLiteral: reduce_text_block(blocks: blocks, ind: ind, txt: txt, one_note_ref: one_note_ref))
if case .hashtag = blocks[safe: ind+1] {
// SPECIAL CASE:
// Do not trim whitespaces from suffix if the following block is a hashtag.
// This is because of the code further up (see "SPECIAL CASE").
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: -1, txt: txt))
} else {
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: hide_text_index, txt: txt))
}
case .relay(let relay):
return str + CompatibleText(stringLiteral: relay)
case .hashtag(let htag):
return str + hashtag_str(htag)
case .invoice(let invoice):
invoices.append(invoice)
return str
return str + invoice_str(invoice)
case .url(let url):
let url_type = classify_url(url)
switch url_type {
case .media:
urls.append(url_type)
return str
case .link(let url):
urls.append(url_type)
return str + url_str(url)
}
return str + url_str(url)
}
}
return NoteArtifactsSeparated(content: txt, words: bs.words, urls: urls, invoices: invoices)
}
func reduce_text_block(blocks: [Block], ind: Int, txt: String, one_note_ref: Bool) -> String {
func reduce_text_block(ind: Int, hide_text_index: Int, txt: String) -> String {
var trimmed = txt
if let prev = blocks[safe: ind-1],
case .url(let u) = prev,
classify_url(u).is_media != nil {
trimmed = " " + trim_prefix(trimmed)
// Trim leading whitespaces.
if ind == 0 {
trimmed = trim_prefix(trimmed)
}
if let next = blocks[safe: ind+1] {
if case .url(let u) = next, classify_url(u).is_media != nil {
trimmed = trim_suffix(trimmed)
} else if case .mention(let m) = next,
case .note = m.ref,
one_note_ref {
trimmed = trim_suffix(trimmed)
}
// Trim trailing whitespaces if the following blocks will be hidden or if this is the last block.
if ind == hide_text_index - 1 {
trimmed = trim_suffix(trimmed)
}
return trimmed
}
func invoice_str(_ invoice: Invoice) -> CompatibleText {
var attributedString = AttributedString(stringLiteral: abbrev_identifier(invoice.string))
attributedString.link = URL(string: "damus:lightning:\(invoice.string)")
attributedString.foregroundColor = DamusColors.purple
return CompatibleText(attributed: attributedString)
}
func url_str(_ url: URL) -> CompatibleText {
var attributedString = AttributedString(stringLiteral: url.absoluteString)
attributedString.link = url
@@ -161,17 +232,16 @@ func url_str(_ url: URL) -> CompatibleText {
}
func classify_url(_ url: URL) -> UrlType {
let str = url.lastPathComponent.lowercased()
if str.hasSuffix(".png") || str.hasSuffix(".jpg") || str.hasSuffix(".jpeg") || str.hasSuffix(".gif") || str.hasSuffix(".webp") {
let fileExtension = url.lastPathComponent.lowercased().components(separatedBy: ".").last
switch fileExtension {
case "png", "jpg", "jpeg", "gif", "webp":
return .media(.image(url))
}
if str.hasSuffix(".mp4") || str.hasSuffix(".mov") || str.hasSuffix(".m3u8") {
case "mp4", "mov", "m3u8":
return .media(.video(url))
default:
return .link(url)
}
return .link(url)
}
func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) {
@@ -194,11 +264,11 @@ func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText
let display_str: String = {
switch m.ref {
case .pubkey(let pk): return getDisplayName(pk: pk, profiles: profiles)
case .note: return abbrev_pubkey(bech32String)
case .nevent: return abbrev_pubkey(bech32String)
case .note: return abbrev_identifier(bech32String)
case .nevent: return abbrev_identifier(bech32String)
case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles)
case .nrelay(let url): return url
case .naddr: return abbrev_pubkey(bech32String)
case .naddr: return abbrev_identifier(bech32String)
}
}()
@@ -213,12 +283,20 @@ func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText
// trim suffix whitespace and newlines
func trim_suffix(_ str: String) -> String {
return str.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression)
var result = str
while result.last?.isWhitespace == true {
result.removeLast()
}
return result
}
// trim prefix whitespace and newlines
func trim_prefix(_ str: String) -> String {
return str.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
var result = str
while result.first?.isWhitespace == true {
result.removeFirst()
}
return result
}
struct LongformContent {
+12 -1
View File
@@ -41,6 +41,10 @@ func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent
return false
}
if state.settings.hellthread_notifications_disabled && ev.is_hellthread(max_pubkeys: state.settings.hellthread_notification_max_pubkeys) {
return false
}
// Don't show notifications that match mute list.
if state.mutelist_manager.is_event_muted(ev) {
return false
@@ -50,7 +54,14 @@ func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent
guard ev.age < EVENT_MAX_AGE_FOR_NOTIFICATION else {
return false
}
// Don't show notifications for future events.
// Allow notes that are created no more than 3 seconds in the future
// to account for natural clock skew between sender and receiver.
guard ev.age >= -3 else {
return false
}
return true
}
+26 -14
View File
@@ -10,10 +10,18 @@ import Foundation
class ProfileModel: ObservableObject, Equatable {
@Published var contacts: NostrEvent? = nil
@Published var following: Int = 0
@Published var relays: [RelayURL: RelayInfo]? = nil
@Published var relay_list: NIP65.RelayList? = nil
@Published var legacy_relay_list: [RelayURL: LegacyKind3RelayRWConfiguration]? = nil
@Published var progress: Int = 0
private let MAX_SHARE_RELAYS = 4
var relay_urls: [RelayURL]? {
if let relay_list {
return relay_list.relays.values.map({ $0.url })
}
if let legacy_relay_list {
return Array(legacy_relay_list.keys)
}
return nil
}
var events: EventHolder
let pubkey: Pubkey
@@ -59,16 +67,17 @@ class ProfileModel: ObservableObject, Equatable {
func unsubscribe() {
print("unsubscribing from profile \(pubkey) with sub_id \(sub_id)")
damus.pool.unsubscribe(sub_id: sub_id)
damus.pool.unsubscribe(sub_id: prof_subid)
damus.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
damus.nostrNetwork.pool.unsubscribe(sub_id: prof_subid)
if pubkey != damus.pubkey {
damus.pool.unsubscribe(sub_id: conversations_subid)
damus.nostrNetwork.pool.unsubscribe(sub_id: conversations_subid)
}
}
func subscribe() {
var text_filter = NostrFilter(kinds: [.text, .longform, .highlight])
var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost])
var relay_list_filter = NostrFilter(kinds: [.relay_list], authors: [pubkey])
profile_filter.authors = [pubkey]
@@ -77,8 +86,8 @@ class ProfileModel: ObservableObject, Equatable {
print("subscribing to textlike events from profile \(pubkey) with sub_id \(sub_id)")
//print_filters(relay_id: "profile", filters: [[text_filter], [profile_filter]])
damus.pool.subscribe(sub_id: sub_id, filters: [text_filter], handler: handle_event)
damus.pool.subscribe(sub_id: prof_subid, filters: [profile_filter], handler: handle_event)
damus.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [text_filter], handler: handle_event)
damus.nostrNetwork.pool.subscribe(sub_id: prof_subid, filters: [profile_filter, relay_list_filter], handler: handle_event)
subscribe_to_conversations()
}
@@ -94,7 +103,7 @@ class ProfileModel: ObservableObject, Equatable {
let conversations_filter_them = NostrFilter(kinds: conversation_kinds, pubkeys: [damus.pubkey], limit: limit, authors: [pubkey])
let conversations_filter_us = NostrFilter(kinds: conversation_kinds, pubkeys: [pubkey], limit: limit, authors: [damus.pubkey])
print("subscribing to conversation events from and to profile \(pubkey) with sub_id \(conversations_subid)")
damus.pool.subscribe(sub_id: conversations_subid, filters: [conversations_filter_them, conversations_filter_us], handler: handle_event)
damus.nostrNetwork.pool.subscribe(sub_id: conversations_subid, filters: [conversations_filter_them, conversations_filter_us], handler: handle_event)
}
func handle_profile_contact_event(_ ev: NostrEvent) {
@@ -109,7 +118,7 @@ class ProfileModel: ObservableObject, Equatable {
self.contacts = ev
self.following = count_pubkeys(ev.tags)
self.relays = decode_json_relays(ev.content)
self.legacy_relay_list = decode_json_relays(ev.content)
}
private func add_event(_ ev: NostrEvent) {
@@ -120,6 +129,9 @@ class ProfileModel: ObservableObject, Equatable {
} else if ev.known_kind == .contacts {
handle_profile_contact_event(ev)
}
else if ev.known_kind == .relay_list {
self.relay_list = try? NIP65.RelayList(event: ev) // Whether another user's list is malformatted is something beyond our control. Probably best to suppress errors
}
seen_event.insert(ev.id)
}
@@ -192,7 +204,7 @@ class ProfileModel: ObservableObject, Equatable {
private func findRelaysHandler(relay_id: RelayURL, ev: NostrConnectionEvent) {
if case .nostr_event(let resp) = ev, case .event(_, let event) = resp, case .contacts = event.known_kind {
self.relays = decode_json_relays(event.content)
self.legacy_relay_list = decode_json_relays(event.content)
}
}
@@ -200,15 +212,15 @@ class ProfileModel: ObservableObject, Equatable {
var profile_filter = NostrFilter(kinds: [.contacts])
profile_filter.authors = [pubkey]
damus.pool.subscribe(sub_id: findRelay_subid, filters: [profile_filter], handler: findRelaysHandler)
damus.nostrNetwork.pool.subscribe(sub_id: findRelay_subid, filters: [profile_filter], handler: findRelaysHandler)
}
func unsubscribeFindRelays() {
damus.pool.unsubscribe(sub_id: findRelay_subid)
damus.nostrNetwork.pool.unsubscribe(sub_id: findRelay_subid)
}
func getCappedRelayStrings() -> [String] {
return relays?.keys.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
return self.relay_urls?.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
}
}
+34 -8
View File
@@ -7,6 +7,12 @@
import Foundation
// Minimum threshold the hellthread pubkey tag count setting can go down to.
let HELLTHREAD_MIN_PUBKEYS: Int = 6
// Maximum threshold the hellthread pubkey tag count setting can go up to.
let HELLTHREAD_MAX_PUBKEYS: Int = 24
struct PushNotificationClient {
let keypair: Keypair
let settings: UserSettingsStore
@@ -175,15 +181,33 @@ extension PushNotificationClient {
}
struct NotificationSettings: Codable, Equatable {
let zap_notifications_enabled: Bool
let mention_notifications_enabled: Bool
let repost_notifications_enabled: Bool
let reaction_notifications_enabled: Bool
let dm_notifications_enabled: Bool
let only_notifications_from_following_enabled: Bool
let zap_notifications_enabled: Bool?
let mention_notifications_enabled: Bool?
let repost_notifications_enabled: Bool?
let reaction_notifications_enabled: Bool?
let dm_notifications_enabled: Bool?
let only_notifications_from_following_enabled: Bool?
let hellthread_notifications_disabled: Bool?
let hellthread_notifications_max_pubkeys: Int?
static func from(json_data: Data) -> Self? {
guard let decoded = try? JSONDecoder().decode(Self.self, from: json_data) else { return nil }
// Normalize hellthread_notifications_max_pubkeys in case
// it goes beyond the expected range supported on the client.
if let max_pubkeys = decoded.hellthread_notifications_max_pubkeys, max_pubkeys < HELLTHREAD_MIN_PUBKEYS || max_pubkeys > HELLTHREAD_MAX_PUBKEYS {
return NotificationSettings(
zap_notifications_enabled: decoded.zap_notifications_enabled,
mention_notifications_enabled: decoded.mention_notifications_enabled,
repost_notifications_enabled: decoded.repost_notifications_enabled,
reaction_notifications_enabled: decoded.reaction_notifications_enabled,
dm_notifications_enabled: decoded.dm_notifications_enabled,
only_notifications_from_following_enabled: decoded.only_notifications_from_following_enabled,
hellthread_notifications_disabled: decoded.hellthread_notifications_disabled,
hellthread_notifications_max_pubkeys: max(min(HELLTHREAD_MAX_PUBKEYS, max_pubkeys), HELLTHREAD_MIN_PUBKEYS)
)
}
return decoded
}
@@ -194,7 +218,9 @@ extension PushNotificationClient {
repost_notifications_enabled: settings.repost_notification,
reaction_notifications_enabled: settings.like_notification,
dm_notifications_enabled: settings.dm_notification,
only_notifications_from_following_enabled: settings.notification_only_from_following
only_notifications_from_following_enabled: settings.notification_only_from_following,
hellthread_notifications_disabled: settings.hellthread_notifications_disabled,
hellthread_notifications_max_pubkeys: settings.hellthread_notification_max_pubkeys
)
}
+13 -7
View File
@@ -1,4 +1,3 @@
//
// SearchHomeModel.swift
// damus
//
@@ -16,6 +15,7 @@ class SearchHomeModel: ObservableObject {
var seen_pubkey: Set<Pubkey> = Set()
let damus_state: DamusState
let base_subid = UUID().description
let follow_pack_subid = UUID().description
let profiles_subid = UUID().description
let limit: UInt32 = 500
//let multiple_events_per_pubkey: Bool = false
@@ -41,13 +41,19 @@ class SearchHomeModel: ObservableObject {
func subscribe() {
loading = true
let to_relays = determine_to_relays(pool: damus_state.pool, filters: damus_state.relay_filters)
damus_state.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays)
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
var follow_list_filter = NostrFilter(kinds: [.follow_list])
follow_list_filter.until = UInt32(Date.now.timeIntervalSince1970)
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays)
damus_state.nostrNetwork.pool.subscribe(sub_id: follow_pack_subid, filters: [follow_list_filter], handler: handle_event, to: to_relays)
}
func unsubscribe(to: RelayURL? = nil) {
loading = false
damus_state.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
damus_state.nostrNetwork.pool.unsubscribe(sub_id: follow_pack_subid, to: to.map { [$0] })
}
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
@@ -57,7 +63,7 @@ class SearchHomeModel: ObservableObject {
switch event {
case .event(let sub_id, let ev):
guard sub_id == self.base_subid || sub_id == self.profiles_subid else {
guard sub_id == self.base_subid || sub_id == self.profiles_subid || sub_id == self.follow_pack_subid else {
return
}
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
@@ -140,7 +146,7 @@ func load_profiles<Y>(context: String, profiles_subid: String, relay_id: RelayUR
let filter = NostrFilter(kinds: [.metadata], authors: authors)
damus_state.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in
damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in
let now = UInt64(Date.now.timeIntervalSince1970)
switch conn_ev {
@@ -156,7 +162,7 @@ func load_profiles<Y>(context: String, profiles_subid: String, relay_id: RelayUR
}
case .eose:
print("load_profiles[\(context)]: done loading \(authors.count) profiles from \(relay_id)")
damus_state.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id])
damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id])
case .ok:
break
case .notice:
+5 -5
View File
@@ -36,18 +36,18 @@ class SearchModel: ObservableObject {
func subscribe() {
// since 1 month
search.limit = self.limit
search.kinds = [.text, .like, .longform, .highlight]
search.kinds = [.text, .like, .longform, .highlight, .follow_list]
//likes_filter.ids = ref_events.referenced_ids!
print("subscribing to search '\(search)' with sub_id \(sub_id)")
state.pool.register_handler(sub_id: sub_id, handler: handle_event)
state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: handle_event)
loading = true
state.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
state.nostrNetwork.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
}
func unsubscribe() {
state.pool.unsubscribe(sub_id: sub_id)
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
loading = false
print("unsubscribing from search '\(search)' with sub_id \(sub_id)")
}
@@ -67,7 +67,7 @@ class SearchModel: ObservableObject {
}
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
let (sub_id, done) = handle_subid_event(pool: state.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
let (sub_id, done) = handle_subid_event(pool: state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
if ev.is_textlike && ev.should_show_event {
self.add_event(ev)
}
+9 -9
View File
@@ -88,12 +88,12 @@ class ThreadModel: ObservableObject {
/// Unsubscribe from events in the relay pool. Call this when unloading the view
func unsubscribe() {
self.damus_state.pool.remove_handler(sub_id: base_subid)
self.damus_state.pool.remove_handler(sub_id: meta_subid)
self.damus_state.pool.remove_handler(sub_id: profiles_subid)
self.damus_state.pool.unsubscribe(sub_id: base_subid)
self.damus_state.pool.unsubscribe(sub_id: meta_subid)
self.damus_state.pool.unsubscribe(sub_id: profiles_subid)
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: base_subid)
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: meta_subid)
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: profiles_subid)
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid)
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: meta_subid)
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid)
Log.info("unsubscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
}
@@ -129,8 +129,8 @@ class ThreadModel: ObservableObject {
let meta_filters = [meta_events, quote_events]
Log.info("subscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
damus_state.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event)
damus_state.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event)
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event)
damus_state.nostrNetwork.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event)
}
/// Adds an event to this thread.
@@ -176,7 +176,7 @@ class ThreadModel: ObservableObject {
/// Marked as private because it is this class' responsibility to load events, not the view's. Simplify the interface
@MainActor
private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
let (sub_id, done) = handle_subid_event(pool: damus_state.pool, relay_id: relay_id, ev: ev) { sid, ev in
let (sub_id, done) = handle_subid_event(pool: damus_state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sid, ev in
guard subids.contains(sid) else {
return
}
+15
View File
@@ -43,6 +43,15 @@ struct DamusURLHandler {
return .route(.Script(script: model))
case .purple(let purple_url):
return await damus_state.purple.handle(purple_url: purple_url)
case .invoice(let invoice):
if damus_state.settings.show_wallet_selector {
return .sheet(.select_wallet(invoice: invoice.string))
} else {
guard let url = try? getUrlToOpen(invoice: invoice.string, with: damus_state.settings.default_wallet.model) else {
return .sheet(.select_wallet(invoice: invoice.string))
}
return .external_url(url)
}
case nil:
break
}
@@ -91,6 +100,11 @@ struct DamusURLHandler {
return .filter(filt)
case .script(let script):
return .script(script)
case .invoice(let bolt11):
if let invoice = decode_bolt11(bolt11) {
return .invoice(invoice)
}
return nil
}
return nil
}
@@ -103,5 +117,6 @@ struct DamusURLHandler {
case wallet_connect(WalletConnectURL)
case script([UInt8])
case purple(DamusPurpleURL)
case invoice(Invoice)
}
}
+33 -4
View File
@@ -113,6 +113,12 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "show_wallet_selector", default_value: false)
var show_wallet_selector: Bool
@Setting(key: "dismiss_wallet_high_balance_warning", default_value: false)
var dismiss_wallet_high_balance_warning: Bool
@Setting(key: "hide_wallet_balance", default_value: false)
var hide_wallet_balance: Bool
@Setting(key: "left_handed", default_value: false)
var left_handed: Bool
@@ -121,10 +127,19 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "media_previews", default_value: true)
var media_previews: Bool
@Setting(key: "show_trusted_replies_first", default_value: true)
var show_trusted_replies_first: Bool
@Setting(key: "reset_tips_on_launch", default_value: false)
var reset_tips_on_launch: Bool
@Setting(key: "hide_nsfw_tagged_content", default_value: false)
var hide_nsfw_tagged_content: Bool
@Setting(key: "reduce_bitcoin_content", default_value: false)
var reduce_bitcoin_content: Bool
@Setting(key: "show_profile_action_sheet_on_pfp_click", default_value: true)
var show_profile_action_sheet_on_pfp_click: Bool
@@ -160,7 +175,13 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "notification_only_from_following", default_value: false)
var notification_only_from_following: Bool
@Setting(key: "hellthread_notifications_disabled", default_value: false)
var hellthread_notifications_disabled: Bool
@Setting(key: "hellthread_notification_max_pubkeys", default_value: DEFAULT_HELLTHREAD_MAX_PUBKEYS)
var hellthread_notification_max_pubkeys: Int
@Setting(key: "translate_dms", default_value: false)
var translate_dms: Bool
@@ -168,8 +189,12 @@ class UserSettingsStore: ObservableObject {
var truncate_timeline_text: Bool
/// Nozaps mode gimps note zapping to fit into apple's content-tipping guidelines. It can not be configurable to end-users on the app store
@Setting(key: "nozaps", default_value: true)
var nozaps: Bool
///
/// Update 2025-05-12: This can be re-enabled 🥳. See https://github.com/damus-io/damus/issues/3016
// @Setting(key: "nozaps", default_value: true)
var nozaps: Bool {
return false
}
@Setting(key: "truncate_mention_text", default_value: true)
var truncate_mention_text: Bool
@@ -336,6 +361,10 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "draft_event_ids", default_value: nil)
var draft_event_ids: [String]?
// TODO: Get rid of this once we have NostrDB query capabilities integrated
@Setting(key: "latest_relay_list_event_id", default_value: nil)
var latestRelayListEventIdHex: String?
// MARK: Helper types
enum NotificationsMode: String, CaseIterable, Identifiable, StringCodable, Equatable {
+4 -2
View File
@@ -83,8 +83,10 @@ enum Wallet: String, CaseIterable, Identifiable, StringCodable {
return .init(index: 9, tag: "breez", displayName: "Breez", link: "breez:",
appStoreLink: "https://apps.apple.com/us/app/breez-lightning-client-pos/id1463604142", image: "breez")
case .bitcoinbeach:
return .init(index: 10, tag: "bitcoinbeach", displayName: "Bitcoin Beach", link: "bitcoinbeach://",
appStoreLink: "https://apps.apple.com/sv/app/bitcoin-beach-wallet/id1531383905", image: "bbw")
// Blink used to be called Bitcoin Beach.
// We have to keep the tag called "bitcoinbeach" for backwards compatibility.
return .init(index: 10, tag: "bitcoinbeach", displayName: "Blink", link: "blink://",
appStoreLink: "https://apps.apple.com/app/blink-bitcoin-wallet/id1531383905", image: "blink")
case .blixtwallet:
return .init(index: 11, tag: "blixtwallet", displayName: "Blixt Wallet", link: "blixtwallet:lightning:",
appStoreLink: "https://testflight.apple.com/join/EXvGhRzS", image: "blixt-wallet")
+50 -4
View File
@@ -27,6 +27,11 @@ class WalletModel: ObservableObject {
@Published private(set) var connect_state: WalletConnectState
/// A dictionary listing continuations waiting for a response for each request note id.
///
/// Please see the `waitForResponse` method for context.
private var continuations: [NoteId: CheckedContinuation<WalletConnect.Response.Result, any Error>] = [:]
init(state: WalletConnectState, settings: UserSettingsStore) {
self.connect_state = state
self.previous_state = .none
@@ -75,12 +80,16 @@ class WalletModel: ObservableObject {
///
/// - Parameter response: The NWC response received from the network
func handle_nwc_response(response: WalletConnect.FullWalletResponse) {
switch response.response.result {
if let error = response.response.error {
self.resume(request: response.req_id, throwing: error)
return
}
guard let result = response.response.result else { return }
self.resume(request: response.req_id, with: result)
switch result {
case .get_balance(let balanceResp):
self.balance = balanceResp.balance / 1000
case .none:
return
case .some(.pay_invoice(_)):
case .pay_invoice(_):
return
case .list_transactions(let transactionsResp):
self.transactions = transactionsResp.transactions
@@ -91,4 +100,41 @@ class WalletModel: ObservableObject {
self.transactions = nil
self.balance = nil
}
// MARK: - Async wallet response waiting mechanism
func waitForResponse(for requestId: NoteId, timeout: Duration = .seconds(10)) async throws -> WalletConnect.Response.Result {
return try await withCheckedThrowingContinuation({ continuation in
self.continuations[requestId] = continuation
let timeoutTask = Task {
try? await Task.sleep(for: timeout)
self.resume(request: requestId, throwing: WaitError.timeout) // Must resume the continuation exactly once even if there is no response
}
})
}
private func resume(request requestId: NoteId, with result: WalletConnect.Response.Result) {
continuations[requestId]?.resume(returning: result)
continuations[requestId] = nil // Never resume a continuation twice
}
private func resume(request requestId: NoteId, throwing error: any Error) {
if let continuation = continuations[requestId] {
continuation.resume(throwing: error)
continuations[requestId] = nil // Never resume a continuation twice
return // Error will be handled by the listener, no need for the generic error sheet
}
// No listeners to catch the error, show generic error sheet
if let error = error as? WalletConnect.WalletResponseErr,
let humanReadableError = error.humanReadableError {
present_sheet(.error(humanReadableError))
}
}
enum WaitError: Error {
case timeout
}
}
+2 -2
View File
@@ -31,11 +31,11 @@ class ZapsModel: ObservableObject {
case .note(let note_target):
filter.referenced_ids = [note_target.note_id]
}
state.pool.subscribe(sub_id: zaps_subid, filters: [filter], handler: handle_event)
state.nostrNetwork.pool.subscribe(sub_id: zaps_subid, filters: [filter], handler: handle_event)
}
func unsubscribe() {
state.pool.unsubscribe(sub_id: zaps_subid)
state.nostrNetwork.pool.unsubscribe(sub_id: zaps_subid)
}
@MainActor
+24
View File
@@ -52,4 +52,28 @@ extension NIP04 {
return create_encrypted_event(message, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created, kind: 4)
}
/// Decrypts string content
static func decryptContent(recipientPrivateKey: Privkey, senderPubkey: Pubkey, content: String, encoding: EncEncoding) throws(NIP04DecryptionError) -> String {
guard let shared_sec = get_shared_secret(privkey: recipientPrivateKey, pubkey: senderPubkey) else {
throw .failedToComputeSharedSecret
}
guard let dat = (encoding == .base64 ? decode_dm_base64(content) : decode_dm_bech32(content)) else {
throw .failedToDecodeEncryptedContent
}
guard let dat = aes_decrypt(data: dat.content, iv: dat.iv, shared_sec: shared_sec) else {
throw .failedToDecryptAES
}
guard let decryptedString = String(data: dat, encoding: .utf8) else {
throw .utf8DecodingFailedOnDecryptedPayload
}
return decryptedString
}
enum NIP04DecryptionError: Error {
case failedToComputeSharedSecret
case failedToDecodeEncryptedContent
case failedToDecryptAES
case utf8DecodingFailedOnDecryptedPayload
}
}
+111
View File
@@ -0,0 +1,111 @@
//
// InterestList.swift
// damus
//
// Created by Daniel D'Aquino on 2025-06-23.
//
// Some text excerpts taken from the Nostr Protocol itself (which are public domain)
import Foundation
/// Includes models and functions for working with NIP-51
struct NIP51: Sendable {}
extension NIP51 {
/// An error thrown when decoding an item into a NIP-51 list
enum NIP51DecodingError: Error {
/// The Nostr event being converted is not a NIP-51 interest list
case notInterestList
}
}
extension NIP51 {
/// Models a NIP-51 Interest List (kind:10015)
struct InterestList: NostrEventConvertible, Sendable {
typealias E = NIP51DecodingError
enum InterestItem: Sendable, Hashable {
case hashtag(String)
case interestSet(String, String, String) // a-tag: kind, pubkey, identifier
var tag: [String] {
switch self {
case .hashtag(let tag):
return ["t", tag]
case .interestSet(let kind, let pubkey, let identifier):
var tag = ["a", "\(kind):\(pubkey):\(identifier)"]
return tag
}
}
static func fromTag(tag: TagSequence) -> InterestItem? {
var i = tag.makeIterator()
guard let t0 = i.next(),
let t1 = i.next() else { return nil }
let tagName = t0.string()
if tagName == "t" {
return .hashtag(t1.string())
} else if tagName == "a" {
let components = t1.string().split(separator: ":")
guard components.count > 2 else { return nil }
let kind = String(components[0])
let pubkey = String(components[1])
let identifier = String(components[2])
return .interestSet(kind, pubkey, identifier)
}
return nil
}
}
let interests: [InterestItem]
// MARK: - Initialization
init(event: NdbNote) throws(E) {
try self.init(event: UnownedNdbNote(event))
}
init(event: borrowing UnownedNdbNote) throws(E) {
guard event.known_kind == .interest_list else {
throw E.notInterestList
}
var interests: [InterestItem] = []
for tag in event.tags {
if let interest = InterestItem.fromTag(tag: tag) {
interests.append(interest)
}
}
self.interests = interests
}
init?(event: NdbNote?) throws(E) {
guard let event else { return nil }
try self.init(event: event)
}
init(interests: [InterestItem]) {
self.interests = interests
}
// MARK: - Conversion to a Nostr Event
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32? = nil) -> NostrEvent? {
return NdbNote(
content: "",
keypair: keypair.to_keypair(),
kind: NostrKind.interest_list.rawValue,
tags: self.interests.map { $0.tag },
createdAt: timestamp ?? UInt32(Date.now.timeIntervalSince1970)
)
}
}
}
+171
View File
@@ -0,0 +1,171 @@
//
// NIP65.swift
// damus
//
// Created by Daniel DAquino on 2025-02-21.
//
// Some text excerpts taken from the Nostr Protocol itself (which are public domain)
import OrderedCollections
import Foundation
/// Includes models and functions for working with NIP-65
struct NIP65: Sendable {}
extension NIP65 {
/// Models a NIP-65 relay list
struct RelayList: NostrEventConvertible, Sendable {
let relays: OrderedDictionary<RelayURL, RelayItem>
// MARK: - Initialization
init(event: NdbNote) throws(NIP65DecodingError) {
try self.init(event: UnownedNdbNote(event))
}
init(event: borrowing UnownedNdbNote) throws(NIP65DecodingError) {
guard event.known_kind == .relay_list else { throw .notRelayList }
var relays: [RelayItem] = []
for tag in event.tags {
guard let relay = try RelayItem.fromTag(tag: tag) else { continue }
relays.append(relay)
}
self.relays = Self.relayOrderedDictionary(from: relays)
}
init?(event: NdbNote?) throws(NIP65DecodingError) {
guard let event else { return nil }
try self.init(event: event)
}
init(relays: [RelayItem]) {
self.relays = Self.relayOrderedDictionary(from: relays)
}
init(relays: [RelayURL]) {
let relayItemList = relays.map({ RelayItem(url: $0, rwConfiguration: .readWrite) })
self.relays = Self.relayOrderedDictionary(from: relayItemList)
}
private static func relayOrderedDictionary(from relayList: [RelayItem]) -> OrderedDictionary<RelayURL, RelayItem> {
var seenUrls: Set<RelayURL> = []
return OrderedDictionary(uniqueKeysWithValues: relayList.compactMap({
// We need to ensure the keys are unique to avoid assertion errors from OrderedDictionary
guard !seenUrls.contains($0.url) else { return nil }
seenUrls.insert($0.url)
return ($0.url, $0)
}))
}
// MARK: - Conversion to a Nostr Event
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32? = nil) -> NostrEvent? {
return NdbNote(
content: "",
keypair: keypair.to_keypair(),
kind: NostrKind.relay_list.rawValue,
tags: self.relays.values.map({ $0.tag }),
createdAt: timestamp ?? UInt32(Date.now.timeIntervalSince1970)
)
}
}
}
extension NIP65 {
/// An error thrown when decoding an item into a NIP-65 relay list
enum NIP65DecodingError: Error {
/// The Nostr event being converted is not a NIP-65 relay list
case notRelayList
/// The relay URL is invalid
case invalidRelayURL
///The relay RW marker is invalid
case invalidRelayMarker
}
}
extension NIP65.RelayList {
/// An item referencing a relay and its configuration inside a relay list
struct RelayItem: ThrowingTagConvertible, Sendable {
typealias E = NIP65.NIP65DecodingError
let url: RelayURL
let rwConfiguration: RWConfiguration
/// The raw tag sequence in a Nostr event
var tag: [String] {
var tag = ["r", url.absoluteString]
if let rwMarker = rwConfiguration.tagItem { tag.append(rwMarker) }
return tag
}
/// Initialize a new relay item from a Nostr event's tag sequence
static func fromTag(tag: TagSequence) throws(E) -> NIP65.RelayList.RelayItem? {
var i = tag.makeIterator()
guard tag.count >= 2,
let t0 = i.next(),
let key = t0.single_char,
let rkey = RefId.RefKey(rawValue: key),
let t1 = i.next()
else { return nil }
let t2 = i.next()
switch rkey {
case .r: return try self.fromRawInfo(urlString: t1.string(), rwMarker: t2?.string())
// Keep options explicit to make compiler prompt developer on whether to ignore or handle new future options
case .e, .p, .q, .t, .d, .a: return nil
}
}
/// Initializes a Relay Item based on raw information
static func fromRawInfo(urlString: String, rwMarker: String?) throws(NIP65.NIP65DecodingError) -> NIP65.RelayList.RelayItem? {
guard let relayUrl = RelayURL(urlString) else { throw .invalidRelayURL }
guard let rwConfiguration = RWConfiguration.fromTagItem(rwMarker) else { throw .invalidRelayMarker }
return NIP65.RelayList.RelayItem(url: relayUrl, rwConfiguration: rwConfiguration)
}
}
}
extension NIP65.RelayList.RelayItem {
/// The read/write configuration for a relay item
enum RWConfiguration: TagItemConvertible {
case read
case write
case readWrite
static let READ_MARKER: String = "read"
static let WRITE_MARKER: String = "write"
var canRead: Bool {
switch self {
case .read, .readWrite: return true
case .write: return false
}
}
var canWrite: Bool {
switch self {
case .write, .readWrite: return true
case .read: return false
}
}
/// A raw Nostr Event tag item
var tagItem: String? {
switch self {
case .read: Self.READ_MARKER
case .write: Self.WRITE_MARKER
case .readWrite: nil
}
}
/// Initialize this from a raw Nostr Event tag item
static func fromTagItem(_ item: String?) -> Self? {
if item == READ_MARKER { return .read }
if item == WRITE_MARKER { return .write }
return .readWrite
}
}
}
+22 -1
View File
@@ -34,6 +34,19 @@ protocol TagConvertible {
static func from_tag(tag: TagSequence) -> Self?
}
/// Protocol for types that can be converted from/to a tag sequence with the possibilty of an error
protocol ThrowingTagConvertible {
associatedtype E: Error
var tag: [String] { get }
static func fromTag(tag: TagSequence) throws(E) -> Self?
}
/// Protocol for types that can be converted from/to a tag item
protocol TagItemConvertible {
var tagItem: String? { get }
static func fromTagItem(_ item: String?) -> Self?
}
struct QuoteId: IdType, TagKey, TagConvertible {
let id: Data
@@ -130,8 +143,16 @@ struct ReplaceableParam: TagConvertible {
var keychar: AsciiCharacter { "d" }
}
struct Signature: Hashable, Equatable {
struct Signature: Codable, Hashable, Equatable {
let data: Data
init(from decoder: Decoder) throws {
self.init(try hex_decoder(decoder, expected_len: 64))
}
func encode(to encoder: Encoder) throws {
try hex_encoder(to: encoder, data: self.data)
}
init(_ p: Data) {
self.data = p
+1 -1
View File
@@ -7,7 +7,7 @@
import Foundation
func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: Relay) -> NostrEvent? {
func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: RelayPool.Relay) -> NostrEvent? {
let tags: [[String]] = [["relay", relay.descriptor.url.absoluteString],["challenge", challenge_string]]
let event = NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 22242, tags: tags)
return event
+3 -3
View File
@@ -7,7 +7,7 @@
import Foundation
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayPool.RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
var tags = zap_target_to_tags(target)
var relay_tag = ["relays"]
relay_tag.append(contentsOf: relays.map { $0.url.absoluteString })
@@ -78,8 +78,8 @@ func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair,
func make_first_contact_event(keypair: Keypair) -> NostrEvent? {
let bootstrap_relays = load_bootstrap_relays(pubkey: keypair.pubkey)
let rw_relay_info = RelayInfo(read: true, write: true)
var relays: [RelayURL: RelayInfo] = [:]
let rw_relay_info = LegacyKind3RelayRWConfiguration(read: true, write: true)
var relays: [RelayURL: LegacyKind3RelayRWConfiguration] = [:]
for relay in bootstrap_relays {
relays[relay] = rw_relay_info
+41 -6
View File
@@ -13,6 +13,18 @@ import CryptoKit
import NaturalLanguage
/// A protocol for structs and classes that can convert themselves from/to a NostrEvent
protocol NostrEventConvertible {
associatedtype E: Error
/// Iniitialize this type from a NostrEvent
init(event: NostrEvent) throws(E)
/// Convert this type into a Nostr Event, using a keypair for signing and a specific timestamp
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32?) -> NostrEvent?
}
enum ValidationResult: Decodable {
case unknown
case ok
@@ -367,6 +379,10 @@ func decode_json<T: Decodable>(_ val: String) -> T? {
return try? JSONDecoder().decode(T.self, from: Data(val.utf8))
}
func decode_json_throwing<T: Decodable>(_ val: String) throws -> T {
return try JSONDecoder().decode(T.self, from: Data(val.utf8))
}
func decode_data<T: Decodable>(_ data: Data) -> T? {
let decoder = JSONDecoder()
do {
@@ -432,17 +448,26 @@ func random_bytes(count: Int) -> Data {
return Data(bytes: bytes, count: count)
}
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent) -> NostrEvent? {
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent, relayURL: RelayURL?) -> NostrEvent? {
var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag })
tags.append(["e", boosted.id.hex(), "", "root"])
tags.append(["p", boosted.pubkey.hex()])
var eTagBuilder = ["e", boosted.id.hex()]
var pTagBuilder = ["p", boosted.pubkey.hex()]
let relayURLString = relayURL?.absoluteString
if let relayURLString {
pTagBuilder.append(relayURLString)
}
eTagBuilder.append(contentsOf: [relayURLString ?? "", "root", boosted.pubkey.hex()])
tags.append(eTagBuilder)
tags.append(pTagBuilder)
let content = event_to_json(ev: boosted)
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 6, tags: tags)
}
func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙") -> NostrEvent? {
func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙", relayURL: RelayURL?) -> NostrEvent? {
var tags = liked.tags.reduce(into: [[String]]()) { ts, tag in
guard tag.count >= 2,
(tag[0].matches_char("e") || tag[0].matches_char("p")) else {
@@ -451,8 +476,17 @@ 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)
}
@@ -527,6 +561,7 @@ func event_to_json(ev: NostrEvent) -> String {
return str
}
@available(*, deprecated, renamed: "NIP04.decryptContent", message: "Deprecated, please use NIP04.decryptContent instead")
func decrypt_dm(_ privkey: Privkey?, pubkey: Pubkey, content: String, encoding: EncEncoding) -> String? {
guard let privkey = privkey else {
return nil
+4
View File
@@ -8,6 +8,7 @@
import Foundation
/// A known Nostr event kind, addressable by name, with the actual number assigned by the protocol as the value
enum NostrKind: UInt32, Codable {
case metadata = 0
case text = 1
@@ -18,6 +19,8 @@ enum NostrKind: UInt32, Codable {
case like = 7
case chat = 42
case mute_list = 10000
case relay_list = 10002
case interest_list = 10015
case list_deprecated = 30000
case draft = 31234
case longform = 30023
@@ -28,4 +31,5 @@ enum NostrKind: UInt32, Codable {
case nwc_response = 23195
case http_auth = 27235
case status = 30315
case follow_list = 39089
}
+10 -2
View File
@@ -12,6 +12,7 @@ enum NostrLink: Equatable {
case ref(RefId)
case filter(NostrFilter)
case script([UInt8])
case invoice(String)
}
func encode_pubkey_uri(_ pubkey: Pubkey) -> String {
@@ -93,8 +94,15 @@ func decode_nostr_uri(_ s: String) -> NostrLink? {
return
}
if parts.count >= 2 && parts[0] == "t" {
return .filter(NostrFilter(hashtag: [parts[1].lowercased()]))
if parts.count >= 2 {
switch parts[0] {
case "t":
return .filter(NostrFilter(hashtag: [parts[1].lowercased()]))
case "lightning":
return .invoice(parts[1])
default:
break
}
}
guard parts.count == 1 else {
+12 -1
View File
@@ -12,11 +12,14 @@ struct NostrSubscribe {
let sub_id: String
}
/// Models a request/message that is sent to a Nostr relay
enum NostrRequestType {
/// A standard nostr request
case typical(NostrRequest)
/// A customized nostr request. Generally used in the context of a nostrscript.
case custom(String)
/// Whether this request is meant to write data to a relay
var is_write: Bool {
guard case .typical(let req) = self else {
return true
@@ -25,6 +28,7 @@ enum NostrRequestType {
return req.is_write
}
/// Whether this request is meant to read data from a relay
var is_read: Bool {
guard case .typical(let req) = self else {
return true
@@ -34,12 +38,18 @@ enum NostrRequestType {
}
}
/// Models a standard request/message that is sent to a Nostr relay.
enum NostrRequest {
/// Subscribes to receive information from the relay
case subscribe(NostrSubscribe)
/// Unsubscribes from an existing subscription, addressed by its id
case unsubscribe(String)
/// Posts an event
case event(NostrEvent)
/// Authenticate with the relay
case auth(NostrEvent)
/// Whether this request is meant to write data to a relay
var is_write: Bool {
switch self {
case .subscribe:
@@ -53,6 +63,7 @@ enum NostrRequest {
}
}
/// Whether this request is meant to read data from a relay
var is_read: Bool {
return !is_write
}
+1
View File
@@ -35,6 +35,7 @@ class Profiles {
@MainActor
private var profiles: [Pubkey: ProfileData] = [:]
// Map of validated NIP-05 address to pubkey.
@MainActor
var nip05_pubkey: [String: Pubkey] = [:]
+26 -1
View File
@@ -115,6 +115,19 @@ enum FollowRef: TagKeys, Hashable, TagConvertible, Equatable {
}
}
/// Models common tag references defined by the Nostr protocol, and their associated values.
///
/// For example, this raw JSON tag sequence:
/// ```json
/// ["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"]
/// ```
///
/// would be parsed into something equivalent to `.pubkey(Pubkey(hex: "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"))`
///
/// ## Notes
///
/// - Not all tag information from all NIPs can be modelled using this alone, as some NIPs may define extra associated values for specific event types. You may need to use a specialized type for some event kinds
///
enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
case event(NoteId)
case pubkey(Pubkey)
@@ -124,6 +137,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
case naddr(NAddr)
case reference(String)
/// The key that defines the type of reference being made
var key: RefKey {
switch self {
case .event: return .e
@@ -136,6 +150,14 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
}
}
/// Defines the type of reference being made on a Nostr event tag
///
/// Example:
/// ```json
/// ["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"]
/// ```
///
/// The `RefKey` is "p"
enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible {
case e, p, t, d, q, a, r
@@ -148,10 +170,12 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
}
}
/// A raw nostr-style tag sequence representation of this object
var tag: [String] {
[self.key.description, self.description]
}
/// Describes what is being referenced, as a `String`
var description: String {
switch self {
case .event(let noteId): return noteId.hex()
@@ -166,6 +190,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
}
}
/// Parses a raw tag sequence
static func from_tag(tag: TagSequence) -> RefId? {
var i = tag.makeIterator()
+88 -50
View File
@@ -7,16 +7,25 @@
import Foundation
public struct RelayInfo: Codable {
let read: Bool?
let write: Bool?
public struct LegacyKind3RelayRWConfiguration: Codable, Sendable {
public let read: Bool?
public let write: Bool?
init(read: Bool, write: Bool) {
self.read = read
self.write = write
}
static let rw = RelayInfo(read: true, write: true)
static let rw = LegacyKind3RelayRWConfiguration(read: true, write: true)
func toNIP65RWConfiguration() -> NIP65.RelayList.RelayItem.RWConfiguration? {
switch (self.read, self.write) {
case (false, true): return .write
case (true, false): return .read
case (true, true): return .readWrite
default: return nil
}
}
}
enum RelayVariant {
@@ -25,30 +34,33 @@ enum RelayVariant {
case nwc
}
public struct RelayDescriptor {
let url: RelayURL
let info: RelayInfo
let variant: RelayVariant
init(url: RelayURL, info: RelayInfo, variant: RelayVariant = .regular) {
self.url = url
self.info = info
self.variant = variant
}
var ephemeral: Bool {
switch variant {
case .regular:
return false
case .ephemeral:
return true
case .nwc:
return true
extension RelayPool {
/// Describes a relay for use in `RelayPool`
public struct RelayDescriptor {
let url: RelayURL
var info: NIP65.RelayList.RelayItem.RWConfiguration
let variant: RelayVariant
init(url: RelayURL, info: NIP65.RelayList.RelayItem.RWConfiguration, variant: RelayVariant = .regular) {
self.url = url
self.info = info
self.variant = variant
}
var ephemeral: Bool {
switch variant {
case .regular:
return false
case .ephemeral:
return true
case .nwc:
return true
}
}
static func nwc(url: RelayURL) -> RelayDescriptor {
return RelayDescriptor(url: url, info: .readWrite, variant: .nwc)
}
}
static func nwc(url: RelayURL) -> RelayDescriptor {
return RelayDescriptor(url: url, info: .rw, variant: .nwc)
}
}
@@ -129,30 +141,56 @@ struct RelayMetadata: Codable {
}
}
class Relay: Identifiable {
let descriptor: RelayDescriptor
let connection: RelayConnection
var authentication_state: RelayAuthenticationState
var flags: Int
init(descriptor: RelayDescriptor, connection: RelayConnection) {
self.flags = 0
self.descriptor = descriptor
self.connection = connection
self.authentication_state = RelayAuthenticationState.none
extension RelayPool {
class Relay: Identifiable {
var descriptor: RelayDescriptor
let connection: RelayConnection
var authentication_state: RelayAuthenticationState
var flags: Int
init(descriptor: RelayDescriptor, connection: RelayConnection) {
self.flags = 0
self.descriptor = descriptor
self.connection = connection
self.authentication_state = RelayAuthenticationState.none
}
var is_broken: Bool {
return (flags & RelayFlags.broken.rawValue) == RelayFlags.broken.rawValue
}
var id: RelayURL {
return descriptor.url
}
}
var is_broken: Bool {
return (flags & RelayFlags.broken.rawValue) == RelayFlags.broken.rawValue
}
var id: RelayURL {
return descriptor.url
}
}
enum RelayError: Error {
case RelayAlreadyExists
extension RelayPool {
enum RelayError: Error {
case RelayAlreadyExists
}
}
// MARK: - Extension to bridge NIP-65 relay list structs with app-native objects
extension NIP65.RelayList {
static func fromLegacyContactList(_ contactList: NdbNote) throws(BridgeError) -> Self {
guard let relayListInfo = decode_json_relays(contactList.content) else { throw .couldNotDecodeRelayListInfo }
let relayItems = relayListInfo.map({ url, rwConfiguration in
return RelayItem(url: url, rwConfiguration: rwConfiguration.toNIP65RWConfiguration() ?? .readWrite)
})
return NIP65.RelayList(relays: relayItems)
}
static func fromLegacyContactList(_ contactList: NdbNote?) throws(BridgeError) -> Self? {
guard let contactList = contactList else { return nil }
return try fromLegacyContactList(contactList)
}
enum BridgeError: Error {
case couldNotDecodeRelayListInfo
}
}
+74 -23
View File
@@ -19,18 +19,15 @@ struct QueuedRequest {
let skip_ephemeral: Bool
}
struct SeenEvent: Hashable {
let relay_id: RelayURL
let evid: NoteId
}
/// Establishes and manages connections and subscriptions to a list of relays.
class RelayPool {
var relays: [Relay] = []
private(set) var relays: [Relay] = []
var handlers: [RelayHandler] = []
var request_queue: [QueuedRequest] = []
var seen: Set<SeenEvent> = Set()
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)?
@@ -122,7 +119,7 @@ class RelayPool {
}
}
func add_relay(_ desc: RelayDescriptor) throws {
func add_relay(_ desc: RelayDescriptor) throws(RelayError) {
let relay_id = desc.url
if get_relay(relay_id) != nil {
throw RelayError.RelayAlreadyExists
@@ -200,6 +197,64 @@ class RelayPool {
register_handler(sub_id: sub_id, handler: handler)
send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
}
/// 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, in seconds
/// - Returns: Returns an async stream that callers can easily consume via a for-loop
func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: TimeInterval = 10) -> AsyncStream<StreamItem> {
let desiredRelays = desiredRelays ?? self.relays.map({ $0.descriptor.url })
return AsyncStream<StreamItem> { continuation in
let sub_id = UUID().uuidString
var seenEvents: Set<NoteId> = []
var relaysWhoFinishedInitialResults: Set<RelayURL> = []
var eoseSent = false
self.subscribe(sub_id: sub_id, filters: filters, handler: { (relayUrl, connectionEvent) in
switch connectionEvent {
case .ws_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_event` in `RelayPool`? Seems like something to be handled on the `RelayConnection` layer.
break
case .nostr_event(let nostrResponse):
guard nostrResponse.subid == sub_id else { return } // Do not stream items that do not belong in this subscription
switch nostrResponse {
case .event(_, let nostrEvent):
if seenEvents.contains(nostrEvent.id) { break } // Don't send two of the same events.
continuation.yield(with: .success(.event(nostrEvent)))
seenEvents.insert(nostrEvent.id)
case .notice(let note):
break // We do not support handling these yet
case .eose(_):
relaysWhoFinishedInitialResults.insert(relayUrl)
if relaysWhoFinishedInitialResults == Set(desiredRelays) {
continuation.yield(with: .success(.eose))
eoseSent = true
}
case .ok(_): break // No need to handle this, we are not sending an event to the relay
case .auth(_): break // Handled in a separate function in RelayPool
}
}
}, to: desiredRelays)
Task {
try? await Task.sleep(nanoseconds: 1_000_000_000 * UInt64(eoseTimeout))
if !eoseSent { continuation.yield(with: .success(.eose)) }
}
continuation.onTermination = { @Sendable _ in
self.unsubscribe(sub_id: sub_id, to: desiredRelays)
self.remove_handler(sub_id: sub_id)
}
}
}
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: @escaping (RelayURL, NostrConnectionEvent) -> ()) {
register_handler(sub_id: sub_id, handler: handler)
@@ -243,19 +298,19 @@ class RelayPool {
func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) {
let relays = to.map{ get_relays($0) } ?? self.relays
self.send_raw_to_local_ndb(req)
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.read ?? true) {
continue
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.write ?? true) {
continue
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
continue // Do not send requests to ephemeral relays if we want to skip them
}
guard relay.connection.isConnected else {
@@ -297,15 +352,11 @@ class RelayPool {
func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) {
if case .nostr_event(let ev) = event {
if case .event(_, let nev) = ev {
let k = SeenEvent(relay_id: relay_id, evid: nev.id)
if !seen.contains(k) {
seen.insert(k)
if counts[relay_id] == nil {
counts[relay_id] = 1
} else {
counts[relay_id] = (counts[relay_id] ?? 0) + 1
}
if seen[nev.id]?.contains(relay_id) == true {
return
}
seen[nev.id, default: Set()].insert(relay_id)
counts[relay_id, default: 0] += 1
}
}
}
@@ -354,7 +405,7 @@ class RelayPool {
}
func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) {
try? pool.add_relay(RelayDescriptor(url: url, info: .rw))
try? pool.add_relay(RelayPool.RelayDescriptor(url: url, info: .readWrite))
}
+3 -5
View File
@@ -83,8 +83,7 @@ var test_damus_state: DamusState = ({
let our_pubkey = test_pubkey
let pool = RelayPool(ndb: ndb)
let settings = UserSettingsStore()
let damus = DamusState(pool: pool,
keypair: test_keypair,
let damus = DamusState(keypair: test_keypair,
likes: .init(our_pubkey: our_pubkey),
boosts: .init(our_pubkey: our_pubkey),
contacts: .init(our_pubkey: our_pubkey),
@@ -100,8 +99,6 @@ var test_damus_state: DamusState = ({
drafts: .init(),
events: .init(ndb: ndb),
bookmarks: .init(pubkey: our_pubkey),
postbox: .init(pool: pool),
bootstrap_relays: .init(),
replies: .init(our_pubkey: our_pubkey),
wallet: .init(settings: settings),
nav: .init(),
@@ -109,7 +106,8 @@ var test_damus_state: DamusState = ({
video: .init(),
ndb: ndb,
quote_reposts: .init(our_pubkey: our_pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: .init()
)
/*
+27 -1
View File
@@ -37,7 +37,23 @@ enum Block: Equatable {
return false
}
}
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)
@@ -186,3 +202,13 @@ extension Block {
}
}
}
extension Block {
var asInvoice: Invoice? {
switch self {
case .invoice(let invoice):
return invoice
default:
return nil
}
}
}
-1
View File
@@ -45,4 +45,3 @@ struct Pubkey: IdType, TagKey, TagConvertible, Identifiable {
}
}
+4
View File
@@ -47,6 +47,10 @@ struct NEvent : Equatable, Hashable {
self.author = author
self.kind = kind
}
init(event: NostrEvent, relays: [String]) {
self.init(noteid: event.id, relays: relays, author: event.pubkey, kind: event.kind)
}
}
struct NProfile : Equatable, Hashable {
@@ -0,0 +1,389 @@
//
// CoinosDeterministicClient.swift
// damus
//
// Created by Daniel DAquino on 2025-04-14.
//
import Foundation
/// Implements a client that can talk to the Coinos API server with a deterministic account derived from the user's private key.
///
/// This is NOT a general-purpose Coinos client, and only works with the user's own deterministic "one-click setup" Coinos wallet account.
class CoinosDeterministicAccountClient {
// MARK: - State
/// The user's normal keypair for using Nostr
private let userKeypair: FullKeypair
/// The JWT authentication token with Coinos
private var jwtAuthToken: String? = nil
// MARK: - Computed properties for a deterministic wallet
/// A deterministic keypair for the NWC connection derived from the user's private key
private var nwcKeypair: FullKeypair? {
let nwcPrivateKey: Privkey = Privkey(sha256(self.userKeypair.privkey.id)) // SHA256 is an irreversible operation, user's nsec should not be deriveable from this new private key
return FullKeypair(privkey: nwcPrivateKey)
}
/// A deterministic username for a Coinos account
private var username: String? {
// Derive from private key because deriving from a pubkey would mean that anyone could compute the username and take that username before our user
// Add some prefix so that we can ensure this will NOT match the password nor the NWC keypair
guard let fullText = sha256Hex(text: "coinos_username:" + self.userKeypair.privkey.hex()) else { return nil }
// There is very little risk of a birthday attack on getting only the first 16 characters, because:
// 1. before this user creates an account, no one else knows the private key in order to know the expected username and create an account before them
// 2. after the account is created and username is revealed, finding collisions is pointless as duplicate usernames will be rejected by Coinos
//
// In terms of the risk of an accidental collision due to the birthday problem, 16 characters should be enough to pragmatically avoid any collision.
// According to `https://en.wikipedia.org/wiki/Birthday_problem#Probability_table`,
// even if we have 610 million Damus users connected to Coinos, the probability of even a single collision is still as low as 1%.
return String(fullText.prefix(16))
}
var expectedLud16: String? {
guard let username else { return nil }
return username + "@coinos.io"
}
/// A deterministic password for a Coinos account
private var password: String? {
// Add some prefix so that we can ensure this will NOT match the user nor the NWC private key
return sha256Hex(text: "coinos_password:" + self.userKeypair.privkey.hex())
}
/// A deterministic NWC app connection name
private var nwcConnectionName: String { return "Damus" }
// MARK: - Initialization
/// Initializes the client with the user's keypair
init(userKeypair: FullKeypair) {
self.userKeypair = userKeypair
}
// MARK: - Authentication and registration
/// Tries to login to the user's deterministic account. If it cannot be found, it will register for one and log into that.
func loginOrRegister() async throws {
do {
// Check if client has an account
try await self.login()
}
catch {
guard let error = error as? CoinosDeterministicAccountClient.ClientError, error == .unauthorized else { throw error }
// Client does not seem to have an account, create one
try await self.register()
try await self.login()
}
}
/// Registers for a Coinos account using deterministic account details.
///
/// It succeeds if it returns without throwing errors.
func register() async throws {
guard let username, let password else { throw ClientError.errorFormingRequest }
let registerPayload = RegisterRequest(user: UserCredentials(username: username, password: password))
let jsonData = try JSONEncoder().encode(registerPayload)
let url = URL(string: "https://coinos.io/api/register")!
let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
return
} else {
throw ClientError.unexpectedHTTPResponse(status_code: (response as? HTTPURLResponse)?.statusCode ?? -1, response: data)
}
}
/// Logs into the deterministic account, if an auth token is not present
func loginIfNeeded() async throws {
if self.jwtAuthToken == nil { try await self.login() }
}
/// Logs into to our deterministic account.
///
/// Succeeds if it returns without returning errors.
///
/// Mutating function, will update the client's internal state.
func login() async throws {
self.jwtAuthToken = try await sendLoginRequest().token
}
/// Sends the login request and return the response
///
/// Does NOT update the internal login state.
private func sendLoginRequest() async throws -> AuthResponse {
guard let url = URL(string: "https://coinos.io/api/login") else { throw ClientError.errorFormingRequest }
guard let username, let password else { throw ClientError.errorFormingRequest }
let credentials = UserCredentials(username: username, password: password)
let jsonData = try JSONEncoder().encode(credentials)
let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200: return try JSONDecoder().decode(AuthResponse.self, from: data)
case 401: throw ClientError.unauthorized
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
}
}
throw ClientError.errorProcessingResponse
}
// MARK: - Managing NWC connections
/// Creates a new NWC connection
///
/// Note: Account must exist before calling this endpoint
func createNWCConnection() async throws -> WalletConnectURL {
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
guard let urlEndpoint = URL(string: "https://coinos.io/api/app") else { throw ClientError.errorFormingRequest }
try await self.loginIfNeeded()
let config = try defaultWalletConnectionConfig()
let configData = try encode_json_data(config)
let (data, response) = try await self.makeAuthenticatedRequest(
method: .post,
url: urlEndpoint,
payload: configData,
payload_type: .json
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
guard let nwc = try await self.getNWCUrl() else { throw ClientError.errorProcessingResponse }
return nwc
case 401: throw ClientError.unauthorized
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
}
}
throw ClientError.errorProcessingResponse
}
/// Updates an existing NWC connection with a new maximum budget
///
/// Note: Account and NWC connection must exist before calling this endpoint
func updateNWCConnection(maxAmount: UInt64) async throws -> WalletConnectURL {
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
guard let urlEndpoint = URL(string: "https://coinos.io/api/app") else { throw ClientError.errorFormingRequest }
try await self.loginIfNeeded()
// Get existing config first
guard let existingConfig = try await self.getNWCAppConnectionConfig() else {
throw ClientError.errorProcessingResponse
}
// Create updated config with new max amount
let updatedConfig = NewWalletConnectionConfig(
name: existingConfig.name ?? self.nwcConnectionName,
secret: existingConfig.secret ?? nwcKeypair.privkey.hex(),
pubkey: existingConfig.pubkey ?? nwcKeypair.pubkey.hex(),
max_amount: maxAmount,
budget_renewal: .weekly
)
let configData = try encode_json_data(updatedConfig)
let (data, response) = try await self.makeAuthenticatedRequest(
method: .post,
url: urlEndpoint,
payload: configData,
payload_type: .json
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
guard let nwc = try await self.getNWCUrl() else { throw ClientError.errorProcessingResponse }
return nwc
case 401: throw ClientError.unauthorized
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
}
}
throw ClientError.errorProcessingResponse
}
/// Returns the default wallet connection config
private func defaultWalletConnectionConfig() throws -> NewWalletConnectionConfig {
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
return NewWalletConnectionConfig(
name: self.nwcConnectionName,
secret: nwcKeypair.privkey.hex(),
pubkey: nwcKeypair.pubkey.hex(),
max_amount: 30000, // 30K sats per week maximum
budget_renewal: .weekly
)
}
/// Gets the NWC URL for the deterministic NWC app connection
///
/// Account must already exist before calling this
///
/// Returns `nil` if no NWC url is found, (e.g. if app connection has not been configured yet)
func getNWCUrl() async throws -> WalletConnectURL? {
guard let connectionConfig = try await self.getNWCAppConnectionConfig(), let nwc = connectionConfig.nwc else { return nil }
return WalletConnectURL(str: nwc)
}
/// Gets the deterministic NWC app connection configuration details, if it exists
///
/// Account must already exist before calling this
///
/// Returns `nil` if no connection is found, (e.g. if app connection has not been configured yet)
func getNWCAppConnectionConfig() async throws -> WalletConnectionConfig? {
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
guard let url = URL(string: "https://coinos.io/api/app/" + nwcKeypair.pubkey.hex()) else { throw ClientError.errorFormingRequest }
try await self.loginIfNeeded()
let (data, response) = try await self.makeAuthenticatedRequest(
method: .get,
url: url,
payload: nil,
payload_type: nil
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200: return try JSONDecoder().decode(WalletConnectionConfig.self, from: data)
case 401: throw ClientError.unauthorized
case 404: return nil
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
}
}
throw ClientError.errorProcessingResponse
}
// MARK: - Lower level request convenience functions
/// Makes a request without any authorization
func makeRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.httpBody = payload
if let payload_type {
request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
}
return try await URLSession.shared.data(for: request)
}
/// Makes an authenticated request with our JWT auth token.
///
/// Client must be logged-in before calling this, otherwise an error will be thrown.
func makeAuthenticatedRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
guard let jwtAuthToken else { throw ClientError.errorFormingRequest }
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.httpBody = payload
request.setValue("Bearer " + jwtAuthToken, forHTTPHeaderField: "Authorization")
if let payload_type {
request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
}
return try await URLSession.shared.data(for: request)
}
// MARK: - Helper structures
/// Payload for registering for a new Coinos account
struct RegisterRequest: Codable {
/// New user credentials
let user: UserCredentials
}
/// Payload for user credentials (sign-up and login)
struct UserCredentials: Codable {
/// The username
let username: String
/// The user password
let password: String
}
/// A successful response to a login auth endpoint
struct AuthResponse: Codable {
/// The JWT token to be applied to any authenticated API calls
let token: String
}
/// Used by the client to define new NWC configurations
struct NewWalletConnectionConfig: Codable {
/// The name of the connection
let name: String
/// 32 Hex-encoded bytes containing a shared private key secret
let secret: String
/// 32 Hex-encoded bytes containing the pubkey for the secret
let pubkey: String
/// Max amount that can be spent in each renewal period (measured in sats)
let max_amount: UInt64
/// The period of time it takes for the budget limits to reset
let budget_renewal: BudgetRenewalPeriod
}
/// The NWC connection configuration details
///
/// ## Implementation notes
///
/// - All items defined as optionals because the Coinos API may change in the future, so this may help increase future compatibility.
struct WalletConnectionConfig: Codable {
/// The name of the connection
let name: String?
/// 32 Hex-encoded bytes containing a shared private key secret
let secret: String?
/// 32 Hex-encoded bytes containing the pubkey for the secret
let pubkey: String?
/// Max amount that can be spent in every renewal period (measured in sats)
let max_amount: UInt64?
/// The NWC url generated by the server
let nwc: String?
/// Budget renewal information
let budget_renewal: BudgetRenewalPeriod?
}
/// A period of time it takes for budget limits to be reset
enum BudgetRenewalPeriod: String, Codable {
/// Resets once a week
case weekly
}
/// A client error occured
enum ClientError: Error, Equatable {
/// Received an unexpected HTTP response
///
/// Could be for a variety of reasons.
case unexpectedHTTPResponse(status_code: Int, response: Data)
/// Error forming the request, generally due to missing or inconsistent internal data
///
/// Probably caused by a programming error.
case errorFormingRequest
/// The client could not process the response from the server
///
/// Might be a sign of an incompatibility bug
case errorProcessingResponse
/// The action performed is not authorized
/// Generally thrown if user does not exist, credentials do not match what Coinos has on file, or programming error
case unauthorized
/// Client not logged in on a call that expected login
case notLoggedIn
}
}
/// Computes a SHA256 hash digest from a piece of UTF-8 text, and returns the result as a "hex" string
///
/// When working only with strings, this can be more convenient than transforming text to data, and data back to text
fileprivate func sha256Hex(text: String) -> String? {
guard let data = text.data(using: .utf8) else { return nil }
return sha256(data).toHexString()
}
+4
View File
@@ -18,6 +18,9 @@ class Constants {
static let MAIN_APP_BUNDLE_IDENTIFIER: String = "com.jb55.damus2"
static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService"
// MARK: Curation
static let ONBOARDING_FOLLOW_PACK_CURATOR_PUBKEY: Pubkey = Pubkey(hex: "895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba")!
// MARK: Push notification server
static let PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL: URL = URL(string: "https://notify.damus.io")!
static let PUSH_NOTIFICATION_SERVER_STAGING_BASE_URL: URL = URL(string: "https://notify-staging.damus.io")!
@@ -42,4 +45,5 @@ class Constants {
// MARK: General constants
static let GIF_IMAGE_TYPE: String = "com.compuserve.gif"
static let MAX_SHARE_RELAYS = 4
}
+2 -2
View File
@@ -80,9 +80,9 @@ func parse_display_name(name: String?, display_name: String?, pubkey: Pubkey) ->
}
func abbrev_bech32_pubkey(pubkey: Pubkey) -> String {
return abbrev_pubkey(String(pubkey.npub.dropFirst(4)))
return abbrev_identifier(String(pubkey.npub.dropFirst(4)))
}
func abbrev_pubkey(_ pubkey: String, amount: Int = 8) -> String {
func abbrev_identifier(_ pubkey: String, amount: Int = 8) -> String {
return pubkey.prefix(amount) + ":" + pubkey.suffix(amount)
}
+1 -1
View File
@@ -354,7 +354,7 @@ func preload_image(url: URL) {
//print("Preloading image \(url.absoluteString)")
KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: url)) { val in
KingfisherManager.shared.retrieveImage(with: Kingfisher.KF.ImageResource(downloadURL: url)) { val in
//print("Preloaded image \(url.absoluteString)")
}
}
+27 -17
View File
@@ -29,15 +29,15 @@ extension KFOptionSetter {
options.onlyLoadFirstFrame = disable_animation
switch imageContext {
case .pfp:
options.diskCacheExpiration = .days(60)
break
case .banner:
options.diskCacheExpiration = .days(5)
break
case .note:
options.diskCacheExpiration = .days(1)
break
case .pfp, .favicon:
options.diskCacheExpiration = .days(60)
break
case .banner:
options.diskCacheExpiration = .days(5)
break
case .note:
options.diskCacheExpiration = .days(1)
break
}
return self
@@ -52,7 +52,7 @@ extension KFOptionSetter {
func onFailure(fallbackUrl: URL?, cacheKey: String?) -> Self {
guard let url = fallbackUrl, let key = cacheKey else { return self }
let imageResource = Kingfisher.ImageResource(downloadURL: url, cacheKey: key)
let imageResource = Kingfisher.KF.ImageResource(downloadURL: url, cacheKey: key)
let source = imageResource.convertToSource()
options.alternativeSources = [source]
@@ -82,11 +82,14 @@ enum ImageContext {
case pfp
case banner
case note
case favicon
func maxMebibyteSize() -> Int {
switch self {
case .favicon:
return 512_000 // 500KiB
case .pfp:
return 5_242_880 // 5Mib
return 5_242_880 // 5MiB
case .banner, .note:
return 20_971_520 // 20MiB
}
@@ -94,6 +97,8 @@ enum ImageContext {
func downsampleSize() -> CGSize {
switch self {
case .favicon:
return CGSize(width: 18, height: 18)
case .pfp:
return CGSize(width: 200, height: 200)
case .banner:
@@ -159,20 +164,25 @@ struct CustomCacheSerializer: CacheSerializer {
}
}
class CustomSessionDelegate: SessionDelegate {
override func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
class CustomSessionDelegate: SessionDelegate, @unchecked Sendable {
override func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive response: URLResponse
) async -> URLSession.ResponseDisposition {
let contentLength = response.expectedContentLength
// Content-Length header is optional (-1 when missing)
if (contentLength != -1 && contentLength > MAX_FILE_SIZE) {
return super.urlSession(session, dataTask: dataTask, didReceive: URLResponse(), completionHandler: completionHandler)
return await super.urlSession(session, dataTask: dataTask, didReceive: URLResponse())
}
super.urlSession(session, dataTask: dataTask, didReceive: response, completionHandler: completionHandler)
return await super.urlSession(session, dataTask: dataTask, didReceive: response)
}
}
class CustomImageDownloader: ImageDownloader {
class CustomImageDownloader: ImageDownloader, @unchecked Sendable {
static let shared = CustomImageDownloader(name: "shared")
+41
View File
@@ -0,0 +1,41 @@
//
// FaviconCache.swift
// damus
//
// Created by Terry Yiu on 5/23/25.
//
import Foundation
import FaviconFinder
class FaviconCache {
private var nip05DomainFavicons: [String: [FaviconURL]] = [:]
@MainActor
func lookup(_ domain: String) async -> [FaviconURL] {
let lowercasedDomain = domain.lowercased()
if let faviconURLs = nip05DomainFavicons[lowercasedDomain] {
return faviconURLs
}
guard let siteURL = URL(string: "https://\(lowercasedDomain)"),
let faviconURLs = try? await FaviconFinder(
url: siteURL,
configuration: .init(
preferredSource: .ico, // Prefer using common favicon .ico filenames at root level to avoid scraping HTML when possible.
preferences: [
.html: FaviconFormatType.appleTouchIcon.rawValue,
.ico: "favicon.ico",
.webApplicationManifestFile: FaviconFormatType.launcherIcon4x.rawValue
]
)
).fetchFaviconURLs()
else {
return []
}
nip05DomainFavicons[lowercasedDomain] = faviconURLs
return faviconURLs
}
}
+79
View File
@@ -0,0 +1,79 @@
//
// ImageCacheMigrations.swift
// damus
//
// Created by Daniel DAquino on 2025-04-26.
//
import Foundation
import Kingfisher
struct ImageCacheMigrations {
static func migrateKingfisherCacheIfNeeded() {
let fileManager = FileManager.default
let defaults = UserDefaults.standard
let migration1Key = "KingfisherCacheMigrated" // Never ever changes
let migration2Key = "KingfisherCacheMigratedV2" // Never ever changes
let migration1Done = defaults.bool(forKey: migration1Key)
let migration2Done = defaults.bool(forKey: migration2Key)
guard !migration1Done || !migration2Done else {
// All migrations are already done. Skip.
return
}
let oldCachePath = migration1Done ? migration1KingfisherCachePath() : migration0KingfisherCachePath()
// New shared cache location
let newCachePath = kingfisherCachePath().path
if fileManager.fileExists(atPath: oldCachePath) {
do {
// Move the old cache to the new location
try fileManager.moveItem(atPath: oldCachePath, toPath: newCachePath)
Log.info("Successfully migrated Kingfisher cache to %s", for: .storage, newCachePath)
} catch {
do {
// Cache data is not essential, fallback to deleting the cache and starting all over
// It's better than leaving significant garbage data stuck indefinitely on the user's phone
try fileManager.removeItem(atPath: newCachePath)
try fileManager.removeItem(atPath: oldCachePath)
}
catch {
Log.error("Failed to migrate cache: %s", for: .storage, error.localizedDescription)
return // Do not mark them as complete, we can try again next time the user reloads the app
}
}
}
// Mark migrations as complete
defaults.set(true, forKey: migration1Key)
defaults.set(true, forKey: migration2Key)
}
static private func migration0KingfisherCachePath() -> String {
// Implementation note: These are old, so they should not be changed
let defaultCache = ImageCache.default
return defaultCache.diskStorage.directoryURL.path
}
static private func migration1KingfisherCachePath() -> String {
// Implementation note: These are old, so they are hard-coded on purpose, because we can't change these values from the past.
let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.damus")!
return groupURL.appendingPathComponent("ImageCache").path
}
/// The latest path for kingfisher to store cached images on.
///
/// Documentation references:
/// - https://developer.apple.com/documentation/foundation/filemanager/containerurl(forsecurityapplicationgroupidentifier:)#:~:text=The%20system%20creates%20only%20the%20Library/Caches%20subdirectory%20automatically
/// - https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html#:~:text=Put%20data%20cache,files%20as%20needed.
static func kingfisherCachePath() -> URL {
let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER)!
return groupURL
.appendingPathComponent("Library")
.appendingPathComponent("Caches")
.appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME)
}
}
+1
View File
@@ -21,6 +21,7 @@ enum LogCategory: String {
case damus_purple
case image_uploading
case video_coordination
case tips
}
/// Damus structured logger
+1 -1
View File
@@ -54,7 +54,7 @@ enum CancelSendErr {
}
class PostBox {
let pool: RelayPool
private let pool: RelayPool
var events: [NoteId: PostedEvent]
init(pool: RelayPool) {
+7
View File
@@ -7,6 +7,13 @@
import Foundation
/// Stores information, metadata, and logs about different relays. Generally used as a singleton.
///
/// # Discussion
///
/// This class is primarily used as a shared singleton in `DamusState`, to allow other parts of the app to access information, metadata, and logs about relays without having to fetch it themselves.
///
/// For example, it is used by `RelayView` to supplement information about the relay without having to fetch those again from the network, as well as to display logs collected throughout the use of the app.
final class RelayModelCache: ObservableObject {
private var models = [RelayURL: RelayModel]()
+21 -2
View File
@@ -5,6 +5,7 @@
// Created by Scott Penrose on 5/7/23.
//
import FaviconFinder
import SwiftUI
enum Route: Hashable {
@@ -46,6 +47,9 @@ enum Route: Hashable {
case Wallet(wallet: WalletModel)
case WalletScanner(result: Binding<WalletScanResult>)
case FollowersYouKnow(friendedFollowers: [Pubkey], followers: FollowersModel)
case NIP05DomainEvents(events: NIP05DomainEventsModel, nip05_domain_favicon: FaviconURL?)
case NIP05DomainPubkeys(domain: String, nip05_domain_favicon: FaviconURL?, pubkeys: [Pubkey])
case FollowPack(followPack: NostrEvent, model: FollowPackModel, blur_imgs: Bool)
@ViewBuilder
func view(navigationCoordinator: NavigationCoordinator, damusState: DamusState) -> some View {
@@ -126,7 +130,13 @@ enum Route: Hashable {
case .FollowersYouKnow(let friendedFollowers, let followers):
FollowersYouKnowView(damus_state: damusState, friended_followers: friendedFollowers, followers: followers)
case .Script(let load_model):
LoadScript(pool: damusState.pool, model: load_model)
LoadScript(pool: damusState.nostrNetwork.pool, model: load_model)
case .NIP05DomainEvents(let events, let nip05_domain_favicon):
NIP05DomainTimelineView(damus_state: damusState, model: events, nip05_domain_favicon: nip05_domain_favicon)
case .NIP05DomainPubkeys(let domain, let nip05_domain_favicon, let pubkeys):
NIP05DomainPubkeysView(damus_state: damusState, domain: domain, nip05_domain_favicon: nip05_domain_favicon, pubkeys: pubkeys)
case .FollowPack(let followPack, let followPackModel, let blur_imgs):
FollowPackView(state: damusState, ev: followPack, model: followPackModel, blur_imgs: blur_imgs)
}
}
@@ -209,7 +219,7 @@ enum Route: Hashable {
case .Search(let search):
hasher.combine("search")
hasher.combine(search.search)
case .NDBSearch(let results):
case .NDBSearch:
hasher.combine("results")
case .EULA:
hasher.combine("eula")
@@ -231,6 +241,15 @@ enum Route: Hashable {
case .Script(let model):
hasher.combine("script")
hasher.combine(model.data.count)
case .NIP05DomainEvents(let events, _):
hasher.combine("nip05DomainEvents")
hasher.combine(events.domain)
case .NIP05DomainPubkeys(let domain, _, _):
hasher.combine("nip05DomainPubkeys")
hasher.combine(domain)
case .FollowPack(let followPack, let followPackModel, let blur_imgs):
hasher.combine("followPack")
hasher.combine(followPack.id)
}
}
}
+3 -3
View File
@@ -35,11 +35,11 @@ func remove_nostr_uri_prefix(_ s: String) -> String {
return uri
}
func abbreviateURL(_ url: URL) -> String {
func abbreviateURL(_ url: URL, maxLength: Int = MAX_CHAR_URL) -> String {
let urlString = url.absoluteString
if urlString.count > MAX_CHAR_URL {
return String(urlString.prefix(MAX_CHAR_URL)) + "..."
if urlString.count > maxLength {
return String(urlString.prefix(maxLength)) + ""
}
return urlString
}
@@ -0,0 +1,97 @@
//
// HumanReadableErrors.swift
// damus
//
// Created by Daniel DAquino on 2025-05-05.
//
import Foundation
extension WalletConnect.FullWalletResponse.InitializationError {
var humanReadableError: ErrorView.UserPresentableError? {
switch self {
case .incorrectAuthorPubkey:
nil // Anyone can send a response event with an incorrect author pubkey, it is not really an "error". We should silently ignore it.
case .missingRequestIdReference:
.init(
user_visible_description: NSLocalizedString("Wallet provider returned an invalid response.", comment: "Error description shown to the user when a response from the wallet provider is invalid"),
tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"),
technical_info: "Wallet response does not make a reference to any request; No request ID `e` tag was found."
)
case .failedToDecodeJSON(let error):
.init(
user_visible_description: NSLocalizedString("Wallet provider returned a response that we do not understand.", comment: "Error description shown to the user when a response from the wallet provider contains data the app does not understand"),
tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"),
technical_info: "Failed to decode NWC Wallet response JSON. Error: \(error)"
)
case .failedToDecrypt(let error):
.init(
user_visible_description: NSLocalizedString("Wallet provider returned a response that we could not decrypt.", comment: "Error description shown to the user when a response from the wallet provider contains data the app could not decrypt."),
tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"),
technical_info: "Failed to decrypt NWC Wallet response. Error: \(error)"
)
}
}
}
extension WalletConnect.WalletResponseErr {
var humanReadableError: ErrorView.UserPresentableError? {
guard let code = self.code else {
return .init(
user_visible_description: String(format: NSLocalizedString("Your connected wallet raised an unknown error. Message: %s", comment: "Human readable error description for unknown error"), self.message ?? NSLocalizedString("Empty error message", comment: "A human readable placeholder to indicate that the error message is empty")),
tip: NSLocalizedString("Please contact the developer of your wallet provider for help.", comment: "Human readable error description for an unknown error raised by a wallet provider."),
technical_info: "NWC wallet provider returned an error response without a valid reason code. Message: \(self.message ?? "Empty error message")"
)
}
switch code {
case .rateLimited:
return .init(
user_visible_description: NSLocalizedString("Your wallet is temporarily being rate limited.", comment: "Error description for rate limit error"),
tip: NSLocalizedString("Wait a few moments, and then try again.", comment: "Tip for rate limit error"),
technical_info: "Wallet returned a rate limit error with message: \(self.message ?? "No further details provided")"
)
case .notImplemented:
return .init(
user_visible_description: NSLocalizedString("This feature is not implemented by your wallet.", comment: "Error description for not implemented feature"),
tip: NSLocalizedString("Please check for updates or contact your wallet provider.", comment: "Tip for not implemented error"),
technical_info: "Wallet reported a not implemented error. Message: \(self.message ?? "No further details provided")"
)
case .insufficientBalance:
return .init(
user_visible_description: NSLocalizedString("Your wallet does not have sufficient balance for this transaction.", comment: "Error description for insufficient balance"),
tip: NSLocalizedString("Please deposit more funds and try again.", comment: "Tip for insufficient balance errors"),
technical_info: "Wallet returned an insufficient balance error. Message: \(self.message ?? "No further details provided")"
)
case .quotaExceeded:
return .init(
user_visible_description: NSLocalizedString("Your transaction quota has been exceeded.", comment: "Error description for quota exceeded"),
tip: NSLocalizedString("Wait for the quota to reset, or configure your wallet provider to allow a higher limit.", comment: "Tip for quota exceeded"),
technical_info: "Wallet reported a quota exceeded error. Message: \(self.message ?? "No further details provided")"
)
case .restricted:
return .init(
user_visible_description: NSLocalizedString("This operation is restricted by your wallet.", comment: "Error description for restricted operation"),
tip: NSLocalizedString("Check your account permissions or contact support.", comment: "Tip for restricted operation"),
technical_info: "Wallet returned a restricted error. Message: \(self.message ?? "No further details provided")"
)
case .unauthorized:
return .init(
user_visible_description: NSLocalizedString("You are not authorized to perform this action with your wallet.", comment: "Error description for unauthorized access"),
tip: NSLocalizedString("Please verify your credentials or permissions.", comment: "Tip for unauthorized access"),
technical_info: "Wallet returned an unauthorized error. Message: \(self.message ?? "No further details provided")"
)
case .internalError:
return .init(
user_visible_description: NSLocalizedString("An internal error occurred in your wallet.", comment: "Error description for an internal error"),
tip: NSLocalizedString("Try restarting your wallet or contacting support if the problem persists.", comment: "Tip for internal error"),
technical_info: "Wallet reported an internal error. Message: \(self.message ?? "No further details provided")"
)
case .other:
return .init(
user_visible_description: NSLocalizedString("An unspecified error occurred in your wallet.", comment: "Error description for an unspecified error"),
tip: NSLocalizedString("Please try again or contact your wallet provider for further assistance.", comment: "Tip for unspecified error"),
technical_info: "Wallet returned an error: \(self.message ?? "No further details provided")"
)
}
}
}
+44 -4
View File
@@ -13,7 +13,11 @@ extension WalletConnect {
/// Pay an invoice
case payInvoice(
/// bolt-11 invoice string
invoice: String
invoice: String,
/// The full description of the invoice (If description does not fit in the BOLT-11 invoice, this is the pre-image of the description hash)
description: String?,
/// Optional metadata object containing more information
metadata: Metadata?
)
/// Get the current wallet balance
case getBalance
@@ -33,6 +37,38 @@ extension WalletConnect {
type: String?
)
static func payZapRequest(invoice: String, zapRequest: NostrEvent?) -> Self {
guard let zapRequest, let zapRequestEncoded = encode_json(zapRequest) else {
return WalletConnect.Request.payInvoice(
invoice: invoice,
description: nil,
metadata: nil
)
}
return WalletConnect.Request.payInvoice(
invoice: invoice,
description: zapRequestEncoded,
metadata: .init(nostr: zapRequest)
)
}
struct Metadata: Codable, Equatable, Hashable {
/// NIP-57-compliant `kind:9734` zap request event
let nostr: NostrEvent?
init(nostr: NostrEvent?) {
self.nostr = nostr
}
init(from decoder: any Decoder) throws {
let container: KeyedDecodingContainer<WalletConnect.Request.Metadata.CodingKeys> = try decoder.container(keyedBy: WalletConnect.Request.Metadata.CodingKeys.self)
guard let decodedZapRequest = try? container.decodeIfPresent(NostrEvent.self, forKey: WalletConnect.Request.Metadata.CodingKeys.nostr) else {
self.nostr = nil // Be lenient and fallback to nil if the NWC provider provided something invalid, since metadata is not strictly spec'd yet.
return
}
self.nostr = decodedZapRequest
}
}
// MARK: - Interface
@@ -61,7 +97,7 @@ extension WalletConnect {
/// Keys for the JSON inside the "params" object
private enum ParamKeys: String, CodingKey {
case invoice
case invoice, description, metadata
case from, until, limit, offset, unpaid, type
}
@@ -82,7 +118,9 @@ extension WalletConnect {
case Method.payInvoice.rawValue:
let paramsContainer = try container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
let invoice = try paramsContainer.decode(String.self, forKey: .invoice)
self = .payInvoice(invoice: invoice)
let description: String? = try paramsContainer.decodeIfPresent(String.self, forKey: .description)
let metadata: Metadata? = try paramsContainer.decodeIfPresent(Metadata.self, forKey: .metadata)
self = .payInvoice(invoice: invoice, description: description, metadata: metadata)
case Method.getBalance.rawValue:
// No params to decode
@@ -112,10 +150,12 @@ extension WalletConnect {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .payInvoice(let invoice):
case .payInvoice(let invoice, let description, let metadata):
try container.encode(Method.payInvoice.rawValue, forKey: .method)
var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
try paramsContainer.encode(invoice, forKey: .invoice)
try paramsContainer.encodeIfPresent(description, forKey: .description)
try paramsContainer.encodeIfPresent(metadata, forKey: .metadata)
case .getBalance:
try container.encode(Method.getBalance.rawValue, forKey: .method)
+70 -23
View File
@@ -5,6 +5,8 @@
// Created by Daniel DAquino on 2025-03-10.
//
import Combine
extension WalletConnect {
/// Models a response from the NWC provider
struct Response: Decodable {
@@ -50,35 +52,80 @@ extension WalletConnect {
let req_id: NoteId
let response: Response
init?(from: NostrEvent, nwc: WalletConnect.ConnectURL) async {
guard let note_id = from.referenced_ids.first else {
return nil
}
self.req_id = note_id
let ares = Task {
guard let json = decrypt_dm(nwc.keypair.privkey, pubkey: nwc.pubkey, content: from.content, encoding: .base64),
let resp: WalletConnect.Response = decode_json(json)
else {
let resp: WalletConnect.Response? = nil
return resp
}
return resp
}
init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) throws(InitializationError) {
guard event.pubkey == nwc.pubkey else { throw .incorrectAuthorPubkey }
guard let res = await ares.value else {
return nil
guard let referencedNoteId = event.referenced_ids.first else { throw .missingRequestIdReference }
self.req_id = referencedNoteId
var json = ""
do {
json = try NIP04.decryptContent(
recipientPrivateKey: nwc.keypair.privkey,
senderPubkey: nwc.pubkey,
content: event.content,
encoding: .base64
)
}
self.response = res
catch { throw .failedToDecrypt(error) }
do {
let response: WalletConnect.Response = try decode_json_throwing(json)
self.response = response
}
catch { throw .failedToDecodeJSON(error) }
}
enum InitializationError: Error {
case incorrectAuthorPubkey
case missingRequestIdReference
case failedToDecodeJSON(any Error)
case failedToDecrypt(any Error)
}
}
struct WalletResponseErr: Codable {
let code: String?
struct WalletResponseErr: Codable, Error {
let code: Code?
let message: String?
enum Code: String, Codable {
/// The client is sending commands too fast. It should retry in a few seconds.
case rateLimited = "RATE_LIMITED"
/// The command is not known or is intentionally not implemented.
case notImplemented = "NOT_IMPLEMENTED"
/// The wallet does not have enough funds to cover a fee reserve or the payment amount.
case insufficientBalance = "INSUFFICIENT_BALANCE"
/// The wallet has exceeded its spending quota.
case quotaExceeded = "QUOTA_EXCEEDED"
/// This public key is not allowed to do this operation.
case restricted = "RESTRICTED"
/// This public key has no wallet connected.
case unauthorized = "UNAUTHORIZED"
/// An internal error.
case internalError = "INTERNAL"
/// Other error.
case other = "OTHER"
}
enum CodingKeys: String, CodingKey {
case code, message
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Attempt to decode the code as a String
if let codeString = try container.decodeIfPresent(String.self, forKey: .code),
let validCode = Code(rawValue: codeString) {
self.code = validCode
} else {
// If the code is either missing or not one of the allowed cases, set it to nil
self.code = nil
}
self.message = try container.decodeIfPresent(String.self, forKey: .message)
}
}
}
+27 -3
View File
@@ -20,6 +20,7 @@ extension WalletConnect {
static func subscribe(url: WalletConnectURL, pool: RelayPool) {
var filter = NostrFilter(kinds: [.nwc_response])
filter.authors = [url.pubkey]
filter.pubkeys = [url.keypair.pubkey]
filter.limit = 0
let sub = NostrSubscribe(filters: [filter], sub_id: "nwc")
@@ -40,8 +41,9 @@ extension WalletConnect {
/// - on_flush: A callback to call after the event has been flushed to the network
/// - Returns: The Nostr Event that was sent to the network, representing the request that was made
@discardableResult
static func pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? {
let req = WalletConnect.Request.payInvoice(invoice: invoice)
static func pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, zap_request: NostrEvent?, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? {
let req = WalletConnect.Request.payZapRequest(invoice: invoice, zapRequest: zap_request)
guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else {
return nil
}
@@ -103,6 +105,28 @@ extension WalletConnect {
post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush)
return ev
}
@MainActor
static func refresh_wallet_information(damus_state: DamusState) async {
damus_state.wallet.resetWalletStateInformation()
await Self.update_wallet_information(damus_state: damus_state)
}
@MainActor
static func update_wallet_information(damus_state: DamusState) async {
guard let url = damus_state.settings.nostr_wallet_connect,
let nwc = WalletConnectURL(str: url) else {
return
}
let flusher: OnFlush? = nil
let delay = 0.0 // We don't need a delay when fetching a transaction list or balance
WalletConnect.request_transaction_list(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher)
WalletConnect.request_balance_information(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher)
return
}
static func handle_zap_success(state: DamusState, resp: WalletConnect.FullWalletResponse) {
// find the pending zap and mark it as pending-confirmed
@@ -142,7 +166,7 @@ extension WalletConnect {
}
print("damus-donation donating...")
WalletConnect.pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil)
WalletConnect.pay(url: nwc, pool: pool, post: postbox, invoice: invoice, zap_request: nil, delay: nil)
}
/// Handles a received Nostr Wallet Connect error
+1 -1
View File
@@ -86,7 +86,7 @@ extension WalletConnect {
let created_at: UInt64 // unixtimestamp, // invoice/payment creation time
let expires_at: UInt64? // unixtimestamp, // invoice expiration time, optional if not applicable
let settled_at: UInt64? // unixtimestamp, // invoice/payment settlement time, optional if unpaid
//"metadata": {} // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc.
let metadata: WalletConnect.Request.Metadata? // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc.
}
}
+15 -4
View File
@@ -217,7 +217,16 @@ struct EventActionBar: View {
AnyView(self.action_bar_content)
}
}
var event_relay_url_strings: [String] {
let relays = damus_state.nostrNetwork.relaysForEvent(event: event)
if !relays.isEmpty {
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString }
}
return userProfile.getCappedRelayStrings()
}
var body: some View {
self.content
.onAppear {
@@ -233,7 +242,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 }) {
@@ -262,7 +273,7 @@ struct EventActionBar: View {
func send_like(emoji: String) {
guard let keypair = damus_state.keypair.to_full(),
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
return
}
@@ -270,7 +281,7 @@ struct EventActionBar: View {
generator.impactOccurred()
damus_state.postbox.send(like_ev)
damus_state.nostrNetwork.postbox.send(like_ev)
}
// MARK: Helper structures
+2 -2
View File
@@ -21,11 +21,11 @@ struct RepostAction: View {
dismiss()
guard let keypair = self.damus_state.keypair.to_full(),
let boost = make_boost_event(keypair: keypair, boosted: self.event) else {
let boost = make_boost_event(keypair: keypair, boosted: self.event, relayURL: damus_state.nostrNetwork.relaysForEvent(event: self.event).first) else {
return
}
damus_state.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)
+11 -2
View File
@@ -26,7 +26,16 @@ struct ShareAction: View {
self.userProfile = userProfile
self._show_share = show_share
}
var event_relay_url_strings: [String] {
let relays = userProfile.damus.nostrNetwork.relaysForEvent(event: event)
if !relays.isEmpty {
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString }
}
return userProfile.getCappedRelayStrings()
}
var body: some View {
VStack {
@@ -40,7 +49,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(event: event, relays: event_relay_url_strings)))
}
let bookmarkImg = isBookmarked ? "bookmark.fill" : "bookmark"
+20 -24
View File
@@ -15,6 +15,8 @@ struct AddRelayView: View {
@Environment(\.dismiss) var dismiss
typealias UpdateError = NostrNetworkManager.UserRelayListManager.UpdateError
var body: some View {
VStack {
Text("Add relay", comment: "Title text to indicate user to an add a relay.")
@@ -82,38 +84,21 @@ struct AddRelayView: View {
new_relay = "wss://" + new_relay
}
guard let url = RelayURL(new_relay),
let ev = state.contacts.event,
let keypair = state.keypair.to_full() else {
guard let url = RelayURL(new_relay) else {
relayAddErrorTitle = NSLocalizedString("Invalid relay address", comment: "Heading for an error when adding a relay")
relayAddErrorMessage = NSLocalizedString("Please check the address and try again", comment: "Tip for an error where the relay address being added is invalid")
return
}
let info = RelayInfo.rw
let descriptor = RelayDescriptor(url: url, info: info)
do {
try state.pool.add_relay(descriptor)
try state.nostrNetwork.userRelayList.insert(relay: NIP65.RelayList.RelayItem(url: url, rwConfiguration: .readWrite))
relayAddErrorTitle = nil // Clear error title
relayAddErrorMessage = nil // Clear error message
} catch RelayError.RelayAlreadyExists {
relayAddErrorTitle = NSLocalizedString("Duplicate relay", comment: "Title of the duplicate relay error message.")
relayAddErrorMessage = NSLocalizedString("The relay you are trying to add is already added.\nYou're all set!", comment: "An error message that appears when the user attempts to add a relay that has already been added.")
return
} catch {
return
}
catch {
present_sheet(.error(self.humanReadableError(for: error)))
}
state.pool.connect(to: [url])
if let new_ev = add_relay(ev: ev, keypair: keypair, current_relays: state.pool.our_descriptors, relay: url, info: info) {
process_contact_event(state: state, ev: ev)
state.pool.send(.event(new_ev))
}
if let relay_metadata = make_relay_metadata(relays: state.pool.our_descriptors, keypair: keypair) {
state.postbox.send(relay_metadata)
}
new_relay = ""
this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
@@ -134,6 +119,17 @@ struct AddRelayView: View {
}
.padding()
}
func humanReadableError(for error: any Error) -> ErrorView.UserPresentableError {
guard let error = error as? UpdateError else {
return .init(
user_visible_description: NSLocalizedString("An unknown error occurred while adding a relay.", comment: "Title of an unknown relay error message."),
tip: NSLocalizedString("Please contact support.", comment: "Tip for an unknown relay error message."),
technical_info: error.localizedDescription
)
}
return error.humanReadableError
}
}
// TODO
@@ -28,6 +28,15 @@ enum AppAccessibilityIdentifiers: String {
// 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
-44
View File
@@ -1,44 +0,0 @@
//
// FriendsButton.swift
// damus
//
// Created by William Casarin on 2023-04-21.
//
import SwiftUI
struct FriendsButton: View {
@Binding var filter: FriendFilter
var body: some View {
Button(action: {
switch self.filter {
case .all:
self.filter = .friends_of_friends
case .friends_of_friends:
self.filter = .all
}
}) {
if filter == .friends_of_friends {
LINEAR_GRADIENT
.mask(Image("user-added")
.resizable()
).frame(width: 28, height: 28)
} else {
Image("user-added")
.resizable()
.frame(width: 28, height: 28)
.foregroundColor(.gray)
}
}
.buttonStyle(.plain)
}
}
struct FriendsButton_Previews: PreviewProvider {
@State static var enabled: FriendFilter = .all
static var previews: some View {
FriendsButton(filter: $enabled)
}
}
@@ -0,0 +1,54 @@
//
// TrustedNetworkButton.swift
// damus
//
// Created by William Casarin on 2023-04-21.
//
import SwiftUI
struct TrustedNetworkButton: View {
@Binding var filter: FriendFilter
var action: (@MainActor () -> Void)? = nil
var MainButton: some View {
Button(action: {
switch self.filter {
case .all:
self.filter = .friends_of_friends
case .friends_of_friends:
self.filter = .all
}
if let action {
action()
}
}) {
if filter == .friends_of_friends {
LINEAR_GRADIENT
.mask(Image(systemName: "network.badge.shield.half.filled")
.frame(width: 24, height: 24)
)
.scaledToFit()
.frame(width: 24, height: 24)
} else {
Image(systemName: "network.slash")
.frame(width: 24, height: 24)
.foregroundColor(.gray)
}
}
.buttonStyle(.plain)
}
var body: some View {
MainButton
}
}
struct TrustedNetworkButton_Previews: PreviewProvider {
@State static var enabled: FriendFilter = .all
static var previews: some View {
TrustedNetworkButton(filter: $enabled)
}
}
+2 -8
View File
@@ -235,7 +235,7 @@ struct ChatEventView: View {
func send_like(emoji: String) {
guard let keypair = damus_state.keypair.to_full(),
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
return
}
@@ -244,7 +244,7 @@ struct ChatEventView: View {
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.impactOccurred()
damus_state.postbox.send(like_ev)
damus_state.nostrNetwork.postbox.send(like_ev)
}
var action_bar: some View {
@@ -337,12 +337,6 @@ struct ChatEventView: View {
}
}
extension Notification.Name {
static var toggle_thread_view: Notification.Name {
return Notification.Name("convert_to_thread")
}
}
#Preview {
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
+201 -94
View File
@@ -7,6 +7,7 @@
import SwiftUI
import SwipeActions
import TipKit
struct ChatroomThreadView: View {
@Environment(\.dismiss) var dismiss
@@ -15,11 +16,20 @@ struct ChatroomThreadView: View {
@ObservedObject var thread: ThreadModel
@State var highlighted_note_id: NoteId? = nil
@State var user_just_posted_flag: Bool = false
@State var untrusted_network_expanded: Bool = true
@Namespace private var animation
// Add state for sticky header
@State var showStickyHeader: Bool = false
@State var untrustedSectionOffset: CGFloat = 0
private static let untrusted_network_section_id = "untrusted-network-section"
private static let sticky_header_adjusted_anchor = UnitPoint(x: UnitPoint.top.x, y: 0.2)
func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) {
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: .top)
let adjustedAnchor: UnitPoint = showStickyHeader ? ChatroomThreadView.sticky_header_adjusted_anchor : .top
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: adjustedAnchor)
highlighted_note_id = note_id
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
withAnimation {
@@ -27,7 +37,7 @@ struct ChatroomThreadView: View {
}
})
}
func set_active_event(scroller: ScrollViewProxy, ev: NdbNote) {
withAnimation {
self.thread.select(event: ev)
@@ -35,93 +45,202 @@ struct ChatroomThreadView: View {
}
}
func trusted_event_filter(_ event: NostrEvent) -> Bool {
!damus.settings.show_trusted_replies_first || damus.contacts.is_in_friendosphere(event.pubkey)
}
func ThreadedSwipeViewGroup(scroller: ScrollViewProxy, events: [NostrEvent]) -> some View {
SwipeViewGroup {
ForEach(Array(zip(events, events.indices)), id: \.0.id) { (ev, ind) in
ChatEventView(event: events[ind],
selected_event: self.thread.selected_event,
prev_ev: ind > 0 ? events[ind-1] : nil,
next_ev: ind == events.count-1 ? nil : events[ind+1],
damus_state: damus,
thread: thread,
scroll_to_event: { note_id in
self.go_to_event(scroller: scroller, note_id: note_id)
},
focus_event: {
self.set_active_event(scroller: scroller, ev: ev)
},
highlight_bubble: highlighted_note_id == ev.id,
bar: make_actionbar_model(ev: ev.id, damus: damus)
)
.id(ev.id)
.matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center)
.padding(.horizontal)
}
}
}
var OutsideTrustedNetworkLabel: some View {
HStack {
Label(
NSLocalizedString(
"Replies outside your trusted network",
comment: "Section title in thread for replies from outside of the current user's trusted network, which is their follows and follows of follows."),
systemImage: "network.slash"
)
Spacer()
Image(systemName: "chevron.right")
.rotationEffect(.degrees(untrusted_network_expanded ? 90 : 0))
.animation(.easeInOut(duration: 0.1), value: untrusted_network_expanded)
}
.foregroundColor(.secondary)
}
var StickyHeaderView: some View {
OutsideTrustedNetworkLabel
.padding(.horizontal)
.padding(.vertical, 12)
.background(
Color(UIColor.systemBackground)
.shadow(color: .black.opacity(0.15), radius: 3, x: 0, y: 2)
)
}
var body: some View {
ScrollViewReader { scroller in
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: 8) {
// MARK: - Parents events view
ForEach(thread.parent_events, id: \.id) { parent_event in
EventMutingContainerView(damus_state: damus, event: parent_event) {
EventView(damus: damus, event: parent_event)
.matchedGeometryEffect(id: parent_event.id.hex(), in: animation, anchor: .center)
}
.padding(.horizontal)
.onTapGesture {
self.set_active_event(scroller: scroller, ev: parent_event)
}
.id(parent_event.id)
Divider()
.padding(.top, 4)
.padding(.leading, 25 * 2)
}.background(GeometryReader { geometry in
// get the height and width of the EventView view
let eventHeight = geometry.frame(in: .global).height
// let eventWidth = geometry.frame(in: .global).width
// vertical gray line in the background
Rectangle()
.fill(Color.gray.opacity(0.25))
.frame(width: 2, height: eventHeight)
.offset(x: 40, y: 40)
})
// MARK: - Actual event view
EventMutingContainerView(
damus_state: damus,
event: self.thread.selected_event,
muteBox: { event_shown, muted_reason in
AnyView(
EventMutedBoxView(shown: event_shown, reason: muted_reason)
.padding(5)
)
}
) {
SelectedEventView(damus: damus, event: self.thread.selected_event, size: .selected)
.matchedGeometryEffect(id: self.thread.selected_event.id.hex(), in: animation, anchor: .center)
}
.id(self.thread.selected_event.id)
// MARK: - Children view
let events = thread.sorted_child_events
let count = events.count
SwipeViewGroup {
ForEach(Array(zip(events, events.indices)), id: \.0.id) { (ev, ind) in
ChatEventView(event: events[ind],
selected_event: self.thread.selected_event,
prev_ev: ind > 0 ? events[ind-1] : nil,
next_ev: ind == count-1 ? nil : events[ind+1],
damus_state: damus,
thread: thread,
scroll_to_event: { note_id in
self.go_to_event(scroller: scroller, note_id: note_id)
},
focus_event: {
self.set_active_event(scroller: scroller, ev: ev)
},
highlight_bubble: highlighted_note_id == ev.id,
bar: make_actionbar_model(ev: ev.id, damus: damus)
)
let sorted_child_events = thread.sorted_child_events
let untrusted_events = sorted_child_events.filter { !trusted_event_filter($0) }
let trusted_events = sorted_child_events.filter { trusted_event_filter($0) }
ZStack(alignment: .top) {
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: 8) {
// MARK: - Parents events view
ForEach(thread.parent_events, id: \.id) { parent_event in
EventMutingContainerView(damus_state: damus, event: parent_event) {
EventView(damus: damus, event: parent_event)
.matchedGeometryEffect(id: parent_event.id.hex(), in: animation, anchor: .center)
}
.padding(.horizontal)
.id(ev.id)
.matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center)
.onTapGesture {
self.set_active_event(scroller: scroller, ev: parent_event)
}
.id(parent_event.id)
Divider()
.padding(.top, 4)
.padding(.leading, 25 * 2)
}.background(GeometryReader { geometry in
let eventHeight = geometry.frame(in: .global).height
Rectangle()
.fill(Color.gray.opacity(0.25))
.frame(width: 2, height: eventHeight)
.offset(x: 40, y: 40)
})
// MARK: - Actual event view
EventMutingContainerView(
damus_state: damus,
event: self.thread.selected_event,
muteBox: { event_shown, muted_reason in
AnyView(
EventMutedBoxView(shown: event_shown, reason: muted_reason)
.padding(5)
)
}
) {
SelectedEventView(damus: damus, event: self.thread.selected_event, size: .selected)
.matchedGeometryEffect(id: self.thread.selected_event.id.hex(), in: animation, anchor: .center)
}
.id(self.thread.selected_event.id)
// MARK: - Children view - inside trusted network
if !trusted_events.isEmpty {
ThreadedSwipeViewGroup(scroller: scroller, events: trusted_events)
}
}
.padding(.top)
// MARK: - Children view - outside trusted network
if !untrusted_events.isEmpty {
if #available(iOS 17, *) {
TipView(TrustedNetworkRepliesTip.shared, arrowEdge: .bottom)
.padding(.top, 10)
.padding(.horizontal)
}
VStack(alignment: .leading, spacing: 0) {
// Track this section's position
Color.clear
.frame(height: 1)
.background(
GeometryReader { proxy in
Color.clear
.onAppear {
untrustedSectionOffset = proxy.frame(in: .global).minY
}
.onChange(of: proxy.frame(in: .global).minY) { newY in
let shouldShow = newY <= 100 // Adjust this threshold as needed
if shouldShow != showStickyHeader {
withAnimation(.easeInOut(duration: 0.3)) {
showStickyHeader = shouldShow
}
}
}
}
)
Button(action: {
withAnimation {
untrusted_network_expanded.toggle()
if #available(iOS 17, *) {
TrustedNetworkRepliesTip.shared.invalidate(reason: .actionPerformed)
}
scroll_to_event(scroller: scroller, id: ChatroomThreadView.untrusted_network_section_id, delay: 0.1, animate: true, anchor: ChatroomThreadView.sticky_header_adjusted_anchor)
}
}) {
OutsideTrustedNetworkLabel
}
.id(ChatroomThreadView.untrusted_network_section_id)
.buttonStyle(PlainButtonStyle())
.padding(.horizontal)
if untrusted_network_expanded {
withAnimation {
LazyVStack(alignment: .leading, spacing: 8) {
ThreadedSwipeViewGroup(scroller: scroller, events: untrusted_events)
}
.padding(.top, 10)
}
}
}
}
EndBlock()
HStack {}
.frame(height: tabHeight + getSafeAreaBottom())
}
if showStickyHeader && !untrusted_events.isEmpty {
VStack {
StickyHeaderView
.onTapGesture {
withAnimation {
untrusted_network_expanded.toggle()
}
}
Spacer()
}
.transition(.move(edge: .top).combined(with: .opacity))
.zIndex(1)
}
.padding(.top)
EndBlock()
HStack {}
.frame(height: tabHeight + getSafeAreaBottom())
}
.onReceive(handle_notify(.post), perform: { notify in
switch notify {
case .post(_):
user_just_posted_flag = true
case .cancel:
return
case .post(_):
user_just_posted_flag = true
case .cancel:
return
}
})
.onReceive(thread.objectWillChange) {
@@ -139,15 +258,8 @@ struct ChatroomThreadView: View {
}
}
}
func toggle_thread_view() {
NotificationCenter.default.post(name: .toggle_thread_view, object: nil)
}
}
struct ChatroomView_Previews: PreviewProvider {
static var previews: some View {
Group {
@@ -167,8 +279,3 @@ struct ChatroomView_Previews: PreviewProvider {
}
}
}
@MainActor
func scroll_after_load(thread: ThreadModel, proxy: ScrollViewProxy) {
scroll_to_event(scroller: proxy, id: thread.selected_event.id, delay: 0.1, animate: false)
}
+2 -2
View File
@@ -161,7 +161,7 @@ struct ConfigView: View {
}
.navigationTitle(NSLocalizedString("Settings", comment: "Navigation title for Settings view."))
.navigationBarTitleDisplayMode(.large)
.searchable(text: $searchText,prompt: "Search within settings")
.searchable(text: $searchText, prompt: NSLocalizedString("Search within settings", comment: "Text to prompt the user to search settings."))
.alert(NSLocalizedString("WARNING:\n\nTHIS WILL SIGN AN EVENT THAT DELETES THIS ACCOUNT.\n\nYOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.\n\n ARE YOU SURE YOU WANT TO CONTINUE?", comment: "Alert for deleting the users account."), isPresented: $delete_account_warning) {
Button(NSLocalizedString("Cancel", comment: "Cancel deleting the user."), role: .cancel) {
@@ -182,7 +182,7 @@ struct ConfigView: View {
let ev = created_deleted_account_profile(keypair: keypair) else {
return
}
state.postbox.send(ev)
state.nostrNetwork.postbox.send(ev)
logout(state)
}
}
+1 -1
View File
@@ -138,7 +138,7 @@ struct DMChatView: View, KeyboardReadable {
dms.draft = ""
damus_state.postbox.send(dm)
damus_state.nostrNetwork.postbox.send(dm)
handle_incoming_dm(ev: dm, our_pubkey: damus_state.pubkey, dms: damus_state.dms, prev_events: NewEventsBits())

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