Compare commits

...

108 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
150 changed files with 8970 additions and 1165 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
+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",
@@ -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

+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)
}
}
}
+1 -1
View File
@@ -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.nostrNetwork.pool, post: damus_state.nostrNetwork.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)")
+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))
+37 -3
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)
@@ -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:
@@ -686,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!
@@ -704,6 +719,21 @@ struct ContentView: View {
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) {
@@ -742,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
@@ -758,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
}
+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")
}
}
}
}
+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
}
}
+8 -4
View File
@@ -36,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(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, 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
@@ -68,7 +69,8 @@ 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)
}
@@ -126,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()
)
}
@@ -194,7 +197,8 @@ class DamusState: HeadlessDamusState {
video: DamusVideoCoordinator(),
ndb: .empty,
quote_reposts: .init(our_pubkey: empty_pub),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: FaviconCache()
)
}
}
+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
}
}
}
+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.")
}
}
}
+29 -18
View File
@@ -227,6 +227,10 @@ class HomeModel: ContactsDelegate {
break
case .relay_list:
break // This will be handled by `UserRelayListManager`
case .follow_list:
break
case .interest_list:
break // Don't care for now
}
}
@@ -261,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.nostrNetwork.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) {
print("nwc: got response, removed \(resp.req_id) from the postbox [\(relay)]")
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
}
+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 {
+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)
}
}
}
@@ -51,6 +51,16 @@ class NostrNetworkManager {
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 []
}
}
+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 {
+8 -1
View File
@@ -54,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
}
+1 -3
View File
@@ -22,8 +22,6 @@ class ProfileModel: ObservableObject, Equatable {
}
return nil
}
private let MAX_SHARE_RELAYS = 4
var events: EventHolder
let pubkey: Pubkey
@@ -222,7 +220,7 @@ class ProfileModel: ObservableObject, Equatable {
}
func getCappedRelayStrings() -> [String] {
return self.relay_urls?.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
return self.relay_urls?.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
}
}
+8 -2
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
@@ -42,12 +42,18 @@ class SearchHomeModel: ObservableObject {
func subscribe() {
loading = true
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.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()
+1 -1
View File
@@ -36,7 +36,7 @@ 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!
+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)
}
}
+22 -3
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
@@ -174,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
+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
}
}
+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)
)
}
}
}
+9 -1
View File
@@ -143,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
+29 -6
View File
@@ -379,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 {
@@ -444,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 {
@@ -463,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)
}
@@ -539,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
+2
View File
@@ -20,6 +20,7 @@ enum NostrKind: UInt32, Codable {
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
@@ -30,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 {
+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] = [:]
+5 -14
View File
@@ -19,17 +19,12 @@ 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 {
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
@@ -357,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
}
}
}
+2 -1
View File
@@ -106,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)
}
+16 -11
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
@@ -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:
+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
+19
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 {
@@ -127,6 +131,12 @@ enum Route: Hashable {
FollowersYouKnowView(damus_state: damusState, friended_followers: friendedFollowers, followers: followers)
case .Script(let 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)
}
}
@@ -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.
}
}
+14 -3
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
}
+1 -1
View File
@@ -21,7 +21,7 @@ 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
}
+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"
@@ -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)
}
}
+1 -7
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
}
@@ -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)
}
+24 -4
View File
@@ -6,6 +6,7 @@
//
import SwiftUI
import TipKit
enum DMType: Hashable {
case rando
@@ -18,6 +19,7 @@ struct DirectMessagesView: View {
@State var dm_type: DMType = .friend
@ObservedObject var model: DirectMessagesModel
@ObservedObject var settings: UserSettingsStore
@Binding var subtitle: String?
func MainContent(requests: Bool) -> some View {
ScrollView {
@@ -72,7 +74,15 @@ struct DirectMessagesView: View {
}
var body: some View {
let showTrustedButton = would_filter_non_friends_from_dms(contacts: damus_state.contacts, dms: self.model.dms)
VStack(spacing: 0) {
if #available(iOS 17, *), showTrustedButton {
TipView(TrustedNetworkButtonTip.shared)
.tipBackground(.clear)
.tipViewStyle(TrustedNetworkButtonTipViewStyle())
.padding(.horizontal)
}
CustomPicker(tabs: [
(NSLocalizedString("DMs", comment: "Picker option for DM selector for seeing only DMs that have been responded to. DM is the English abbreviation for Direct Message."), DMType.friend),
(NSLocalizedString("Requests", comment: "Picker option for DM selector for seeing only message requests (DMs that someone else sent the user which has not been responded to yet"), DMType.rando),
@@ -92,12 +102,22 @@ struct DirectMessagesView: View {
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if would_filter_non_friends_from_dms(contacts: damus_state.contacts, dms: self.model.dms) {
FriendsButton(filter: $settings.friend_filter)
if showTrustedButton {
TrustedNetworkButton(filter: $settings.friend_filter) {
if #available(iOS 17, *) {
TrustedNetworkButtonTip.shared.invalidate(reason: .actionPerformed)
}
}
}
}
}
.onAppear {
self.subtitle = settings.friend_filter.description()
}
.onChange(of: settings.friend_filter) { val in
self.subtitle = val.description()
}
.navigationTitle(NSLocalizedString("DMs", comment: "Navigation title for view of DMs, where DM is an English abbreviation for Direct Message."))
}
}
@@ -115,6 +135,6 @@ func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageMo
struct DirectMessagesView_Previews: PreviewProvider {
static var previews: some View {
let ds = test_damus_state
DirectMessagesView(damus_state: ds, model: ds.dms, settings: ds.settings)
DirectMessagesView(damus_state: ds, model: ds.dms, settings: ds.settings, subtitle: .constant(nil))
}
}
+38 -1
View File
@@ -50,6 +50,10 @@ struct ErrorView: View {
.cornerRadius(10)
.padding(.vertical, 30)
if let technical_info = error.technical_info {
ErrorTechInfoCopyButton(errorInfo: technical_info)
}
Spacer()
if let damus_state, damus_state.is_privkey_user {
@@ -69,6 +73,39 @@ struct ErrorView: View {
.padding(.top, 20)
}
struct ErrorTechInfoCopyButton: View {
let errorInfo: String
@State var copied: Bool = false
var body: some View {
VStack {
if !copied {
Button(action: {
UIPasteboard.general.string = errorInfo
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
copied = false
}
}, label: {
HStack {
Image(systemName: "square.on.square.dashed")
Text("Copy technical information", comment: "Button label to allow user to copy technical information from an error screen (usually to provide our support team for further troubleshooting)")
}
})
}
else {
HStack {
Image(systemName: "checkmark.circle")
Text("Copied!", comment: "Label indicating that the error technical information was successfully copied to the clipboard, which shows up as soon as the user clicks the copy button.")
}
.foregroundStyle(.damusGreen)
}
}
.padding(.vertical, 20)
}
}
/// An error that is displayed to the user, and can be sent to the Developers as well.
struct UserPresentableError {
/// The description of the error to be shown to the user
@@ -113,7 +150,7 @@ struct ErrorView: View {
error: .init(
user_visible_description: "We are still too early",
tip: "Stay humble, keep building, stack sats",
technical_info: nil
technical_info: "UTXOs too small, must stack more sats"
)
)
}
+11 -2
View File
@@ -63,7 +63,16 @@ struct MenuItems: View {
self.target_pubkey = target_pubkey
self.profileModel = profileModel
}
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 profileModel.getCappedRelayStrings()
}
var body: some View {
Group {
Button {
@@ -79,7 +88,7 @@ struct MenuItems: View {
}
Button {
UIPasteboard.general.string = event.id.bech32
UIPasteboard.general.string = Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))
} label: {
Label(NSLocalizedString("Copy note ID", comment: "Context menu option for copying the ID of the note."), image: "note-book")
}
@@ -0,0 +1,242 @@
//
// FollowPackPreview.swift
// damus
//
// Created by eric on 4/30/25.
//
import SwiftUI
import Kingfisher
struct FollowPackUsers: View {
let state: DamusState
var publicKeys: [Pubkey]
var body: some View {
HStack(alignment: .center) {
if !publicKeys.isEmpty {
CondensedProfilePicturesView(state: state, pubkeys: publicKeys, maxPictures: 5)
}
let followPackUserCount = publicKeys.count
let nounString = pluralizedString(key: "follow_pack_user_count", count: followPackUserCount)
let nounText = Text(verbatim: nounString).font(.subheadline).foregroundColor(.gray)
Text("\(Text(verbatim: followPackUserCount.formatted()).font(.subheadline.weight(.medium))) \(nounText)", comment: "Sentence composed of 2 variables to describe how many people are in the follow pack. In source English, the first variable is the number of users, and the second variable is 'user' or 'users'.")
}
}
}
struct FollowPackBannerImage: View {
let state: DamusState
let options: EventViewOptions
var image: URL? = nil
var preview: Bool
@State var blur_imgs: Bool
func Placeholder(url: URL, preview: Bool) -> some View {
Group {
if let meta = state.events.lookup_img_metadata(url: url),
case .processed(let blurhash) = meta.state {
Image(uiImage: blurhash)
.resizable()
.frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200)
} else {
DamusColors.adaptableWhite
}
}
}
func titleImage(url: URL, preview: Bool) -> some View {
KFAnimatedImage(url)
.callbackQueue(.dispatch(.global(qos:.background)))
.backgroundDecode(true)
.imageContext(.note, disable_animation: state.settings.disable_animation)
.image_fade(duration: 0.25)
.cancelOnDisappear(true)
.configure { view in
view.framePreloadCount = 3
}
.background {
Placeholder(url: url, preview: preview)
}
.aspectRatio(contentMode: .fill)
.frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200)
.kfClickable()
.cornerRadius(1)
}
var body: some View {
if let url = image {
if (self.options.contains(.no_media)) {
EmptyView()
} else if !blur_imgs {
titleImage(url: url, preview: preview)
} else {
ZStack {
titleImage(url: url, preview: preview)
BlurOverlayView(blur_images: $blur_imgs, artifacts: nil, size: nil, damus_state: nil, parentView: .longFormView)
.frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200)
}
}
} else {
Text(NSLocalizedString("No cover image", comment: "Text letting user know there is no cover image."))
.foregroundColor(.gray)
.frame(width: 350, height: 180)
Divider()
}
}
}
struct FollowPackPreviewBody: View {
let state: DamusState
let event: FollowPackEvent
let options: EventViewOptions
let header: Bool
@State var blur_imgs: Bool
@ObservedObject var artifacts: NoteArtifactsModel
init(state: DamusState, ev: FollowPackEvent, options: EventViewOptions, header: Bool, blur_imgs: Bool) {
self.state = state
self.event = ev
self.options = options
self.header = header
self.blur_imgs = blur_imgs
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model)
}
init(state: DamusState, ev: NostrEvent, options: EventViewOptions, header: Bool, blur_imgs: Bool) {
self.state = state
self.event = FollowPackEvent.parse(from: ev)
self.options = options
self.header = header
self.blur_imgs = blur_imgs
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
}
var body: some View {
Group {
if options.contains(.wide) {
Main.padding(.horizontal)
} else {
Main
}
}
}
var Main: some View {
VStack(alignment: .leading, spacing: 10) {
if state.settings.media_previews {
FollowPackBannerImage(state: state, options: options, image: event.image, preview: true, blur_imgs: blur_imgs)
}
Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of follow list event if it is untitled."))
.font(header ? .title : .headline)
.padding(.horizontal, 10)
.padding(.top, 5)
if let description = event.description {
Text(description)
.font(header ? .body : .caption)
.foregroundColor(.gray)
.padding(.horizontal, 10)
} else {
Text("")
.font(header ? .body : .caption)
.foregroundColor(.gray)
.padding(.horizontal, 10)
}
HStack(alignment: .center) {
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true)
.onTapGesture {
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
}
let profile_txn = state.profiles.lookup(id: event.event.pubkey)
let profile = profile_txn?.unsafeUnownedValue
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
switch displayName {
case .one(let one):
Text(one)
.font(.subheadline).foregroundColor(.gray)
case .both(username: let username, displayName: let displayName):
HStack(spacing: 6) {
Text(verbatim: displayName)
.font(.subheadline).foregroundColor(.gray)
Text(verbatim: "@\(username)")
.font(.subheadline).foregroundColor(.gray)
}
}
}
.padding(.horizontal, 10)
FollowPackUsers(state: state, publicKeys: event.publicKeys)
.padding(.horizontal, 10)
.padding(.bottom, 20)
}
.frame(width: 350, height: state.settings.media_previews ? 330 : 150, alignment: .leading)
.background(DamusColors.neutral3)
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(DamusColors.neutral1, lineWidth: 1)
)
.padding(.top, 10)
}
}
struct FollowPackPreview: View {
let state: DamusState
let event: FollowPackEvent
let options: EventViewOptions
@State var blur_imgs: Bool
init(state: DamusState, ev: NostrEvent, options: EventViewOptions, blur_imgs: Bool) {
self.state = state
self.event = FollowPackEvent.parse(from: ev)
self.options = options.union(.no_mentions)
self.blur_imgs = blur_imgs
}
var body: some View {
FollowPackPreviewBody(state: state, ev: event, options: options, header: false, blur_imgs: blur_imgs)
}
}
let test_follow_list_event = FollowPackEvent.parse(from: NostrEvent(
content: "",
keypair: test_keypair,
kind: NostrKind.longform.rawValue,
tags: [
["title", "DAMUSES"],
["description", "Damus Team"],
["published_at", "1685638715"],
["p", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],
["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"],
["p", "17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4"],
["p", "520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626"],
["p", "2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1"],
["p", "bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91"],
["p", "e7424ad457e512fdf4764a56bf6d428a06a13a1006af1fb8e0fe32f6d03265c7"],
["p", "b88c7f007bbf3bc2fcaeff9e513f186bab33782c0baa6a6cc12add78b9110ba3"],
["p", "4a0510f26880d40e432f4865cb5714d9d3c200ca6ebb16b418ae6c555f574967"],
["image", "https://damus.io/img/logo.png"],
])!
)
struct FollowPackPreview_Previews: PreviewProvider {
static var previews: some View {
VStack {
FollowPackPreview(state: test_damus_state, ev: test_follow_list_event.event, options: [], blur_imgs: false)
}
.frame(height: 400)
}
}
@@ -0,0 +1,135 @@
//
// FollowPackTimeline.swift
// damus
//
// Created by eric on 5/6/25.
//
import SwiftUI
struct FollowPackTimelineView<Content: View>: View {
@ObservedObject var events: EventHolder
@Binding var loading: Bool
let damus: DamusState
let show_friend_icon: Bool
let filter: (NostrEvent) -> Bool
let content: Content?
let apply_mute_rules: Bool
init(events: EventHolder, loading: Binding<Bool>, headerHeight: Binding<CGFloat>, headerOffset: Binding<CGFloat>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
self.events = events
self._loading = loading
self.damus = damus
self.show_friend_icon = show_friend_icon
self.filter = filter
self.apply_mute_rules = apply_mute_rules
self.content = content?()
}
init(events: EventHolder, loading: Binding<Bool>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
self.events = events
self._loading = loading
self.damus = damus
self.show_friend_icon = show_friend_icon
self.filter = filter
self.apply_mute_rules = apply_mute_rules
self.content = content?()
}
var body: some View {
MainContent
}
var MainContent: some View {
ScrollViewReader { scroller in
ScrollView(.horizontal) {
if let content {
content
}
Color.clear
.id("startblock")
.frame(height: 0)
FollowPackInnerView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
.redacted(reason: loading ? .placeholder : [])
.shimmer(loading)
.disabled(loading)
.background {
GeometryReader { proxy -> Color in
handle_scroll_queue(proxy, queue: self.events)
return Color.clear
}
}
}
.coordinateSpace(name: "scroll")
.onReceive(handle_notify(.scroll_to_top)) { () in
events.flush()
self.events.should_queue = false
scroll_to_event(scroller: scroller, id: "startblock", delay: 0.0, animate: true, anchor: .top)
}
}
.onAppear {
events.flush()
}
}
}
struct FollowPackInnerView: View {
@ObservedObject var events: EventHolder
let state: DamusState
let filter: (NostrEvent) -> Bool
init(events: EventHolder, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true) {
self.events = events
self.state = damus
self.filter = apply_mute_rules ? { filter($0) && !damus.mutelist_manager.is_event_muted($0) } : filter
}
var event_options: EventViewOptions {
if self.state.settings.truncate_timeline_text {
return [.wide, .truncate_content]
}
return [.wide]
}
var body: some View {
LazyHStack(spacing: 0) {
let events = self.events.events
if events.isEmpty {
EmptyTimelineView()
} else {
let evs = events.filter(filter)
let indexed = Array(zip(evs, 0...))
ForEach(indexed, id: \.0.id) { tup in
let ev = tup.0
let ind = tup.1
let blur_imgs = should_blur_images(settings: state.settings, contacts: state.contacts, ev: ev, our_pubkey: state.pubkey)
if ev.kind == NostrKind.follow_list.rawValue {
FollowPackPreview(state: state, ev: ev, options: event_options, blur_imgs: blur_imgs)
.onTapGesture {
state.nav.push(route: Route.FollowPack(followPack: ev, model: FollowPackModel(damus_state: state), blur_imgs: blur_imgs))
}
.padding(.top, 7)
.onAppear {
let to_preload =
Array([indexed[safe: ind+1]?.0,
indexed[safe: ind+2]?.0,
indexed[safe: ind+3]?.0,
indexed[safe: ind+4]?.0,
indexed[safe: ind+5]?.0
].compactMap({ $0 }))
preload_events(state: state, events: to_preload)
}
}
}
}
}
.padding(.bottom)
}
}
@@ -0,0 +1,176 @@
//
// FollowPackView.swift
// damus
//
// Created by eric on 4/30/25.
//
import SwiftUI
import Kingfisher
struct FollowPackView: View {
let state: DamusState
let event: FollowPackEvent
@StateObject var model: FollowPackModel
@State var blur_imgs: Bool
@Environment(\.colorScheme) var colorScheme
@ObservedObject var artifacts: NoteArtifactsModel
init(state: DamusState, ev: FollowPackEvent, model: FollowPackModel, blur_imgs: Bool) {
self.state = state
self.event = ev
self._model = StateObject(wrappedValue: model)
self.blur_imgs = blur_imgs
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model)
}
init(state: DamusState, ev: NostrEvent, model: FollowPackModel, blur_imgs: Bool) {
self.state = state
self.event = FollowPackEvent.parse(from: ev)
self._model = StateObject(wrappedValue: model)
self.blur_imgs = blur_imgs
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
}
func content_filter(_ pubkeys: [Pubkey]) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: self.state)
filters.append({ pubkeys.contains($0.pubkey) })
return ContentFilters(filters: filters).filter
}
enum FollowPackTabSelection: Int {
case people = 0
case posts = 1
}
@State var tab_selection: FollowPackTabSelection = .people
var body: some View {
ZStack {
ScrollView {
FollowPackHeader
FollowPackTabs
}
}
.onAppear {
if model.events.events.isEmpty {
model.subscribe(follow_pack_users: event.publicKeys)
}
}
.onDisappear {
model.unsubscribe()
}
}
var tabs: [(String, FollowPackTabSelection)] {
let tabs = [
(NSLocalizedString("People", comment: "Label for filter for seeing the people in this follow pack."), FollowPackTabSelection.people),
(NSLocalizedString("Posts", comment: "Label for filter for seeing the posts from the people in this follow pack."), FollowPackTabSelection.posts)
]
return tabs
}
var FollowPackTabs: some View {
VStack(spacing: 0) {
VStack(spacing: 0) {
CustomPicker(tabs: tabs, selection: $tab_selection)
Divider()
.frame(height: 1)
}
.background(colorScheme == .dark ? Color.black : Color.white)
if tab_selection == FollowPackTabSelection.people {
LazyVStack(alignment: .leading) {
ForEach(event.publicKeys.reversed(), id: \.self) { pk in
FollowUserView(target: .pubkey(pk), damus_state: state)
}
}
.padding()
.padding(.bottom, 50)
.tag(FollowPackTabSelection.people)
.id(FollowPackTabSelection.people)
}
if tab_selection == FollowPackTabSelection.posts {
InnerTimelineView(events: model.events, damus: state, filter: content_filter(event.publicKeys))
}
}
.onAppear() {
model.subscribe(follow_pack_users: event.publicKeys)
}
.onDisappear {
model.unsubscribe()
}
}
var FollowPackHeader: some View {
VStack(alignment: .leading, spacing: 10) {
if state.settings.media_previews {
FollowPackBannerImage(state: state, options: EventViewOptions(), image: event.image, preview: false, blur_imgs: blur_imgs)
}
Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of follow list event if it is untitled."))
.font(.title)
.padding(.horizontal, 10)
.padding(.top, 5)
if let description = event.description {
Text(description)
.font(.body)
.foregroundColor(.gray)
.padding(.horizontal, 10)
} else {
EmptyView()
}
HStack(alignment: .center) {
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true)
.onTapGesture {
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
}
let profile_txn = state.profiles.lookup(id: event.event.pubkey)
let profile = profile_txn?.unsafeUnownedValue
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
switch displayName {
case .one(let one):
Text("Created by \(one)", comment: "Lets the user know who created this follow pack.")
.font(.subheadline).foregroundColor(.gray)
case .both(username: let username, displayName: let displayName):
HStack(spacing: 6) {
Text("Created by \(displayName)", comment: "Lets the user know who created this follow pack.")
.font(.subheadline).foregroundColor(.gray)
Text(verbatim: "@\(username)")
.font(.subheadline).foregroundColor(.gray)
}
}
}
.padding(.horizontal, 10)
.padding(.bottom, 20)
HStack(alignment: .center) {
FollowPackUsers(state: state, publicKeys: event.publicKeys)
}
.padding(.horizontal, 10)
.padding(.bottom, 20)
}
}
}
struct FollowPackView_Previews: PreviewProvider {
static var previews: some View {
VStack {
FollowPackView(state: test_damus_state, ev: test_follow_list_event, model: FollowPackModel(damus_state: test_damus_state), blur_imgs: false)
}
.frame(height: 400)
}
}
@@ -122,10 +122,7 @@ struct LongformPreviewBody: View {
} else if blur_images || (blur_images && !state.settings.media_previews) {
ZStack {
titleImage(url: url)
Blur()
.onTapGesture {
blur_images = false
}
BlurOverlayView(blur_images: $blur_images, artifacts: nil, size: nil, damus_state: nil, parentView: .longFormView)
}
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ class LoadableNostrEventViewModel: ObservableObject {
case .zap, .zap_request:
guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found }
return .loaded(route: Route.Zaps(target: zap.target))
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list:
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list, .interest_list:
return .unknown_or_unsupported_kind
}
case .naddr(let naddr):
+18 -18
View File
@@ -47,20 +47,6 @@ struct MutelistView: View {
var body: some View {
List {
Section(NSLocalizedString("Users", comment: "Section header title for a list of muted users.")) {
ForEach(users, id: \.self) { user in
if case let MuteItem.user(pubkey, _) = user {
UserViewRow(damus_state: damus_state, pubkey: pubkey)
.id(pubkey)
.swipeActions {
RemoveAction(item: .user(pubkey, nil))
}
.onTapGesture {
damus_state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
}
}
}
}
Section(NSLocalizedString("Hashtags", comment: "Section header title for a list of hashtags that are muted.")) {
ForEach(hashtags, id: \.self) { item in
if case let MuteItem.hashtag(hashtag, _) = item {
@@ -86,10 +72,7 @@ struct MutelistView: View {
}
}
}
Section(
header: Text(NSLocalizedString("Threads", comment: "Section header title for a list of threads that are muted.")),
footer: Text("").padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
) {
Section(NSLocalizedString("Threads", comment: "Section header title for a list of threads that are muted.")) {
ForEach(threads, id: \.self) { item in
if case let MuteItem.thread(note_id, _) = item {
if let event = damus_state.events.lookup(note_id) {
@@ -104,6 +87,23 @@ struct MutelistView: View {
}
}
}
Section(
header: Text(NSLocalizedString("Users", comment: "Section header title for a list of muted users.")),
footer: Text("").padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
) {
ForEach(users, id: \.self) { user in
if case let MuteItem.user(pubkey, _) = user {
UserViewRow(damus_state: damus_state, pubkey: pubkey)
.id(pubkey)
.swipeActions {
RemoveAction(item: .user(pubkey, nil))
}
.onTapGesture {
damus_state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
}
}
}
}
}
.navigationTitle(NSLocalizedString("Muted", comment: "Navigation title of view to see list of muted users & phrases."))
.onAppear {
+51
View File
@@ -0,0 +1,51 @@
//
// NIP05DomainPubkeysView.swift
// damus
//
// Created by Terry Yiu on 5/23/25.
//
import FaviconFinder
import Kingfisher
import SwiftUI
struct NIP05DomainPubkeysView: View {
let damus_state: DamusState
let domain: String
let nip05_domain_favicon: FaviconURL?
let pubkeys: [Pubkey]
var body: some View {
ScrollView {
LazyVStack(alignment: .leading) {
ForEach(pubkeys, id: \.self) { pk in
FollowUserView(target: .pubkey(pk), damus_state: damus_state)
}
}
.padding(.horizontal)
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
HStack {
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()
}
Text(domain)
.font(.headline)
}
}
}
}
}
#Preview {
let nip05_domain_favicon = FaviconURL(source: URL(string: "https://damus.io/favicon.ico")!, format: .ico, sourceType: .ico)
let pubkeys = [test_pubkey, test_pubkey_2]
NIP05DomainPubkeysView(damus_state: test_damus_state, domain: "damus.io", nip05_domain_favicon: nip05_domain_favicon, pubkeys: pubkeys)
}
@@ -0,0 +1,111 @@
//
// NIP05DomainTimelineHeaderView.swift
// damus
//
// Created by Terry Yiu on 5/16/25.
//
import FaviconFinder
import Kingfisher
import SwiftUI
struct NIP05DomainTimelineHeaderView: View {
let damus_state: DamusState
@ObservedObject var model: NIP05DomainEventsModel
let nip05_domain_favicon: FaviconURL?
@Environment(\.openURL) var openURL
var Icon: some View {
ZStack {
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 friendsOfFriends: [Pubkey] {
// Order it such that the pubkeys that have events come first in the array so that their profile pictures
// show first.
let pubkeys = model.events.all_events.map { $0.pubkey } + (model.filter.authors ?? [])
// Filter out duplicates but retain order, and filter out any that do not have a validated NIP-05.
return (NSMutableOrderedSet(array: pubkeys).array as? [Pubkey] ?? [])
.filter {
damus_state.contacts.is_in_friendosphere($0) && damus_state.profiles.is_validated($0) != nil
}
}
var body: some View {
VStack(alignment: .leading) {
HStack {
if nip05_domain_favicon != nil {
Icon
}
Text(model.domain)
.foregroundStyle(DamusLogoGradient.gradient)
.font(.title.bold())
.onTapGesture {
if let url = URL(string: "https://\(model.domain)") {
openURL(url)
}
}
}
let friendsOfFriends = friendsOfFriends
HStack {
CondensedProfilePicturesView(state: damus_state, pubkeys: friendsOfFriends, maxPictures: 3)
let friendsOfFriendsString = friendsOfFriendsString(friendsOfFriends, ndb: damus_state.ndb)
Text(friendsOfFriendsString)
.font(.subheadline)
.foregroundColor(.gray)
.multilineTextAlignment(.leading)
}
.onTapGesture {
if !friendsOfFriends.isEmpty {
damus_state.nav.push(route: Route.NIP05DomainPubkeys(domain: model.domain, nip05_domain_favicon: nip05_domain_favicon, pubkeys: friendsOfFriends))
}
}
}
}
}
func friendsOfFriendsString(_ friendsOfFriends: [Pubkey], ndb: Ndb, locale: Locale = Locale.current) -> String {
let bundle = bundleForLocale(locale: locale)
let names: [String] = friendsOfFriends.prefix(3).map { pk in
let profile = ndb.lookup_profile(pk)?.unsafeUnownedValue?.profile
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 20)
}
switch friendsOfFriends.count {
case 0:
return "No one in your trusted network is associated with this domain."
case 1:
let format = NSLocalizedString("Notes from %@", bundle: bundle, comment: "Text to indicate that notes from one pubkey in our trusted network are shown below.")
return String(format: format, locale: locale, names[0])
case 2:
let format = NSLocalizedString("Notes from %@ & %@", bundle: bundle, comment: "Text to indicate that notes from two pubkeys in our trusted network are shown below.")
return String(format: format, locale: locale, names[0], names[1])
case 3:
let format = NSLocalizedString("Notes from %@, %@ & %@", bundle: bundle, comment: "Text to indicate that notes from three pubkeys in our trusted network are shown below.")
return String(format: format, locale: locale, names[0], names[1], names[2])
default:
let format = localizedStringFormat(key: "notes_from_three_and_others", locale: locale)
return String(format: format, locale: locale, friendsOfFriends.count - 3, names[0], names[1], names[2])
}
}
#Preview {
let model = NIP05DomainEventsModel(state: test_damus_state, domain: "damus.io")
let nip05_domain_favicon = FaviconURL(source: URL(string: "https://damus.io/favicon.ico")!, format: .ico, sourceType: .ico)
NIP05DomainTimelineHeaderView(damus_state: test_damus_state, model: model, nip05_domain_favicon: nip05_domain_favicon)
}
+64
View File
@@ -0,0 +1,64 @@
//
// NIP05DomainTimelineView.swift
// damus
//
// Created by Terry Yiu on 4/11/25.
//
import FaviconFinder
import Kingfisher
import SwiftUI
struct NIP05DomainTimelineView: View {
let damus_state: DamusState
@ObservedObject var model: NIP05DomainEventsModel
let nip05_domain_favicon: FaviconURL?
func nip05_filter(ev: NostrEvent) -> Bool {
damus_state.contacts.is_in_friendosphere(ev.pubkey) && damus_state.profiles.is_validated(ev.pubkey) != nil
}
var contentFilters: ContentFilters {
var filters = Array<(NostrEvent) -> Bool>()
filters.append(contentsOf: ContentFilters.defaults(damus_state: damus_state))
filters.append(nip05_filter)
return ContentFilters(filters: filters)
}
var body: some View {
let height: CGFloat = 250.0
TimelineView(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true, filter: contentFilters.filter(ev:)) {
ZStack(alignment: .leading) {
DamusBackground(maxHeight: height)
.mask(LinearGradient(gradient: Gradient(colors: [.black, .black, .black, .clear]), startPoint: .top, endPoint: .bottom))
NIP05DomainTimelineHeaderView(damus_state: damus_state, model: model, nip05_domain_favicon: nip05_domain_favicon)
.padding(.leading, 30)
.padding(.top, 30)
}
}
.ignoresSafeArea()
.padding(.bottom, tabHeight)
.onAppear {
guard model.events.all_events.isEmpty else { return }
model.subscribe()
if let pubkeys = model.filter.authors {
for pubkey in pubkeys {
check_nip05_validity(pubkey: pubkey, profiles: damus_state.profiles)
}
}
}
.onDisappear {
model.unsubscribe()
}
}
}
#Preview {
let damus_state = test_damus_state
let model = NIP05DomainEventsModel(state: damus_state, domain: "damus.io")
let nip05_domain_favicon = FaviconURL(source: URL(string: "https://damus.io/favicon.ico")!, format: .ico, sourceType: .ico)
NIP05DomainTimelineView(damus_state: test_damus_state, model: model, nip05_domain_favicon: nip05_domain_favicon)
}
+92 -12
View File
@@ -23,6 +23,7 @@ struct Blur: UIViewRepresentable {
}
}
struct NoteContentView: View {
let damus_state: DamusState
@@ -72,15 +73,40 @@ struct NoteContentView: View {
}
var preview: LinkViewRepresentable? {
guard !blur_images,
case .loaded(let preview) = preview_model.state,
guard case .loaded(let preview) = preview_model.state,
case .value(let cached) = preview else {
return nil
}
// If either
// (1) the blur images setting is enabled
// (2) the media previews setting is disabled
// (3) this note content view does not display media
// then do not show media in the link preview.
if blur_images || !damus_state.settings.media_previews || self.options.contains(.no_media) {
return linkPreviewWithNoMedia(cached)
}
// If media is already being shown, do not show media in the link preview
// to avoid taking up additional screen space.
if case let .separated(separated) = note_artifacts, !separated.media.isEmpty && !self.options.contains(.no_media) {
return linkPreviewWithNoMedia(cached)
}
return LinkViewRepresentable(meta: .linkmeta(cached))
}
// Creates a LinkViewRepresentable without media previews.
func linkPreviewWithNoMedia(_ cached: CachedMetadata) -> LinkViewRepresentable? {
let linkMetadata = LPLinkMetadata()
linkMetadata.originalURL = cached.meta.originalURL
linkMetadata.title = cached.meta.title
linkMetadata.url = cached.meta.url
return LinkViewRepresentable(meta: .linkmeta(CachedMetadata(meta: linkMetadata)))
}
func truncatedText(content: CompatibleText) -> some View {
Group {
if truncate_very_short {
@@ -107,7 +133,7 @@ struct NoteContentView: View {
func previewView(links: [URL]) -> some View {
Group {
if let preview = self.preview, !blur_images {
if let preview = self.preview {
if let preview_height {
preview
.frame(height: preview_height)
@@ -166,10 +192,7 @@ struct NoteContentView: View {
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in
fullscreen_preview(dismiss: dismiss)
}
Blur()
.onTapGesture {
blur_images = false
}
BlurOverlayView(blur_images: $blur_images, artifacts: artifacts, size: size, damus_state: damus_state, parentView: .noteContentView)
}
}
}
@@ -183,7 +206,7 @@ struct NoteContentView: View {
}
}
if damus_state.settings.media_previews, has_previews {
if has_previews {
if with_padding {
previewView(links: artifacts.links).padding(.horizontal)
} else {
@@ -384,6 +407,64 @@ func lookup_cached_preview_size(previews: PreviewCache, evid: NoteId) -> CGFloat
return height
}
struct BlurOverlayView: View {
@Binding var blur_images: Bool
let artifacts: NoteArtifactsSeparated?
let size: EventViewKind?
let damus_state: DamusState?
let parentView: ParentViewType
var body: some View {
ZStack {
Color.black
.opacity(0.54)
Blur()
VStack(alignment: .center) {
Image(systemName: "eye.slash")
.foregroundStyle(.white)
.bold()
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
Text(NSLocalizedString("Media from someone you don't follow", comment: "Label on the image blur mask"))
.multilineTextAlignment(.center)
.foregroundStyle(Color.white)
.font(.title2)
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
Button(NSLocalizedString("Tap to load", comment: "Label for button that allows user to dismiss media content warning and unblur the image")) {
blur_images = false
}
.buttonStyle(.bordered)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
if parentView == .noteContentView,
let artifacts = artifacts,
let size = size,
let damus_state = damus_state
{
switch artifacts.media[0] {
case .image(let url), .video(let url):
Text(abbreviateURL(url, maxLength: 30))
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size * 0.8))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.padding(EdgeInsets(top: 20, leading: 10, bottom: 5, trailing: 10))
}
}
}
}
.onTapGesture {
blur_images = false
}
}
enum ParentViewType {
case noteContentView, longFormView
}
}
struct NoteContentView_Previews: PreviewProvider {
static var previews: some View {
let state = test_damus_state
@@ -401,7 +482,7 @@ struct NoteContentView_Previews: PreviewProvider {
.previewDisplayName("Super short note")
VStack {
NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: false, size: .normal, options: [])
NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: true, size: .normal, options: [])
}
.previewDisplayName("Note with image")
@@ -434,4 +515,3 @@ func separate_images(ev: NostrEvent, keypair: Keypair) -> [MediaUrl]? {
let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
return mediaUrls.isEmpty ? nil : mediaUrls
}
@@ -6,6 +6,7 @@
//
import SwiftUI
import TipKit
class NotificationFilter: ObservableObject, Equatable {
@Published var state: NotificationFilterState
@@ -41,7 +42,10 @@ class NotificationFilter: ObservableObject, Equatable {
if let item = item.filter({ ev in
self.friend_filter.filter(contacts: contacts, pubkey: ev.pubkey) &&
(!hellthread_notifications_disabled || !ev.is_hellthread(max_pubkeys: hellthread_notification_max_pubkeys))
(!hellthread_notifications_disabled || !ev.is_hellthread(max_pubkeys: hellthread_notification_max_pubkeys)) &&
// 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
}) {
acc.append(item)
}
@@ -72,10 +76,11 @@ struct NotificationsView: View {
@StateObject var filter = NotificationFilter()
@SceneStorage("NotificationsView.filter_state") var filter_state: NotificationFilterState = .all
@Binding var subtitle: String?
@Environment(\.colorScheme) var colorScheme
var body: some View {
let showTrustedButton = would_filter_non_friends_from_notifications(contacts: state.contacts, state: filter_state, items: self.notifications.notifications)
TabView(selection: $filter_state) {
NotificationTab(
NotificationFilter(
@@ -112,14 +117,19 @@ struct NotificationsView: View {
Button(
action: { state.nav.push(route: Route.NotificationSettings(settings: state.settings)) },
label: {
Image("settings")
Image(systemName: "gearshape")
.frame(width: 24, height: 24)
.foregroundColor(.gray)
}
)
}
ToolbarItem(placement: .navigationBarTrailing) {
if would_filter_non_friends_from_notifications(contacts: state.contacts, state: filter_state, items: self.notifications.notifications) {
FriendsButton(filter: $filter.friend_filter)
if showTrustedButton {
TrustedNetworkButton(filter: $filter.friend_filter) {
if #available(iOS 17, *) {
TrustedNetworkButtonTip.shared.invalidate(reason: .actionPerformed)
}
}
}
}
}
@@ -137,6 +147,13 @@ struct NotificationsView: View {
}
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 0) {
if #available(iOS 17, *), showTrustedButton {
TipView(TrustedNetworkButtonTip.shared)
.tipBackground(.clear)
.tipViewStyle(TrustedNetworkButtonTipViewStyle())
.padding(.horizontal)
}
CustomPicker(tabs: [
(NSLocalizedString("All", comment: "Label for filter for all notifications."), NotificationFilterState.all),
(NSLocalizedString("Zaps", comment: "Label for filter for zap notifications."), NotificationFilterState.zaps),
@@ -0,0 +1,123 @@
//
// InterestSelectionView.swift
// damus
//
// Created by Daniel DAquino on 2025-05-16.
//
import SwiftUI
extension OnboardingSuggestionsView {
typealias Interest = DIP06.Interest
struct InterestSelectionView: View {
var damus_state: DamusState
var next_page: (() -> Void)
/// Track selected interests using a Set
@Binding var selectedInterests: Set<Interest>
var isNextEnabled: Bool
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Title
Text(NSLocalizedString("Select Your Interests", comment: "Screen title for interest selection"))
.font(.largeTitle)
.fontWeight(.bold)
.multilineTextAlignment(.center)
.padding(.top)
// Instruction subtitle
Text(NSLocalizedString("Please pick your interests. This will help us recommend accounts to follow.", comment: "Instruction for interest selection"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
// Interests grid view
InterestsGridView(availableInterests: Interest.allCases,
selectedInterests: $selectedInterests)
.padding()
Spacer()
// Next button wrapped inside a NavigationLink for easy transition.
Button(action: {
self.next_page()
}, label: {
Text(NSLocalizedString("Next", comment: "Next button title"))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
})
.buttonStyle(GradientButtonStyle())
.disabled(!isNextEnabled)
.opacity(isNextEnabled ? 1.0 : 0.5)
.padding([.leading, .trailing, .bottom])
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_interest_page_next_page.rawValue)
}
.padding()
}
}
}
/// A grid view to display interest options
struct InterestsGridView: View {
let availableInterests: [Interest]
@Binding var selectedInterests: Set<Interest>
// Adaptive grid layout with two columns
private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 480)),
GridItem(.adaptive(minimum: 120, maximum: 480)),
]
var body: some View {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(availableInterests, id: \ .self) { interest in
let disabled = false
InterestButton(interest: interest,
isSelected: selectedInterests.contains(interest)) {
// Toggle selection
if selectedInterests.contains(interest) {
selectedInterests.remove(interest)
} else {
selectedInterests.insert(interest)
}
}
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_interest_option_button.rawValue)
.disabled(disabled)
.opacity(disabled ? 0.5 : 1.0)
}
}
}
}
/// A button view representing a single interest option
struct InterestButton: View {
let interest: Interest
let isSelected: Bool
var action: () -> Void
var body: some View {
Button(action: action) {
Text(interest.label)
.font(.body)
.padding(.vertical, 8)
.padding(.horizontal, 10)
.frame(maxWidth: .infinity)
.background(isSelected ? Color.accentColor : Color.gray.opacity(0.2))
.foregroundColor(isSelected ? Color.white : Color.primary)
.cornerRadius(50)
}
}
}
}
struct InterestSelectionView_Previews: PreviewProvider {
static var previews: some View {
OnboardingSuggestionsView.InterestSelectionView(
damus_state: test_damus_state,
next_page: { print("next") },
selectedInterests: Binding.constant(Set([DIP06.Interest.art, DIP06.Interest.music])), isNextEnabled: true
)
}
}
@@ -0,0 +1,83 @@
//
// OnboardingContentSettings.swift
// damus
//
// Created by Daniel DAquino on 2025-05-19.
//
import SwiftUI
extension OnboardingSuggestionsView {
struct OnboardingContentSettings: View {
var model: SuggestedUsersViewModel
var next_page: (() -> Void)
@ObservedObject var settings: UserSettingsStore
@Binding var selectedInterests: Set<Interest>
private var isNextEnabled: Bool { true }
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Title
Text(NSLocalizedString("Other preferences", comment: "Screen title for content preferences screen during onboarding"))
.font(.largeTitle)
.fontWeight(.bold)
.multilineTextAlignment(.center)
.padding(.top)
// Instruction subtitle
Text(NSLocalizedString("Tweak these settings to better match your preferences", comment: "Instructions for content preferences screen during onboarding"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
// Content preferences section with toggles
Section() {
VStack(alignment: .leading, spacing: 5) {
Toggle(NSLocalizedString("Hide notes with #nsfw tags", comment: "Setting to hide notes with not safe for work tags"), isOn: $settings.hide_nsfw_tagged_content)
.toggleStyle(.switch)
Text(NSLocalizedString("Notes with the #nsfw tag usually contains adult content or other \"Not safe for work\" content", comment: "Explanation of what NSFW means"))
.font(.caption)
.foregroundColor(.secondary)
.padding(.bottom, 10)
if !selectedInterests.contains(.bitcoin) {
Toggle(
NSLocalizedString("Show Bitcoin-heavy profile suggestions", comment: "Setting label during onboarding"),
isOn: Binding(get: { !model.reduceBitcoinContent }, set: { model.reduceBitcoinContent = !$0 })
)
.toggleStyle(.switch)
Text(NSLocalizedString("Some profiles tend to have a lot of Bitcoin-related content alongside their topics of interest. Disable this setting if you prefer to filter out follow suggestions that frequently talk about Bitcoin.", comment: "Explanation label for the 'Show Bitcoin-heavy profile suggestions' onboarding toggle setting"))
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color.secondary.opacity(0.1))
.cornerRadius(10)
}
.padding()
Spacer()
Button(action: {
self.next_page()
}, label: {
Text(NSLocalizedString("Next", comment: "Next button title"))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
})
.buttonStyle(GradientButtonStyle())
.disabled(!isNextEnabled)
.opacity(isNextEnabled ? 1.0 : 0.5)
.padding([.leading, .trailing, .bottom])
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_content_settings_page_next_page.rawValue)
}
.padding()
}
}
}
}
@@ -26,49 +26,103 @@ struct OnboardingSuggestionsView: View {
current_page += 1
}
}
private var canLeaveInterestSelectionPage: Bool {
let count = model.interests.count
return count > 0
}
/// Save the user's selected interests to NDB
private func saveInterestsToNdb() {
// Convert the selected interests to hashtags for the NIP51 interest list
let interestItems = model.interests.map { interest in
NIP51.InterestList.InterestItem.hashtag(interest.rawValue)
}
// Create the interest list
let interestList = NIP51.InterestList(interests: Array(interestItems))
// Convert to a NostrEvent and send to NDB
guard let keypair = model.damus_state.keypair.to_full(),
let event = interestList.toNostrEvent(keypair: keypair, timestamp: nil) else {
return // Not a big deal, fail silently
}
// Send the event to NostrDB to allow us to retrieve later
// Did not send this to the network yet because:
// 1. I believe we should add an opt-out/opt-in button.
// 2. If we do, and the user accepts to share it, it will be an awkward situation considering:
// - We don't show that anywhere else yet
// - We don't have other mechanisms to allow the user to edit this yet
//
// Therefore, it is better to just save it locally, and retrieve this once we build out https://github.com/damus-io/damus/issues/3042
model.damus_state.nostrNetwork.pool.send_raw_to_local_ndb(.typical(.event(event)))
}
var body: some View {
NavigationView {
TabView(selection: $current_page) {
SuggestedUsersPageView(model: model, next_page: self.next_page)
.navigationTitle(NSLocalizedString("Who to Follow", comment: "Title for a screen displaying suggestions of who to follow"))
InterestSelectionView(damus_state: model.damus_state, next_page: {
self.next_page()
}, selectedInterests: $model.interests, isNextEnabled: canLeaveInterestSelectionPage)
.navigationTitle(NSLocalizedString("Select your interests", comment: "Title for a screen asking the user for interests"))
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: {
self.next_page()
}, label: {
Text("Skip", comment: "Button to dismiss the suggested users screen")
.font(.subheadline.weight(.semibold))
})
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_sheet_skip_button.rawValue)
)
.tag(0)
PostView(
action: .posting(.user(model.damus_state.pubkey)),
damus_state: model.damus_state,
prompt_view: {
AnyView(
HStack {
Image(systemName: "sparkles")
Text("Add your first post", comment: "Prompt given to the user during onboarding, suggesting them to write their first post")
}
.foregroundColor(.secondary)
.font(.callout)
.padding(.top, 10)
if canLeaveInterestSelectionPage {
OnboardingContentSettings(model: model, next_page: self.next_page, settings: model.damus_state.settings, selectedInterests: $model.interests)
.navigationTitle(NSLocalizedString("Content settings", comment: "Title for an onboarding screen showing user some content settings"))
.navigationBarTitleDisplayMode(.inline)
.tag(1)
SuggestedUsersPageView(model: model, next_page: self.next_page)
.navigationTitle(NSLocalizedString("Who to Follow", comment: "Title for a screen displaying suggestions of who to follow"))
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: {
self.next_page()
}, label: {
Text("Skip", comment: "Button to dismiss the suggested users screen")
.font(.subheadline.weight(.semibold))
})
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_sheet_skip_button.rawValue)
)
},
placeholder_messages: self.first_post_examples,
initial_text_suffix: self.initial_text_suffix
)
.onReceive(handle_notify(.post)) { _ in
// NOTE: Even though PostView already calls `dismiss`, that is not guaranteed to work under deeply nested views.
// Thus, we should also call `dismiss` from here (a direct subview of a sheet), which is explicitly supported by Apple.
// See https://github.com/damus-io/damus/issues/1726 for more context and information
dismiss()
.tag(2)
PostView(
action: .posting(.user(model.damus_state.pubkey)),
damus_state: model.damus_state,
prompt_view: {
AnyView(
HStack {
Image(systemName: "sparkles")
Text("Add your first post", comment: "Prompt given to the user during onboarding, suggesting them to write their first post")
}
.foregroundColor(.secondary)
.font(.callout)
.padding(.top, 10)
)
},
placeholder_messages: self.first_post_examples,
initial_text_suffix: self.initial_text_suffix
)
.onReceive(handle_notify(.post)) { _ in
// NOTE: Even though PostView already calls `dismiss`, that is not guaranteed to work under deeply nested views.
// Thus, we should also call `dismiss` from here (a direct subview of a sheet), which is explicitly supported by Apple.
// See https://github.com/damus-io/damus/issues/1726 for more context and information
dismiss()
}
.tag(3)
}
.tag(1)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: current_page) { newPage in
// If the user just swiped from the interests page (0) to the next page (1),
// save their interests to NDB
if newPage == 1 && current_page == 1 {
saveInterestsToNdb()
}
}
}
}
}
@@ -79,20 +133,27 @@ fileprivate struct SuggestedUsersPageView: View {
var body: some View {
VStack {
List {
ForEach(model.groups) { group in
Section {
ForEach(group.users, id: \.self) { pk in
if let user = model.suggestedUser(pubkey: pk) {
SuggestedUserView(user: user, damus_state: model.damus_state)
if let suggestions = model.suggestions {
List {
ForEach(suggestions, id: \.self) { followPack in
Section {
ForEach(followPack.publicKeys, id: \.self) { pk in
if let usersInterests = model.interestUserMap[pk],
!usersInterests.intersection(model.interests).isEmpty && usersInterests.intersection(model.disinterests).isEmpty,
let user = model.suggestedUser(pubkey: pk) {
SuggestedUserView(user: user, damus_state: model.damus_state)
}
}
} header: {
SuggestedUsersSectionHeader(followPack: followPack, model: model)
}
} header: {
SuggestedUsersSectionHeader(group: group, model: model)
}
}
.listStyle(.plain)
}
else {
ProgressView()
}
.listStyle(.plain)
Spacer()
@@ -110,17 +171,14 @@ fileprivate struct SuggestedUsersPageView: View {
}
struct SuggestedUsersSectionHeader: View {
let group: SuggestedUserGroup
let followPack: FollowPackEvent
let model: SuggestedUsersViewModel
var body: some View {
HStack {
let locale = Locale.current
let format = localizedStringFormat(key: group.category, locale: locale)
let categoryName = String(format: format, locale: locale)
Text(categoryName)
Text(followPack.title ?? NSLocalizedString("Untitled Follow Pack", comment: "Default title for a follow pack if no title is specified"))
Spacer()
Button(NSLocalizedString("Follow All", comment: "Button to follow all users in this section")) {
model.follow(pubkeys: group.users)
model.follow(pubkeys: followPack.publicKeys)
}
.font(.subheadline.weight(.semibold))
}
@@ -129,6 +187,6 @@ struct SuggestedUsersSectionHeader: View {
struct SuggestedUsersView_Previews: PreviewProvider {
static var previews: some View {
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: test_damus_state))
OnboardingSuggestionsView(model: try! SuggestedUsersViewModel(damus_state: test_damus_state))
}
}
@@ -35,7 +35,6 @@ struct SuggestedUserView: View {
let target = FollowTarget.pubkey(user.pubkey)
InnerProfilePicView(url: user.pfp,
fallbackUrl: nil,
pubkey: target.pubkey,
size: 50,
highlight: .none,
disable_animation: false)
@@ -8,32 +8,76 @@
import Foundation
import Combine
struct SuggestedUserGroup: Identifiable, Codable {
let id = UUID()
let category: String
let users: [Pubkey]
enum CodingKeys: String, CodingKey {
case category, users
}
}
/// This model does the following:
///
/// - It loads follow packs (From the network, with a local copy fallback), and related profiles
/// - It tracks the interests and disinterests as selected by the user via an interface
/// - It computes publishes suggestions for users based on selected interests
@MainActor
class SuggestedUsersViewModel: ObservableObject {
/// The Damus State
public let damus_state: DamusState
@Published var groups: [SuggestedUserGroup] = []
private let sub_id = UUID().uuidString
init(damus_state: DamusState) {
self.damus_state = damus_state
loadSuggestedUserGroups()
let pubkeys = getPubkeys(groups: groups)
subscribeToSuggestedProfiles(pubkeys: pubkeys)
/// Keeps all the suggested follow packs available. For internal use only.
private var allSuggestions: [FollowPackEvent]? = nil {
didSet { self.recomputeSuggestions() }
}
/// The user-selected topics of interests
@Published var interests: Set<Interest> = [] {
didSet {
self.recomputeSuggestions()
if interests.contains(.bitcoin) {
// Ensures there are no setting contradictions if user goes back and forth on onboarding
reduceBitcoinContent = false
}
}
}
/// A user preference that allows users to reduce bitcoin content
@Published var reduceBitcoinContent: Bool {
didSet {
self.recomputeDisinterests()
damus_state.settings.reduce_bitcoin_content = reduceBitcoinContent
}
}
@Published private(set) var disinterests: Set<Interest> = [] {
didSet { self.recomputeSuggestions() }
}
/// Keeps the suggested follow packs to the user.
///
/// ## Implementation notes
///
/// This is technically meant to be a computed property (see `recomputeSuggestions`),
/// but we also want views that display this to be automatically updated,
/// so therefore we use `@Published` instead, and add property write observers on its logical dependencies
@Published private(set) var suggestions: [FollowPackEvent]? = nil
/// A map of suggested pubkeys and the particular interest categories they belong to
private(set) var interestUserMap: [Pubkey: Set<Interest>] = [:]
// MARK: - Helper types
typealias FollowPackID = String
typealias Interest = DIP06.Interest
// MARK: - Initialization
init(damus_state: DamusState) throws {
self.damus_state = damus_state
self.reduceBitcoinContent = damus_state.settings.reduce_bitcoin_content
self.recomputeAll()
Task.detached {
await self.loadSuggestedFollowPacks()
}
}
// MARK: - External interface methods
/// Gets suggested user information from a provided pubkey
func suggestedUser(pubkey: Pubkey) -> SuggestedUser? {
let profile_txn = damus_state.profiles.lookup(id: pubkey)
if let profile = profile_txn?.unsafeUnownedValue,
@@ -43,63 +87,154 @@ class SuggestedUsersViewModel: ObservableObject {
return nil
}
/// Allows the user to follow a list of other users
func follow(pubkeys: [Pubkey]) {
for pubkey in pubkeys {
notify(.follow(.pubkey(pubkey)))
}
}
private func loadSuggestedUserGroups() {
guard let url = Bundle.main.url(forResource: "suggested_users", withExtension: "json") else {
return
// MARK: - Internal state management logic
/// State management function that recomputes all "computed" properties
///
/// This helps ensure views get instant updates everytime the suggestions are supposed to be updated.
private func recomputeAll() {
self.recomputeDisinterests()
self.recomputeSuggestions()
}
/// State management function that recomputes `disinterests` based its logical dependencies
///
/// This helps ensure views get instant updates everytime the suggestions are supposed to be updated.
private func recomputeDisinterests() {
self.disinterests = reduceBitcoinContent ? Set([.bitcoin]) : []
}
/// State management function that recomputes `suggestions` based its logical dependencies
///
/// This helps ensure views get instant updates everytime the suggestions are supposed to be updated.
private func recomputeSuggestions() {
self.suggestions = Self.computeSuggestions(basedOn: allSuggestions, interests: interests, disinterests: disinterests)
}
/// Purely functional function that computes suggestions based on the ones available, and the user's interest selections
private static func computeSuggestions(basedOn allSuggestions: [FollowPackEvent]?, interests: Set<Interest>, disinterests: Set<Interest>) -> [FollowPackEvent]? {
guard let allSuggestions else { return nil }
return allSuggestions.filter({ suggestion in
return !suggestion.interests.intersection(interests).isEmpty && suggestion.interests.intersection(disinterests).isEmpty
})
}
// MARK: - Internal loading logic
/// Loads suggestions
///
/// (This is the main loading function that kicks-off the others)
///
/// ## Usage notes
///
/// - Long running task, preferably use this as a detached task
private func loadSuggestedFollowPacks() async {
// First, try preload events from the local file (To have a fallback in the case of an unstable internet connection)
var packsById = await self.loadLocalSuggestedFollowPacks()
// Then fetch the newest follow packs from the network and overwrite old ones where necessary
let subscriptionTask = Task {
await self.loadSuggestedFollowPacksFromNetwork(packsById: &packsById)
}
guard let data = try? Data(contentsOf: url) else {
return
// Wait for 5 seconds before timing out
try? await Task.sleep(nanoseconds: 5_000_000_000)
// Cancel the subscription task on timeout, to make sure we don't load forever
subscriptionTask.cancel()
// Finish loading and computing suggestions, as well as profile info
let allPacks = Array(packsById.values)
self.allSuggestions = allPacks
await self.loadProfiles(for: allPacks)
}
/// Load the local follow packs, to have a fallback in the case of network instability
///
/// ## Implementation notes
///
/// This might seem redundant, but onboarding is a crucial moment for users, so we need to make sure they are onboarded successfully.
private func loadLocalSuggestedFollowPacks() async -> [FollowPackID: FollowPackEvent] {
var packsById: [String: FollowPackEvent] = [:]
if let bundleURL = Bundle.main.url(forResource: "follow-packs", withExtension: "jsonl"),
let jsonlData = try? Data(contentsOf: bundleURL),
let jsonlString = String(data: jsonlData, encoding: .utf8) {
let lines = jsonlString.components(separatedBy: .newlines)
for line in lines where !line.isEmpty {
if let note = NdbNote.owned_from_json(json: line) {
let followPack = FollowPackEvent.parse(from: note)
if let id = followPack.uuid {
packsById[id] = followPack
}
}
}
}
return packsById
}
/// Loads the newest follow packs from the network, and overwrites the provided follow packs where appropriate
private func loadSuggestedFollowPacksFromNetwork(packsById: inout [FollowPackID: FollowPackEvent]) async {
let filter = NostrFilter(
kinds: [NostrKind.follow_list],
authors: [Constants.ONBOARDING_FOLLOW_PACK_CURATOR_PUBKEY]
)
for await item in self.damus_state.nostrNetwork.reader.subscribe(filters: [filter]) {
// Check for cancellation on each iteration
guard !Task.isCancelled else { break }
let decoder = JSONDecoder()
do {
let groups = try decoder.decode([SuggestedUserGroup].self, from: data)
self.groups = groups
} catch {
print(error.localizedDescription.localizedLowercase)
switch item {
case .event(let borrow):
try? borrow { event in
let followPack = FollowPackEvent.parse(from: event.toOwned())
guard let id = followPack.uuid else { return }
let latestPackForThisId: FollowPackEvent
if let existingPack = packsById[id], existingPack.event.created_at > followPack.event.created_at {
latestPackForThisId = existingPack
} else {
latestPackForThisId = followPack
}
packsById[id] = latestPackForThisId
}
case .eose:
break
}
}
}
private func getPubkeys(groups: [SuggestedUserGroup]) -> [Pubkey] {
var pubkeys: [Pubkey] = []
for group in groups {
pubkeys.append(contentsOf: group.users)
/// Finds all profiles mentioned in the follow packs, and loads the profile data from the network
private func loadProfiles(for packs: [FollowPackEvent]) async {
var allPubkeys: [Pubkey] = []
for followPack in packs {
for pubkey in followPack.publicKeys {
self.interestUserMap[pubkey] = Set(Array(self.interestUserMap[pubkey] ?? []) + Array(followPack.interests))
allPubkeys.append(pubkey)
}
}
return pubkeys
}
private func subscribeToSuggestedProfiles(pubkeys: [Pubkey]) {
let filter = NostrFilter(kinds: [.metadata], authors: pubkeys)
damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [filter], handler: handle_event)
}
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
guard case .nostr_event(let nev) = ev else {
return
}
switch nev {
case .event:
break
case .notice(let msg):
print("suggested user profiles notice: \(msg)")
case .eose:
self.objectWillChange.send()
case .ok:
break
case .auth:
break
let profileFilter = NostrFilter(kinds: [.metadata], authors: allPubkeys)
for await item in damus_state.nostrNetwork.reader.subscribe(filters: [profileFilter]) {
switch item {
case .event(_):
continue // We just need NostrDB to ingest these for them to be available elsewhere, no need to analyze the data
case .eose:
break
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{"kind":39089,"id":"5d0bb7d7cfde8614d91180e534ef9e33c35006d81185d8065a36ab3d3fa079db","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1750724913,"tags":[["title","Bitcoin enthusiasts"],["description","Profiles that like to talk about Bitcoin-related topics."],["image","https://images.unsplash.com/photo-1623227413711-25ee4388dae3?q=80&w=2972&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["d","hzgji33wnyku"],["t","bitcoin"],["p","704c4773626bf0f7ebf99d861eb0eae473be2b004f91725f8a1750486c9c848f"],["p","5b3260bc06f8e96b64a8c02e37385125d85479d681bb0302652b7a817c307a5e"],["p","7b3f7803750746f455413a221f80965eecb69ef308f2ead1da89cc2c8912e968"],["p","58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196"],["p","7c765d407d3a9d5ea117cb8b8699628560787fc084a0c76afaa449bfbd121d84"],["p","692c6feb28a0bd93d67cc19840bb07bfe678f97bf15930b5d812676b4cd512ef"],["p","130dcd3a1963f7fa35b206c44be6bc6f4ea0f5ee531b26126cb989678d5cfff5"],["p","c1831fbe2653f76164421d57db6cee38b8cef8ce6771bc65c12f8543de4b39bf"],["p","4d023ce9dfd75a7f3075b8e8e084008be17a1f750c63b5de721e6ef883adc765"],["p","f783ba3b12b91e375aba6594015b90bd95f7e132b03cc8c4c52ce0a7c36aab52"],["p","e07bfc04ea87c72a9185b0891f055361e6492f259f81247683d6c9ccb2b651ed"],["p","500ccc532c301711d88aa6d30b11dc477e6a32770853f8ab1c2be389b824e3f8"],["p","6a359852238dc902aed19fbbf6a055f9abf21c1ca8915d1c4e27f50df2f290d9"],["p","1586fd57ac81b66177b0087bb0c0fa465f30b9895949c8936836ec5e6cd13132"],["p","31ff694f01417160459e59e2e283b5454b499d3885092c792ae0d6b2de7839f0"],["p","259b1ddc0d3137cad123d4af64f87ff9db3834a843843963e58e2a9ac956cf39"],["p","91c9a5e1a9744114c6fe2d61ae4de82629eaaa0fb52f48288093c7e7e036f832"],["p","a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f"],["p","b7b51cc25216d4c10bc85ae27055c9a945fe77cafd463cf23b20917e39ce6816"],["p","25a2192dcf34c3be326988b5c9f942aa96789899d15b59412602854a8723e9e8"],["p","1c197b12ca9ce0415b70e7405b9770f0ec6bccfb59b32b63aafd42cb242e1642"],["p","9c86d77537a6380ce371e3a4860bc7e1fb2adbb2821bf1a8f1cd4e8ce02240c9"],["p","b203872cefbd5572b5551dde5bdfc0387219d99fb7e8d1c8418145d432ca0390"],["p","97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"],["p","ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a"],["p","566c166f3adab0c8fba5da015b0b3bcc8eb3696b455f2a1d43bfbd97059646a8"],["p","c7617e84337c611c7d5f941b35b1ec51f2ae6e9f41aac9616092d510e1c295e0"],["p","e9986a10caaa96738ceda88aabd3e184307be5143e687457581f9b096c6ef89c"],["p","805b34f708837dfb3e7f05815ac5760564628b58d5a0ce839ccbb6ef3620fac3"],["p","d284b9523c99fe1acc9e759129bd9f17984a5aff413caf87018510a020a656fd"],["p","7644119b18ec56b5b2779e0d035e7712a9d669dbfbe5b2c5f458cb564afb6c95"],["p","5a8e581f16a012e24d2a640152ad562058cb065e1df28e907c1bfa82c150c8ba"],["p","0d6c8388dcb049b8dd4fc8d3d8c3bb93de3da90ba828e4f09c8ad0f346488a33"],["p","738f7873ac2c6cb7701e3150616afc824379b132b467ba5a8429d5964af1b136"],["p","37039b91a6ff36ff48189dfae4a1f6ea017992a61fb9485b47de54fe491f75bf"],["p","d36e8083fa7b36daee646cb8b3f99feaa3d89e5a396508741f003e21ac0b6bec"],["p","f4d89779148ccd245c8d50914a284fd62d97cb0fb68b797a70f24a172b522db9"],["p","32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],["p","b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450"],["p","40b9c85fffeafc1cadf8c30a4e5c88660ff6e4971a0dc723d5ab674b5e61b451"],["p","963a2dca29e2ed5663a627b5289ed36d445531a3f5ef127716227d8a1aaa5166"],["p","139137969c1c56bf8306cd71272f681ed54206be15eeceb9eb98956d2fd9f7ef"],["p","f1989a96d75aa386b4c871543626cbb362c03248b220dc9ae53d7cefbcaaf2c1"],["p","76e36f2fabfbd8c353ffcda8fe07877a0977ee4aa9d9bcc224f02038d73b3787"],["p","e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb"],["p","4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"],["p","5e7e69f863d20ca30103df88887194e091679491fefe5eb8212753013cc3d552"],["p","ce9cfafcdc8425dc90c7045f263f966d077dbde96940b7fab4c9403998c5b49b"],["p","75da94027ad408bc2faffeb1e67d71babe8d78d89c3620da212303b877a65b5c"],["p","eda96cb93aecdd61ade0c1f9d2bfdf95a7e76cf1ca89820c38e6e4cea55c0c05"]],"content":"","sig":"5128ec83bd15ea260f6597b2e1d77125e297c8fef26977fe952b7e9d04e06d8ceb17d5cf94d5e7fb58e475c6afb8ff2880ec63b405bf868f826b425dde12fbe1"}
{"kind":39089,"id":"7426682b30d03cf8a40bcd86fe074e2f79c1b81c3b6c335ef56a3ba9d1035d16","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1750721921,"tags":[["title","Health"],["d","7i53tdhuhhex"],["image","https://images.unsplash.com/photo-1649134296132-56606326c566?q=80&w=1332&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","6b4ec98f02e647e01440b473bbd92a7fae21e01b6aa6c65e32db94a36092272e"],["description","Profiles that talk about health topics."],["t","health"]],"content":"","sig":"ff9f6f7d3c9a00fe89baef11559ca2ad9b5247e87129c0d6631c75bf3e10a12e9778f1ef5d2ae951aca504c7ac6f08a250868aae978ad5fae235e593af0c98d7"}
{"kind":39089,"id":"88d94f81af4eb6b5a8c70dcdb54124cf7d177dddd6408095d30e301f41024f9b","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1750721164,"tags":[["title","Sports"],["d","i6z5fdlgm0mc"],["image","https://images.unsplash.com/photo-1612872087720-bb876e2e67d1?q=80&w=1307&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","7c765d407d3a9d5ea117cb8b8699628560787fc084a0c76afaa449bfbd121d84"],["description","Profiles that talk or post about Sports."],["t","sports"]],"content":"","sig":"6b2d43dea6e50c04cd4bf1e5992e277c1719d1206125bbe045d3396543c3642daa8513d3a44eca5ed6f9a3bd536c9425ab034aaa29a0aa3e096aba93ad83071c"}
{"kind":39089,"id":"97d3211d499773c7b67a79f48a0bda207bcc09239af069285b256f247815f774","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1749695841,"tags":[["title","Christianity"],["d","fa5b4qnthgtw"],["image","https://images.unsplash.com/photo-1646281579942-ef27ddbf729f?q=80&w=3270&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","ffca54e078fd9884a745d70eb0159c0b8a9e7d383a4906a7484b83ebf6101dd5"],["p","d1b118c61b0fcaac3d9e8beafa8bb9a649cce921756a8fd7c21ca97b4985b38d"],["p","8d34bd2432240c5637174a3db191878baa1c133aec739b64a264259f414be32b"],["p","6e80246f10cc91b261e46d376bd3d64409b79e0c9bb3c2a0ecba612c321f6c4f"],["p","31ff694f01417160459e59e2e283b5454b499d3885092c792ae0d6b2de7839f0"],["description","Profiles that actively post about Christian topics."],["t","religion-spirituality"]],"content":"","sig":"f02e1d37fbb0f18f4b5c5da7d6a5fdfca4506d81232acb1032ef9aab5397641ad645296ed358729c968f0260cafd628fffd7efee60d91694849834b6b5279d86"}
{"kind":39089,"id":"7b18fc4ca212dd7d9afb025d08fe9562ff9ac32c6daffdeda3a15196cd1e158e","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1749691990,"tags":[["title","Music"],["d","w6d8d4247nr8"],["image","https://images.unsplash.com/photo-1511379938547-c1f69419868d?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","ca9d68eb25620fc755e1b8c76b5f155f4c7e96d99c532c109a8b36d208bdce55"],["p","23708a76e7090cb108d33e8801fd36262c475e5499b23eb87eee4a31f4f0144e"],["p","8806372af51515bf4aef807291b96487ea1826c966a5596bca86697b5d8b23bc"],["p","b9d02cb8fddeb191701ec0648e37ed1f6afba263e0060fc06099a62851d25e04"],["p","eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e"],["p","937ce652c92cb8bb7a8a3715d62c6e9f71dc66fd9164193e6c3cb962fdc07c94"],["description","Profiles of musicians or profiles that talk about music."],["t","music"]],"content":"","sig":"04f03a368481a1c3716fe426ac920a6977f4717f68073c0114ba16b1a76e30e7c3b99a97f8a7dee666317b077d30aaf2a6206223d6a3d65eb6d8377cf38a124d"}
{"kind":39089,"id":"9927b76f56750aeb3f1ef454720546a0e22c0f9cfcce346a6928fd7844d728f9","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1749256086,"tags":[["title","Human rights"],["d","vhsaqseo9sbx"],["image","https://images.unsplash.com/photo-1629753908080-e8551ac57b8d?q=80&w=1475&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240"],["p","6a359852238dc902aed19fbbf6a055f9abf21c1ca8915d1c4e27f50df2f290d9"],["p","f1989a96d75aa386b4c871543626cbb362c03248b220dc9ae53d7cefbcaaf2c1"],["p","500ccc532c301711d88aa6d30b11dc477e6a32770853f8ab1c2be389b824e3f8"],["p","58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196"],["p","33bd77e5394520747faae1394a4af5fa47f404389676375b6dc7be865ed81452"],["p","6e1010d8e5b953f9a52314d97bc94c597af26d51bae88b3fdf2c8fbd7e962d01"],["description","Human rights activists, or profiles that talk about human rights."],["t","politics"]],"content":"","sig":"7d2bf4423be8cab7d21138c172c9fc409e011f6c148e86ceed500f51edcf6d10530950385486d28f8b42eee0eb3dbb3b4b502d0e9d4adfe3f24fff99245074b3"}
{"kind":39089,"id":"dc17bccfe044d3350768f3c58632aaacee025eaf1e0b17717cbb51461446f377","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748291531,"tags":[["title","Philosophy"],["d","2dnchq9fxd5r"],["image","https://images.unsplash.com/photo-1620662736427-b8a198f52a4d?q=80&w=2971&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","0ffab7d9247132f14bcd38378b0acedc30d35e2b2670d89b7ae8c2d2c306af99"],["p","580f511af0a79d2cca20fb5bf6bb89abe6988593f8568177d2a513e87b2bbef3"],["t","humanities"]],"content":"","sig":"8cc9baf5beac297e575fb6db72785792cf43ac1086460f82deb4e7bc81c232ed0887e061de8bdc05389d6b26ded882036c9a7c120842548a192805c543ef4814"}
{"kind":39089,"id":"a77a31ee6fbab6ba3067ce72ba3a5f376b90f8a98f50635179010d98e158095c","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287979,"tags":[["title","Farmers (farmstr)"],["d","cioc58duuftq"],["image","https://images.unsplash.com/photo-1589923188900-85dae523342b?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","d70d50091504b992d1838822af245d5f6b3a16b82d917acb7924cef61ed4acee"],["p","4d023ce9dfd75a7f3075b8e8e084008be17a1f750c63b5de721e6ef883adc765"],["p","8d9d2b77930ee54ec3e46faf774ddd041dbb4e4aa35ad47c025884a286dd65fa"],["p","c0e0c4272134d92da8651650c10ca612b710a670d5e043488f27e073a1f63a16"],["p","5e7e69f863d20ca30103df88887194e091679491fefe5eb8212753013cc3d552"],["p","a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be"],["p","f27a4813f31d8faf80377ef15d4a397e3640b5ca1dffba9ce9dca91d3362c091"],["p","715c279295b65454c464b78503d6154ad7697ba82460c56d8a42eb5da11e6c4b"],["p","ab11aa702482080cfead6e35fb6fbad454a3e792dd4d75f183f52f5fea2a2fa9"],["p","6a02b7d5d5c1ceec3d0ad28dd71c4cfeebb6397b95fef5cd5032c9223a13d02a"],["p","e7211c227437dcb5582712b5b1bbfb2a23b27654649ddc7c1fb5a7dde87afeee"],["p","7b8fcd9153d2c47146155df24b7f167924c1fc16fb4e843d98f8b5f3add6bd85"],["p","0f4011159c24a6a53eeea72e1c2e97e0425be8af15e7de397003dd65d9e8d278"],["p","58d2be9f20537ad7074b0283287611507ef7bd313c5edb64def9e1fc5df26d11"],["p","d020fe6e22b3aca9f578172a135e25d7b692e67b52e97fdce55431127d7626af"],["p","7644119b18ec56b5b2779e0d035e7712a9d669dbfbe5b2c5f458cb564afb6c95"],["description","Profiles of farmers or profiles that talk about farming"],["t","food"]],"content":"","sig":"d6bbdbc1d84bf6f85e462f1abbe7dfc5d06218091ab0be00cd0ba440d0835eb26f81e8d0b2aea3e4bd3a5ab3638be0ce27c4072df460e99c5fe9dcc59113a153"}
{"kind":39089,"id":"fb51210ce13077cecd890a053f611fa8a258e8898e8b7b3116b641dbf1330959","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287889,"tags":[["title","Lifestyle"],["d","rptxdnrphqsr"],["image","https://images.unsplash.com/photo-1493225255756-d9584f8606e9?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","2652f3af10de63bc10a2628871a3fce62e08655e4fcf90a58be16f246bb65da3"],["p","c7c8f645fd45b09055fb6c26d148737ad7ed12ddecde0d4c877b88f8d4196865"],["p","e451c4e82e272ef912a04f490373fce9054a044b11a2aa847823b3686c8eba77"],["p","22a193fb86c793d7f73348126a5fe55ab8a4c975805ebc2ff81bf290f7805f7e"],["p","805b34f708837dfb3e7f05815ac5760564628b58d5a0ce839ccbb6ef3620fac3"],["description","Profiles that talk about different lifestyles."],["t","lifestyle"]],"content":"","sig":"11d70b315a7084ad26f31e3821ff295962654c2d41526f7e8491e18dcd7ed5a578acade9c47b0456c76b12e74653147bef069dafd1b05278506d63d7a8cb2316"}
{"kind":39089,"id":"d1c8d4b8684b7e1eda6830e6dde96b2ea548ce4f717ff5cf528a00a2206992e7","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287802,"tags":[["title","Art & Photography"],["d","9gnjzbkd59lp"],["image","https://images.unsplash.com/photo-1579541592065-da8a15e49bc7?q=80&w=3201&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","23708a76e7090cb108d33e8801fd36262c475e5499b23eb87eee4a31f4f0144e"],["p","f96c3d76497074c4c83a7b3823380e77dc73d5a9494fd2e053e4a1453e17824b"],["p","11b2d93b26d7e56fb57f0afce0d33bfa7fb35b913e4c0aeb7706464befb9ca97"],["p","f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0"],["p","af146f51634a5fb0abc592fbc2bed42cf740700990344a766a8999fe55eed1c6"],["p","8816e8938fe60a3a925433c77410f39500fe1320787acb2165f9345e70464592"],["p","8d0638d3e8d9b23e337fb4017bb7d5a97def40bdc258e8121ee7d51e623ca065"],["p","ece8ed2111f73b92251e74a3da22dee1d6c6d9b497040acc68a37ac6bbf5a105"],["p","64bfa9abffe5b18d0731eed57b38173adc2ba89bf87c168da90517f021e722b5"],["p","546bffcb9317edf919828de272dd8eba11f2aadc07969cba6c9bb894c07712ef"],["p","20998a8d433181cc5e778903db528d162f5579918a3437be40fdba0130b3469c"],["p","37c962bfb34d32e667d5075f7d55f3a7ca9bcddbafeff59a4003c276ad147352"],["p","87deef9034c52d87ae327af3a76358e58e7ee2e03d80c738ee1a2be399c23e79"],["p","55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185"],["description","A list of profiles related to art and photography"],["t","art"]],"content":"","sig":"a6409e0172380d30d36446f46ff2ccc97cb01cd3cdff612991b8e759a103a8243928579efdcea9f5e0c44ce2785c240218928fec26cdd6b2b0bafb63a8c0790a"}
{"kind":39089,"id":"f650750b46c7f97d9f4f85ee1f1c704015f3ca35b129826ac370ad47faa016a4","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287746,"tags":[["title","Technology companies"],["d","yogtlbnbuw39"],["image","https://images.unsplash.com/photo-1531297484001-80022131f5a1?q=80&w=3220&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","d7df8b3e14166796a8ad8740b06f427aea9dd95b72e0276aa9179210e27f81f7"],["p","6b4a29bbd43d1d0eeead384f512dbb591ce9407d27dba48ad54b00d9d2e1972b"],["p","34104dedf3cc5936802c8308a3d0090f2857d4aba4e8b720accaf6b2ab049969"],["p","ebdee92945ef05283be0ac3de25787c81a6a58a10f568f9c6b61d9dd513adbad"],["p","37cdc3e7f5a7147752cc6cb348bd77e1b999e3e43a4b21b740812193ba81c298"],["p","de7aa63b1c7e809f66a67bdcd2bd4c952a09cd52ee01ed7db358d09bad97f840"],["p","c7617e84337c611c7d5f941b35b1ec51f2ae6e9f41aac9616092d510e1c295e0"],["p","9be21611a341426e9146257c54179e22d178bb7d4106e247ddf3e507b7985a6b"],["description","A list of technology companies on Nostr"],["t","technology"]],"content":"","sig":"d1e9adeb5f9850ecffc3a524e59dbfa6c49913ac77b3653852f46908ae6e6290d7e58fc4c74717c45ee9a2fd4b6421d8c5885b8e198a6aaf14db39e461902d68"}
{"kind":39089,"id":"7e734701c3a09c63e9c68a4c6762c7dd122220d2d325802e40d1f8c983733b42","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287584,"tags":[["title","Technology news"],["d","mbj6yxabm94c"],["image","https://images.unsplash.com/photo-1726568313407-c7d9c8a8ce88?q=80&w=2971&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","9c5d0fe246b80bc313082aa3dbb4f82744751ca8d79901dc74f7eb69bba8ad4b"],["p","8451100685869f1fa9b122300167359f9a0459fcf517f2fb1b1dd22319a38815"],["p","de5ba539eb564946ae0e3f5abbf3599e8eeed8efd40875c6320405a0c17d976e"],["t","technology"]],"content":"","sig":"b92854e27172dfb0225308669a1d8c422f82258736e2403a89dc104e0d3a97ac4434d482402f37ecb16e57f65cd7ced7dccf978f1da0065d6f0bcd55782829bc"}
{"kind":39089,"id":"9143d35516660826a8c1a4a2440fa124c395b144bd172d39ea647d0222aaaeb3","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287486,"tags":[["title","Bitcoin builders"],["d","2iofns19fsiv"],["image","https://images.unsplash.com/photo-1564241832756-58a1d4055663?q=80&w=2969&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","566c166f3adab0c8fba5da015b0b3bcc8eb3696b455f2a1d43bfbd97059646a8"],["p","91c9a5e1a9744114c6fe2d61ae4de82629eaaa0fb52f48288093c7e7e036f832"],["p","25a2192dcf34c3be326988b5c9f942aa96789899d15b59412602854a8723e9e8"],["p","11b9a89404dbf3034e7e1886ba9dc4c6d376f239a118271bd2ec567a889850ce"],["p","b7b51cc25216d4c10bc85ae27055c9a945fe77cafd463cf23b20917e39ce6816"],["p","e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411"],["p","5a8e581f16a012e24d2a640152ad562058cb065e1df28e907c1bfa82c150c8ba"],["description","People who builds products and services around Bitcoin.\n\n(Not an exhaustive list, work in progress)"],["t","bitcoin"]],"content":"","sig":"192f65e8392742a01be41b60aef05da4026a1fd22229bde468a6123890f680d2f7765fa8b21c41f75945b676053e91eabfe2529bb289a7dda80789a0e6486d20"}
{"kind":39089,"id":"0ed119d7e0828ac23e7a0d735498be9054da91d21e7e268c25633ad747a949c1","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287175,"tags":[["title","Cars"],["d","dgj9fi1mk83t"],["image","https://images.unsplash.com/photo-1526726538690-5cbf956ae2fd?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","98adf137d9da1b1b3654f29ede930f27c95847c9719bf4b38e1cef33c21f7e38"],["description","A list of profiles that talk about cars."],["t","other"]],"content":"","sig":"47e6b974e57b6344e47269cc2ac1c800332a2c1f949c9cd1522b90580ece31d49409a02fa9cdac64a5cfe660c221a1c50b9eeaa5204a44c24d5d6c5bd5ade506"}
{"kind":39089,"id":"37d183d3f069167e4df7294c842086d61abee8ca8f7d79a402eeb0d2dca34268","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287050,"tags":[["title","Science"],["d","ypfr6bg55nrw"],["image","https://images.unsplash.com/photo-1606125784258-570fc63c22c1?q=80&w=3161&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","c582af78dff442700ec59e21786532a7074c00be8b7b1eac989bbf62698069cc"],["p","e85ed75286cb77475776c1007df8c4ff1c9c68eff91c3627347b065c5bf4dc78"],["t","science"]],"content":"","sig":"3649115023c3c62432742d209f3a4791b0d38d784a147a967d81288add8ab127197a6207de82b5cb53d4618cd1c92b0d190b3c7a9f5276535853f0a83fc485d3"}
{"kind":39089,"id":"bf1546f39fc0d3dac03ab77b0ce9d62d71707114cfe3f88c75fb13227c0b02ad","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748286976,"tags":[["title","Nostr builders"],["d","68qs4i0xgex8"],["image","https://images.unsplash.com/photo-1588508107117-227d4ab6b751?q=80&w=3228&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],["p","97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"],["p","d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a"],["p","3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],["p","a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be"],["p","17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4"],["p","8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"],["p","4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"],["p","1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef"],["p","8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883"],["p","1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411"],["p","2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1"],["p","520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626"],["p","e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb"],["p","850605096dbfb50b929e38a6c26c3d56c425325c85e05de29b759bc0e5d6cebc"],["p","0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd"],["p","d36e8083fa7b36daee646cb8b3f99feaa3d89e5a396508741f003e21ac0b6bec"],["p","0d6c8388dcb049b8dd4fc8d3d8c3bb93de3da90ba828e4f09c8ad0f346488a33"],["p","460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"],["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["p","bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91"],["p","e7424ad457e512fdf4764a56bf6d428a06a13a1006af1fb8e0fe32f6d03265c7"],["p","68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272"],["p","50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63"],["p","76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa"],["description","People who build products and services on Nostr. (Not an exhaustive list)"],["t","technology"]],"content":"","sig":"566d88b6ecbfa00f51789bc660c92abb4c9d6d592a3f2022531c0615444ad773aecf6d1781177b19dd2f26fad94e4ab077affcad86b60fda8f5f7658545a2ae6"}
{"kind":39089,"id":"700646832f4d70ce896d9ebd833c6c7552a91b2acaaf469c5ac35c59c9d8801b","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748286876,"tags":[["title","Travel"],["d","2xhos7ml5pcp"],["image","https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=2973&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","7d33ba57d8a6e8869a1f1d5215254597594ac0dbfeb01b690def8c461b82db35"],["description","Profiles that talk about travel."],["t","travel"]],"content":"","sig":"b62a5df0295cac59d2616a3786a72ba47052a540b7185b2b93f77d26dbb4fad171cf145ea445d15b150e7642260646c5850ea4482330cd5dc5038d0ef26954de"}
{"kind":39089,"id":"03012789fd1d247142192bf6f8999ee848ba83edf6b7f4919b4e3690e75889b6","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1747845497,"tags":[["title","Human Architecture, Local Vernacular, and Craftsmanship"],["d","y156932o9xfh"],["image","https://c4.wallpaperflare.com/wallpaper/82/123/783/architecture-building-bulgaria-village-wallpaper-preview.jpg"],["p","5db2be23cde61dd0a69e667a021a943fa38760104fe4160ce13b1a097e9fe447"],["p","7a8e476bd97e1a9c348b2f9e1c8c9d1f371e2fda001dae82e44d336d4ca2f7ec"],["p","16f1a0100d4cfffbcc4230e8e0e4290cc5849c1adc64d6653fda07c031b1074b"],["p","462eb31a6b3de5727407e796d984be2c631cb4bfa854f8a1a2b092dcc6d7bbe1"],["p","e9986a10caaa96738ceda88aabd3e184307be5143e687457581f9b096c6ef89c"],["p","704c4773626bf0f7ebf99d861eb0eae473be2b004f91725f8a1750486c9c848f"],["p","41261aca9c043397d53c1d09d2d62926e8ab230ec8f7516c258c81c6844169c3"],["p","94f57887daad1a4b952bd755539f239922cd614a1b1ba0e623ea8361a4ca2a65"],["description","Aesthetic architecture, craftsmanship, and localism"],["t","art"]],"content":"","sig":"2a987dcbc86d66166599c55221a0bfb39a42fc83cd5db5f79e7aa6ddbf0b3777cfd39e1d97d36572084eb34ae30d58e10fab4e50de29a8798ef21a64e9b90e84"}
{"kind":39089,"id":"7e847eafeaf8ec7dec435b7a8f2eda41b645653ea2414297fbf4b9788d77b582","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1747767150,"tags":[["title","Linux Enjoyers"],["d","unjue0fdg0ef"],["image","https://image.nostr.build/2e730d4041764c32c40f33dd1e5ae15158f4463ed1bf09f217715b2735f667f1.jpg"],["p","52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd"],["p","edb470271297ac5a61f277f3cd14de54c67eb5ccd20ef0d9df29be18685bb004"],["p","fd5989ddfadd9e2af6ceb8b63942a9e31b37367e89917931ede3b2ea76823f10"],["p","139137969c1c56bf8306cd71272f681ed54206be15eeceb9eb98956d2fd9f7ef"],["p","f4d89779148ccd245c8d50914a284fd62d97cb0fb68b797a70f24a172b522db9"],["p","70122128273bdc07af9be7725fa5c4bc0fc146866bec38d44360dc4bc6cc18b9"],["p","0463223adf38df9a22a7fb07999a638fdd42d8437573e0bf19c43e013b14d673"],["p","480ec1a7516406090dc042ddf67780ef30f26f3a864e83b417c053a5a611c838"],["p","93332f1e437be05aa1a4c2cbb694cafc9721be57cca98aef7ec6641895ae4735"],["p","259b1ddc0d3137cad123d4af64f87ff9db3834a843843963e58e2a9ac956cf39"],["p","75656740209960c74fe373e6943f8a21ab896889d8691276a60f86aadbc8f92a"],["p","bd4ae3e67e29964d494172261dc45395c89f6bd2e774642e366127171dfb81f5"],["p","76e36f2fabfbd8c353ffcda8fe07877a0977ee4aa9d9bcc224f02038d73b3787"],["p","75da94027ad408bc2faffeb1e67d71babe8d78d89c3620da212303b877a65b5c"],["p","a793448d75b909e9597252cc5b133b24f64012d02fe041009f70cba16bf72864"],["p","c7eda660a6bc8270530e82b4a7712acdea2e31dc0a56f8dc955ac009efd97c86"],["p","1c197b12ca9ce0415b70e7405b9770f0ec6bccfb59b32b63aafd42cb242e1642"],["p","74fb3ef27cd8985d7fefc6e94d178290275f5492557b4a166ab9cd1458adabc7"],["p","d9a329afabb263a8af216838eb45e1f04cc3000704464947c4b907e1bef580d7"],["p","b203872cefbd5572b5551dde5bdfc0387219d99fb7e8d1c8418145d432ca0390"],["p","566c166f3adab0c8fba5da015b0b3bcc8eb3696b455f2a1d43bfbd97059646a8"],["p","fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1"],["p","97a403640c83ac12bce556ded8db2f3ebe891801832fa1114abda73a6ae8598c"],["p","40b9c85fffeafc1cadf8c30a4e5c88660ff6e4971a0dc723d5ab674b5e61b451"],["p","a5ee447568a2372226d647013a55251eac7dd37ab40804a5121a63ce2ca75401"],["p","abd277d90caba20aee0f1f05a68d24cd117badc1205d0d1b1a0451357d32b92f"],["p","b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc"],["p","ce9cfafcdc8425dc90c7045f263f966d077dbde96940b7fab4c9403998c5b49b"],["t","technology"]],"content":"","sig":"271c27e39c2cb24977a1f04e7df0c234b0fd35624abbbb0df35cf5ae6ae4e4b40f00acbcb2fd964edc40167fd0b45598d3879c01c0645938c1686f103285cb0f"}
@@ -1,79 +0,0 @@
[
{
"category": "suggested_users_nostr",
"users": [
"ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a",
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
"b708f7392f588406212c3882e7b3bc0d9b08d62f95fa170d099127ece2770e5e"
]
},
{
"category": "suggested_users_permaculture_livestock_gardening",
"users": [
"4b1804c90d59dff195ee0e8f692b98a7c762bf1793b3e126c546d730dcb04477",
"2c24e1af571fb4ccfeca3981649c1b09c695cd83b129709eb3b41c7ad2854899",
"296842eaaed9be5ae0668da09fe48aac0521c4af859ad547d93145e5ac34c17e"
]
},
{
"category": "suggested_users_music",
"users": [
"23708a76e7090cb108d33e8801fd36262c475e5499b23eb87eee4a31f4f0144e",
"ca9d68eb25620fc755e1b8c76b5f155f4c7e96d99c532c109a8b36d208bdce55"
]
},
{
"category": "suggested_users_books",
"users": [
"2652f3af10de63bc10a2628871a3fce62e08655e4fcf90a58be16f246bb65da3",
"b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450"
]
},
{
"category": "suggested_users_art_photography",
"users": [
"f96c3d76497074c4c83a7b3823380e77dc73d5a9494fd2e053e4a1453e17824b",
"11b2d93b26d7e56fb57f0afce0d33bfa7fb35b913e4c0aeb7706464befb9ca97",
"f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0",
"af146f51634a5fb0abc592fbc2bed42cf740700990344a766a8999fe55eed1c6",
"8816e8938fe60a3a925433c77410f39500fe1320787acb2165f9345e70464592",
"8d0638d3e8d9b23e337fb4017bb7d5a97def40bdc258e8121ee7d51e623ca065",
"ece8ed2111f73b92251e74a3da22dee1d6c6d9b497040acc68a37ac6bbf5a105",
"64bfa9abffe5b18d0731eed57b38173adc2ba89bf87c168da90517f021e722b5",
"546bffcb9317edf919828de272dd8eba11f2aadc07969cba6c9bb894c07712ef",
"20998a8d433181cc5e778903db528d162f5579918a3437be40fdba0130b3469c",
"37c962bfb34d32e667d5075f7d55f3a7ca9bcddbafeff59a4003c276ad147352",
"387fa328198e67a400caf00947cc91e0c166d24d71d8b4b78a6c3ef91ff4d058",
"87deef9034c52d87ae327af3a76358e58e7ee2e03d80c738ee1a2be399c23e79"
]
},
{
"category": "suggested_users_ai_art",
"users": [
"431fa2f340f0adf8963d6d7c6e2c20d913278c691fe609fd3857db13d8f39feb",
"9936a53def39d712f886ac7e2ed509ce223b534834dd29d95caba9f6bc01ef35",
"1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b",
"693c2832de939b4af8ccd842b17f05df2edd551e59989d3c4ef9a44957b2f1fb",
"55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185"
]
},
{
"category": "suggested_users_parenting",
"users": [
"c7c8f645fd45b09055fb6c26d148737ad7ed12ddecde0d4c877b88f8d4196865",
"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
"e451c4e82e272ef912a04f490373fce9054a044b11a2aa847823b3686c8eba77",
"261c7e18545aee4eb55e8052297fd93bd886c2f96b10d599cfdad4f3477b87ab",
"22a193fb86c793d7f73348126a5fe55ab8a4c975805ebc2ff81bf290f7805f7e"
]
},
{
"category": "suggested_users_food",
"users": [
"cbb2f023b6aa09626d51d2f4ea99fa9138ea80ec7d5ffdce9feef8dcd6352031"
]
}
]
+47 -19
View File
@@ -79,6 +79,7 @@ struct PostView: View {
var autoSaveModel: AutoSaveIndicatorView.AutoSaveViewModel
@State var preUploadedMedia: [PreUploadedMedia] = []
@State var mediaUploadUnderProgress: MediaUpload? = nil
@StateObject var image_upload: ImageUploadModel = ImageUploadModel()
@StateObject var tagModel: TagModel = TagModel()
@@ -330,11 +331,6 @@ struct PostView: View {
PostButton
}
if let progress = image_upload.progress {
ProgressView(value: progress, total: 1.0)
.progressViewStyle(.linear)
}
Divider()
.foregroundColor(DamusColors.neutral3)
.padding(.top, 5)
@@ -346,6 +342,7 @@ struct PostView: View {
@discardableResult
func handle_upload(media: MediaUpload) async -> Bool {
mediaUploadUnderProgress = media
let uploader = damus_state.settings.default_media_uploader
let img = getImage(media: media)
@@ -354,6 +351,7 @@ struct PostView: View {
async let blurhash = calculate_blurhash(img: img)
let res = await image_upload.start(media: media, uploader: uploader, mediaType: .normal, keypair: damus_state.keypair)
mediaUploadUnderProgress = nil
switch res {
case .success(let url):
guard let url = URL(string: url) else {
@@ -401,10 +399,13 @@ struct PostView: View {
}
.id("post")
PVImageCarouselView(media: $uploadedMedias, deviceWidth: deviceSize.size.width)
.onChange(of: uploadedMedias) { media in
post_changed(post: post, media: media)
}
PVImageCarouselView(media: $uploadedMedias,
mediaUnderProgress: $mediaUploadUnderProgress,
imageUploadModel: image_upload,
deviceWidth: deviceSize.size.width)
.onChange(of: uploadedMedias) { media in
post_changed(post: post, media: media)
}
if case .quoting(let ev) = action {
BuilderEventView(damus: damus_state, event: ev)
@@ -620,6 +621,8 @@ struct PostView_Previews: PreviewProvider {
struct PVImageCarouselView: View {
@Binding var media: [UploadedMedia]
@Binding var mediaUnderProgress: MediaUpload?
@ObservedObject var imageUploadModel: ImageUploadModel
let deviceWidth: CGFloat
@@ -667,6 +670,25 @@ struct PVImageCarouselView: View {
.padding(.bottom, 35)
}
}
if let mediaUP = mediaUnderProgress, let progress = imageUploadModel.progress {
ZStack {
// Media under upload-progress
Image(uiImage: getImage(media: mediaUP))
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: media.count == 0 ? deviceWidth * 0.8 : 250, height: media.count == 0 ? 400 : 250)
.cornerRadius(10)
.opacity(0.3)
.padding()
// Circle showing progress on top of media
Circle()
.trim(from: 0, to: CGFloat(progress))
.stroke(Color.damusPurple, lineWidth: 5.0)
.rotationEffect(.degrees(-90))
.frame(width: 30, height: 30)
.padding()
}
}
}
.padding()
}
@@ -776,18 +798,18 @@ private func isAlphanumeric(_ char: Character) -> Bool {
return char.isLetter || char.isNumber
}
func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair) -> [[String]] {
func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair, relayURL: RelayURL?) -> [[String]] {
guard let nip10 = replying_to.thread_reply() else {
// we're replying to a post that isn't in a thread,
// just add a single reply-to-root tag
return [["e", replying_to.id.hex(), "", "root"]]
return [["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "root"]]
}
// otherwise use the root tag from the parent's nip10 reply and include the note
// that we are replying to's note id.
let tags = [
["e", nip10.root.note_id.hex(), nip10.root.relay ?? "", "root"],
["e", replying_to.id.hex(), "", "reply"]
["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "reply"]
]
return tags
@@ -841,7 +863,9 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction,
func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], pubkeys: [Pubkey]) -> NostrPost {
let post = NSMutableAttributedString(attributedString: post)
post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in
if let link = attributes[.link] as? String {
let linkValue = attributes[.link]
let link = (linkValue as? String) ?? (linkValue as? URL)?.absoluteString
if let link {
let nextCharIndex = range.upperBound
if nextCharIndex < post.length,
let nextChar = post.attributedSubstring(from: NSRange(location: nextCharIndex, length: 1)).string.first,
@@ -878,15 +902,19 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction,
switch action {
case .replying_to(let replying_to):
// start off with the reply tags
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair)
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair, relayURL: state.nostrNetwork.relaysForEvent(event: replying_to).first)
case .quoting(let ev):
content.append("\n\nnostr:" + bech32_note_id(ev.id))
let relay_urls = state.nostrNetwork.relaysForEvent(event: ev)
let nevent = Bech32Object.encode(.nevent(NEvent(event: ev, relays: relay_urls.prefix(4).map { $0.absoluteString })))
content.append("\n\nnostr:\(nevent)")
tags.append(["q", ev.id.hex()]);
if let quoted_ev = state.events.lookup(ev.id) {
tags.append(["p", quoted_ev.pubkey.hex()])
if let first_relay = relay_urls.first?.absoluteString {
tags.append(["q", ev.id.hex(), first_relay, ev.pubkey.hex()]);
tags.append(["p", ev.pubkey.hex(), first_relay])
} else {
tags.append(["q", ev.id.hex(), "", ev.pubkey.hex()]);
tags.append(["p", ev.pubkey.hex()])
}
case .posting, .highlighting, .sharing:
break
+75 -67
View File
@@ -9,6 +9,7 @@ import SwiftUI
import Combine
let BANNER_HEIGHT: CGFloat = 150.0;
fileprivate let Scroll_height: CGFloat = 700.0
struct EditMetadataView: View {
let damus_state: DamusState
@@ -79,11 +80,14 @@ struct EditMetadataView: View {
func topSection(topLevelGeo: GeometryProxy) -> some View {
ZStack(alignment: .top) {
GeometryReader { geo in
let offset = geo.frame(in: .global).minY
EditBannerImageView(damus_state: damus_state, viewModel: bannerUploadObserver, callback: uploadedBanner(image_url:), safeAreaInsets: topLevelGeo.safeAreaInsets, banner_image: URL(string: banner))
.aspectRatio(contentMode: .fill)
.frame(width: geo.size.width, height: BANNER_HEIGHT)
.frame(width: geo.size.width, height: offset > 0 ? BANNER_HEIGHT + offset : BANNER_HEIGHT)
.clipped()
}.frame(height: BANNER_HEIGHT)
.offset(y: offset > 0 ? -offset : 0) // Pin the top
}
.frame(height: BANNER_HEIGHT)
VStack(alignment: .leading) {
let pfp_size: CGFloat = 90.0
@@ -129,74 +133,78 @@ struct EditMetadataView: View {
func content(topLevelGeo: GeometryProxy) -> some View {
VStack(alignment: .leading) {
self.topSection(topLevelGeo: topLevelGeo)
Form {
Section(NSLocalizedString("Your Name", comment: "Label for Your Name section of user profile form.")) {
let display_name_placeholder = "Satoshi Nakamoto"
TextField(display_name_placeholder, text: $display_name)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
}
ScrollView(showsIndicators: false) {
self.topSection(topLevelGeo: topLevelGeo)
Section(NSLocalizedString("Username", comment: "Label for Username section of user profile form.")) {
let username_placeholder = "satoshi"
TextField(username_placeholder, text: $name)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
}
Section(NSLocalizedString("Website", comment: "Label for Website section of user profile form.")) {
TextField(NSLocalizedString("https://jb55.com", comment: "Placeholder example text for website URL for user profile."), text: $website)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
}
Section(NSLocalizedString("About Me", comment: "Label for About Me section of user profile form.")) {
let placeholder = NSLocalizedString("Absolute Boss", comment: "Placeholder text for About Me description.")
ZStack(alignment: .topLeading) {
TextEditor(text: $about)
.textInputAutocapitalization(.sentences)
.frame(minHeight: 45, alignment: .leading)
.multilineTextAlignment(.leading)
Text(about.isEmpty ? placeholder : about)
.padding(4)
.opacity(about.isEmpty ? 1 : 0)
.foregroundColor(Color(uiColor: .placeholderText))
Form {
Section(NSLocalizedString("Your Name", comment: "Label for Your Name section of user profile form.")) {
let display_name_placeholder = "Satoshi Nakamoto"
TextField(display_name_placeholder, text: $display_name)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
}
}
Section(NSLocalizedString("Bitcoin Lightning Tips", comment: "Label for Bitcoin Lightning Tips section of user profile form.")) {
TextField(NSLocalizedString("Lightning Address or LNURL", comment: "Placeholder text for entry of Lightning Address or LNURL."), text: $ln)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.onReceive(Just(ln)) { newValue in
self.ln = newValue.trimmingCharacters(in: .whitespaces)
}
}
Section(content: {
TextField(NSLocalizedString("jb55@jb55.com", comment: "Placeholder example text for identifier used for Nostr addresses."), text: $nip05)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.onReceive(Just(nip05)) { newValue in
self.nip05 = newValue.trimmingCharacters(in: .whitespaces)
}
}, header: {
Text("Nostr Address", comment: "Label for the Nostr Address section of user profile form.")
}, footer: {
switch validate_nostr_address(nip05: nip05_parts, nip05_str: nip05) {
case .empty:
// without this, the keyboard dismisses unnecessarily when the footer changes state
Text("")
case .valid:
Text("")
case .invalid:
Text("'\(nip05)' is an invalid Nostr address. It should look like an email address.", comment: "Description of why the Nostr address is invalid.")
Section(NSLocalizedString("Username", comment: "Label for Username section of user profile form.")) {
let username_placeholder = "satoshi"
TextField(username_placeholder, text: $name)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
}
})
Section(NSLocalizedString("Website", comment: "Label for Website section of user profile form.")) {
TextField(NSLocalizedString("https://jb55.com", comment: "Placeholder example text for website URL for user profile."), text: $website)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
}
Section(NSLocalizedString("About Me", comment: "Label for About Me section of user profile form.")) {
let placeholder = NSLocalizedString("Absolute Boss", comment: "Placeholder text for About Me description.")
ZStack(alignment: .topLeading) {
TextEditor(text: $about)
.textInputAutocapitalization(.sentences)
.frame(minHeight: 45, alignment: .leading)
.multilineTextAlignment(.leading)
Text(about.isEmpty ? placeholder : about)
.padding(4)
.opacity(about.isEmpty ? 1 : 0)
.foregroundColor(Color(uiColor: .placeholderText))
}
}
Section(NSLocalizedString("Bitcoin Lightning Tips", comment: "Label for Bitcoin Lightning Tips section of user profile form.")) {
TextField(NSLocalizedString("Lightning Address or LNURL", comment: "Placeholder text for entry of Lightning Address or LNURL."), text: $ln)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.onReceive(Just(ln)) { newValue in
self.ln = newValue.trimmingCharacters(in: .whitespaces)
}
}
Section(content: {
TextField(NSLocalizedString("jb55@jb55.com", comment: "Placeholder example text for identifier used for Nostr addresses."), text: $nip05)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.onReceive(Just(nip05)) { newValue in
self.nip05 = newValue.trimmingCharacters(in: .whitespaces)
}
}, header: {
Text("Nostr Address", comment: "Label for the Nostr Address section of user profile form.")
}, footer: {
switch validate_nostr_address(nip05: nip05_parts, nip05_str: nip05) {
case .empty:
// without this, the keyboard dismisses unnecessarily when the footer changes state
Text("")
case .valid:
Text("")
case .invalid:
Text("'\(nip05)' is an invalid Nostr address. It should look like an email address.", comment: "Description of why the Nostr address is invalid.")
}
})
}
.frame(height: Scroll_height)
}
Button(action: {
+31 -5
View File
@@ -5,6 +5,7 @@
// Created by William Casarin on 2022-04-16.
//
import FaviconFinder
import SwiftUI
enum FriendType {
@@ -43,6 +44,7 @@ struct ProfileName: View {
@State var nip05: NIP05?
@State var donation: Int?
@State var purple_account: DamusPurple.Account?
@State var nip05_domain_favicon: FaviconURL?
init(pubkey: Pubkey, prefix: String = "", damus: DamusState, show_nip5_domain: Bool = true, supporterBadgeStyle: SupporterBadge.Style = .compact) {
self.pubkey = pubkey
@@ -61,7 +63,7 @@ struct ProfileName: View {
var current_nip05: NIP05? {
nip05 ?? damus_state.profiles.is_validated(pubkey)
}
func current_display_name(profile: Profile?) -> DisplayName {
return display_name ?? Profile.displayName(profile: profile, pubkey: pubkey)
}
@@ -101,7 +103,7 @@ struct ProfileName: View {
.fontWeight(prefix == "@" ? .none : .bold)
if let nip05 = current_nip05 {
NIP05Badge(nip05: nip05, pubkey: pubkey, contacts: damus_state.contacts, show_domain: show_nip5_domain, profiles: damus_state.profiles)
NIP05Badge(nip05: nip05, pubkey: pubkey, damus_state: damus_state, show_domain: show_nip5_domain, nip05_domain_favicon: nip05_domain_favicon)
}
if let friend = friend_type, current_nip05 == nil {
@@ -118,9 +120,15 @@ struct ProfileName: View {
}
.task {
if damus_state.purple.enable_purple {
self.purple_account = try? await damus_state.purple.get_maybe_cached_account(pubkey: pubkey)
}
if damus_state.purple.enable_purple {
self.purple_account = try? await damus_state.purple.get_maybe_cached_account(pubkey: pubkey)
}
}
.task {
if let domain = current_nip05?.host {
self.nip05_domain_favicon = try? await damus_state.favicon_cache.lookup(domain)
.largest()
}
}
.onReceive(handle_notify(.profile_updated)) { update in
if update.pubkey != pubkey {
@@ -151,6 +159,24 @@ struct ProfileName: View {
let nip05 = damus_state.profiles.is_validated(pubkey)
if nip05 != self.nip05 {
self.nip05 = nip05
if let domain = nip05?.host {
Task {
let favicon = try? await damus_state.favicon_cache.lookup(domain)
.filter {
if let size = $0.size {
return size.width <= 128 && size.height <= 128
} else {
return true
}
}
.largest()
await MainActor.run {
self.nip05_domain_favicon = favicon
}
}
}
}
if donation != profile.damus_donation {
+16 -5
View File
@@ -31,7 +31,6 @@ func pfp_line_width(_ h: Highlight) -> CGFloat {
struct InnerProfilePicView: View {
let url: URL?
let fallbackUrl: URL?
let pubkey: Pubkey
let size: CGFloat
let highlight: Highlight
let disable_animation: Bool
@@ -65,16 +64,19 @@ struct InnerProfilePicView: View {
struct ProfilePicView: View {
@Environment(\.redactionReasons) var redactionReasons
let pubkey: Pubkey
let size: CGFloat
let highlight: Highlight
let profiles: Profiles
let disable_animation: Bool
let zappability_indicator: Bool
let privacy_sensitive: Bool
@State var picture: String?
init(pubkey: Pubkey, size: CGFloat, highlight: Highlight, profiles: Profiles, disable_animation: Bool, picture: String? = nil, show_zappability: Bool? = nil) {
init(pubkey: Pubkey, size: CGFloat, highlight: Highlight, profiles: Profiles, disable_animation: Bool, picture: String? = nil, show_zappability: Bool? = nil, privacy_sensitive: Bool = false) {
self.pubkey = pubkey
self.profiles = profiles
self.size = size
@@ -82,15 +84,24 @@ struct ProfilePicView: View {
self._picture = State(initialValue: picture)
self.disable_animation = disable_animation
self.zappability_indicator = show_zappability ?? false
self.privacy_sensitive = privacy_sensitive
}
var privacy_sensitive_pubkey: Pubkey {
if privacy_sensitive && redactionReasons.contains(.privacy) {
ANON_PUBKEY
} else {
pubkey
}
}
func get_lnurl() -> String? {
return profiles.lookup_with_timestamp(pubkey)?.unsafeUnownedValue?.lnurl
}
var body: some View {
ZStack (alignment: Alignment(horizontal: .trailing, vertical: .bottom)) {
InnerProfilePicView(url: get_profile_url(picture: picture, pubkey: pubkey, profiles: profiles), fallbackUrl: URL(string: robohash(pubkey)), pubkey: pubkey, size: size, highlight: highlight, disable_animation: disable_animation)
InnerProfilePicView(url: get_profile_url(picture: picture, pubkey: privacy_sensitive_pubkey, profiles: profiles), fallbackUrl: URL(string: robohash(privacy_sensitive_pubkey)), size: size, highlight: highlight, disable_animation: disable_animation)
.onReceive(handle_notify(.profile_updated)) { updated in
guard updated.pubkey == self.pubkey else {
return
+1 -1
View File
@@ -123,7 +123,7 @@ struct ProfileView: View {
var filters = ContentFilters.defaults(damus_state: damus_state)
filters.append(fstate.filter)
switch fstate {
case .posts, .posts_and_replies:
case .posts, .posts_and_replies, .follow_list:
filters.append({ profile.pubkey == $0.pubkey })
case .conversations:
filters.append({ profile.conversation_events.contains($0.id) } )
+1 -1
View File
@@ -46,7 +46,7 @@ struct PubkeyView: View {
let bech32 = pubkey.npub
HStack {
Text(verbatim: "\(abbrev_pubkey(bech32, amount: sidemenu ? 12 : 16))")
Text(verbatim: "\(abbrev_identifier(bech32, amount: sidemenu ? 12 : 16))")
.font(sidemenu ? .system(size: 10) : .footnote)
.foregroundColor(keyColor())
.padding(5)
+13 -6
View File
@@ -11,12 +11,14 @@ struct RelayView: View {
let state: DamusState
let relay: RelayURL
let recommended: Bool
/// Disables navigation link
let disableNavLink: Bool
@ObservedObject private var model_cache: RelayModelCache
@State var relay_state: Bool
@Binding var showActionButtons: Bool
init(state: DamusState, relay: RelayURL, showActionButtons: Binding<Bool>, recommended: Bool) {
init(state: DamusState, relay: RelayURL, showActionButtons: Binding<Bool>, recommended: Bool, disableNavLink: Bool = false) {
self.state = state
self.relay = relay
self.recommended = recommended
@@ -24,6 +26,7 @@ struct RelayView: View {
_showActionButtons = showActionButtons
let relay_state = RelayView.get_relay_state(pool: state.nostrNetwork.pool, relay: relay)
self._relay_state = State(initialValue: relay_state)
self.disableNavLink = disableNavLink
}
static func get_relay_state(pool: RelayPool, relay: RelayURL) -> Bool {
@@ -96,10 +99,12 @@ struct RelayView: View {
RelayStatusView(connection: relay_connection)
}
Image("chevron-large-right")
.resizable()
.frame(width: 15, height: 15)
.foregroundColor(.gray)
if !disableNavLink {
Image("chevron-large-right")
.resizable()
.frame(width: 15, height: 15)
.foregroundColor(.gray)
}
}
}
.contentShape(Rectangle())
@@ -108,7 +113,9 @@ struct RelayView: View {
self.relay_state = RelayView.get_relay_state(pool: state.nostrNetwork.pool, relay: self.relay)
}
.onTapGesture {
state.nav.push(route: Route.RelayDetail(relay: relay, metadata: model_cache.model(with_relay_id: relay)?.metadata))
if !disableNavLink {
state.nav.push(route: Route.RelayDetail(relay: relay, metadata: model_cache.model(with_relay_id: relay)?.metadata))
}
}
}
+12 -2
View File
@@ -12,9 +12,19 @@ struct QuoteRepostsView: View {
@ObservedObject var model: EventsModel
var body: some View {
TimelineView<AnyView>(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true, filter: ContentFilters.default_filters(damus_state: damus_state).filter(ev:))
TimelineView(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true, filter: ContentFilters.default_filters(damus_state: damus_state).filter(ev:)) {
ZStack(alignment: .leading) {
DamusBackground(maxHeight: 250)
.mask(LinearGradient(gradient: Gradient(colors: [.black, .black, .black, .clear]), startPoint: .top, endPoint: .bottom))
Text("Quotes", comment: "Navigation bar title for Quote Reposts view.")
.foregroundStyle(DamusLogoGradient.gradient)
.font(.title.bold())
.padding(.leading, 30)
.padding(.top, 30)
}
}
.ignoresSafeArea()
.padding(.bottom, tabHeight)
.navigationBarTitle(NSLocalizedString("Quotes", comment: "Navigation bar title for Quote Reposts view."))
.onAppear {
model.subscribe()
}
+16 -16
View File
@@ -15,8 +15,9 @@ struct SearchHomeView: View {
@State var search: String = ""
@FocusState private var isFocused: Bool
var content_filter: (NostrEvent) -> Bool {
let filters = ContentFilters.defaults(damus_state: self.damus_state)
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: damus_state)
filters.append(fstate.filter)
return ContentFilters(filters: filters).filter
}
@@ -52,21 +53,20 @@ struct SearchHomeView: View {
loading: $model.loading,
damus: damus_state,
show_friend_icon: true,
filter: { ev in
if !content_filter(ev) {
return false
}
let event_muted = damus_state.mutelist_manager.is_event_muted(ev)
if event_muted {
return false
}
return true
},
filter:content_filter(FilterState.posts),
content: {
AnyView(VStack {
SuggestedHashtagsView(damus_state: damus_state, max_items: 5, events: model.events)
AnyView(VStack(alignment: .leading) {
HStack {
Image(systemName: "sparkles")
.foregroundStyle(PinkGradient)
Text("Follow Packs", comment: "A label indicating that the items below it are follow packs")
.foregroundStyle(PinkGradient)
}
.padding(.top)
.padding(.horizontal)
FollowPackTimelineView<AnyView>(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true,filter:content_filter(FilterState.follow_list)
).padding(.bottom)
Divider()
.frame(height: 1)
@@ -100,6 +100,8 @@ struct AppearanceSettingsView: View {
header: Text("Content filters", comment: "Section title for content filtering/moderation configuration."),
footer: Text("Notes with the #nsfw tag usually contains adult content or other \"Not safe for work\" content", comment: "Section footer clarifying what #nsfw (not safe for work) tags mean")
) {
Toggle(NSLocalizedString("Show replies from your trusted network first", comment: "Setting to show replies in threads from the current user's trusted network first."), isOn: $settings.show_trusted_replies_first)
.toggleStyle(.switch)
Toggle(NSLocalizedString("Hide notes with #nsfw tags", comment: "Setting to hide notes with the #nsfw (not safe for work) tags"), isOn: $settings.hide_nsfw_tagged_content)
.toggleStyle(.switch)
}
@@ -96,6 +96,11 @@ struct DeveloperSettingsView: View {
Toggle(NSLocalizedString("Enable experimental Purple In-app purchase support", comment: "Developer mode setting to enable experimental Purple In-app purchase support."), isOn: $settings.enable_experimental_purple_iap_support)
.toggleStyle(.switch)
if #available(iOS 17, *) {
Toggle(NSLocalizedString("Reset tips on launch", comment: "Developer mode setting to reset tips upon app first launch. Tips are visual contextual hints that highlight new, interesting, or unused features users have not discovered yet."), isOn: $settings.reset_tips_on_launch)
.toggleStyle(.switch)
}
}
}
}

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