Compare commits

..

51 Commits

Author SHA1 Message Date
f7a7e7ed8a Fix translation export script by upgrading nostr-sdk-swift dependency to support Mac Catalyst
Changelog-Fixed: Fixed translation export script by upgrading nostr-sdk-swift dependency to support Mac Catalyst
Closes: https://github.com/damus-io/damus/issues/2841
Fixes: 24c3e61a4b ("Fix translation export script")
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-02-10 08:36:04 -05:00
Daniel D’Aquino
bb7ac4fea5 Release notes for v1.12.3
Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-02-07 11:15:11 -08:00
Daniel D’Aquino
05d0e15359 Add release process issue template
A Github issue template to help formalizing the release process, in
order to avoid human errors in the process.

Closes: https://github.com/damus-io/damus/issues/2752
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-02-07 10:45:54 -08:00
Daniel D’Aquino
c21d29a897 Improve clarity of mute button to indicate it serves as a block feature
Changelog-Changed: Improved clarity of the mute button to indicate it can be used for blocking a user
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-02-03 18:33:01 -08:00
Daniel D’Aquino
6e117ac39c Improve Microphone usage description
This makes the microphone access request contain a message that is more
clear to the user

Changelog-Changed: Made the microphone access request message more clear to users
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-02-03 18:33:01 -08:00
Daniel D’Aquino
79407f17e8 Add double star for Purple members that have been active for over a year
This commit adds a special badge for purple members who have been active
for more than one entire year.

Closes: https://github.com/damus-io/damus/issues/2831
Changelog-Added: Purple members who have been active for more than a year now get a special badge
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-01-29 21:36:36 -08:00
Daniel D’Aquino
72c19fc411 Unsubscribe from push notifications on logout
Closes: https://github.com/damus-io/damus/issues/1707
Changelog-Fixed: Fixed issue where users continue to receive push notifications after logout
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-01-24 10:42:29 -08:00
Daniel D’Aquino
24c3e61a4b Make drafts persistent
This commit makes drafts persistent.

It does so by:
1. Converting `DraftsArtifacts` into Nostr events
2. Wrapping those Nostr events into NIP-37 notes
3. Saving those NIP-37 notes into NostrDB
4. Loading those same notes at startup
5. Unwrapping NIP-37 notes into Nostr events
6. Parsing that into `DraftsArtifacts`, loaded into DamusState
7. PostView can then load these drafts

Furthermore, a UX indicator was added to show when a draft has been
saved.

Limitations:
1. No encoding/decoding roundtrip guarantees. That would require
   extensive and heavy refactoring which is out of the scope of this
   commit.
2. We rely on `UserSettings` to keep track of note ids, while we do not
   have Ndb query capabilities
3. No NIP-37 relay sync support has been added yet, as that adds
   important privacy and sync conflict considerations which are out of
   the scope of this ticket, which is ensuring people don't lose their
   progress while writing notes.
4. The main use cases and scenarios have been tested. Because of (1),
   there may be some small inconsistencies on the stored version of the
   draft, but care was taken to keep the substantial portions of the
   content intact.

Closes: https://github.com/damus-io/damus/issues/1862
Changelog-Added: Added local persistence of note drafts
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-01-24 10:36:46 -08:00
Daniel D’Aquino
74d5bee1f6 Fix disappearing events on thread view
This commit fixes an issue where events in threads would occasionally
disappear.

Previously, the computation of parent events and reply events depended
on EventCache and had to be manually computed upon event selection
change. This may lead to inconsistencies if the computation is not
re-done after a new event that leads to a change in the model, or if certain
events are not yet on the cache. Instead, these are now computed
properties inside ThreadModel, and relies exclusively on the events
already in the ThreadModel.

Several other smaller improvements were made around the affected class,
including:
- Removing unused code for simplicity
- Configuring the class external interface with more intent, avoiding
  misusage
- Adding more documentation on the usage of things, as well as
  implementation notes on why certain design decisions were taken.
- Moving things to explicit actors, to integrate more structured concurrency
- Improving code efficiency to lower computational overhead on the main
  actor
- Splitting concerns between objects with more intent and thoughful
  design.

Changelog-Fixed: Fixed an issue where events on a thread view would occasionally disappear
Closes: https://github.com/damus-io/damus/issues/2791
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-01-24 10:36:46 -08:00
Daniel D’Aquino
8066fa1bf8 Improve robustness of the URL handler
This commit improves reliability on the handling of
external URLs.

This was achieved through the following improvements:
1. The URL handler interface is now well-defined, with more clear inputs
   and outputs, to avoid silent failures and error paths that are hard to see
   within convoluted logic paths
2. Side effects during URL parsing were almost completely removed for
   more predictable behavior
3. Error handling logic was added to present errors to the user in a user-friendly manner,
   instead of silently failing
4. Event loading logic was moved into a special new thread view, which
   makes its own internal state evident to the user (i.e. whether
   the note is loading, loaded, or if the note could not be found)

These changes make the URL opening logic more predictable, easy to
refactor, and helps ensure the user always gets some outcome from
opening a URL, even if it means showing a "not found" or "error" screen,
to eliminate cases where nothing seems to happen.

Closes: https://github.com/damus-io/damus/issues/2429
Changelog-Fixed: Improved robustness of the URL handler
Changelog-Added: Added user-friendly error view for errors around the app that would not fit in other places
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-01-24 10:05:55 -08:00
26df547605 Remove language filtering from Universe feed because language detection can be inaccurate
Changelog-Removed: Removed language filtering from Universe feed because language detection can be inaccurate

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-01-22 17:23:00 +09:00
a97532b90d Translate notes even if they are in a preferred language but not the current language as that is what users expect
Changelog-Fixed: Translate notes even if they are in a preferred language but not the current language as that is what users expect

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-01-22 17:23:00 +09:00
Daniel D’Aquino
e8ba1ec806 Merge pull request #2812 from damus-io/translations
Translations
2025-01-22 16:56:56 +09:00
Daniel D’Aquino
e8c265a4d8 Version bump to 1.13
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-01-22 14:03:22 +09:00
Daniel D’Aquino
b33dc63fe4 v1.12 changelog
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-01-22 13:56:42 +09:00
transifex-integration[bot]
c4852f1309 Translate Localizable.strings in hu_HU
100% translated source file: 'Localizable.strings'
on 'hu_HU'.
2025-01-21 13:36:41 +00:00
transifex-integration[bot]
39a4be7076 Translate Localizable.strings in de
100% translated source file: 'Localizable.strings'
on 'de'.
2025-01-20 20:01:03 +00:00
transifex-integration[bot]
50c7edc420 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-01-20 19:40:49 +00:00
transifex-integration[bot]
67fa3c1ce5 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-01-20 19:40:37 +00:00
transifex-integration[bot]
cd671da3e7 Translate Localizable.strings in nl
100% translated source file: 'Localizable.strings'
on 'nl'.
2025-01-20 19:40:30 +00:00
transifex-integration[bot]
3b60ca04f1 Translate Localizable.strings in pl_PL
100% translated source file: 'Localizable.strings'
on 'pl_PL'.
2025-01-20 11:46:31 +00:00
transifex-integration[bot]
e2e58499f5 Translate Localizable.strings in ja
100% translated source file: 'Localizable.strings'
on 'ja'.
2025-01-20 00:40:20 +00:00
5cadf09665 Export strings for translation 2025-01-19 12:29:10 -05:00
transifex-integration[bot]
1ca7b3462f Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:10 -05:00
transifex-integration[bot]
8a552d2b0f Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:10 -05:00
transifex-integration[bot]
9fa0f18f78 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:10 -05:00
transifex-integration[bot]
db672ca048 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:09 -05:00
transifex-integration[bot]
18ad73cd35 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:09 -05:00
transifex-integration[bot]
5719e9b37e Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:09 -05:00
transifex-integration[bot]
9fb2b3c0e5 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:09 -05:00
transifex-integration[bot]
5ec66feb06 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:09 -05:00
transifex-integration[bot]
ccc301cfcc Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:09 -05:00
transifex-integration[bot]
c1b9d0b55e Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:09 -05:00
transifex-integration[bot]
d9daa27016 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:08 -05:00
transifex-integration[bot]
fa3b5d57ed Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:08 -05:00
transifex-integration[bot]
7c3e598ca6 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:08 -05:00
transifex-integration[bot]
563d5c7881 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:08 -05:00
transifex-integration[bot]
b8cba0ee17 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:08 -05:00
transifex-integration[bot]
8556586af4 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:08 -05:00
transifex-integration[bot]
5fc52bb31b Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:08 -05:00
transifex-integration[bot]
a92c9f2c38 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:08 -05:00
transifex-integration[bot]
61e137696e Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:07 -05:00
transifex-integration[bot]
8fc3b124da Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:07 -05:00
transifex-integration[bot]
7852822295 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:07 -05:00
transifex-integration[bot]
85e55953b3 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:07 -05:00
transifex-integration[bot]
077f633f33 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:07 -05:00
transifex-integration[bot]
1c3d1598a3 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:07 -05:00
transifex-integration[bot]
314608627e Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:07 -05:00
transifex-integration[bot]
aeecc04b29 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:06 -05:00
transifex-integration[bot]
341389d438 Translate Localizable.strings in th
100% translated source file: 'Localizable.strings'
on 'th'.
2025-01-19 12:21:06 -05:00
transifex-integration[bot]
fbeae64123 Translate Localizable.strings in pt_PT
100% translated source file: 'Localizable.strings'
on 'pt_PT'.
2025-01-19 12:21:05 -05:00
42 changed files with 2025 additions and 411 deletions

52
.github/ISSUE_TEMPLATE/app_release.md vendored Normal file
View File

@@ -0,0 +1,52 @@
---
name: App release process
about: Begin preparing for a new app release
title: 'Release: '
labels: release-tasks
assignees: ''
---
A new version release. Please attempt to follow the release process steps below in the order they are shown.
## TestFlight release candidates
### Release candidate 1
**Version:** _[Enter full build information for the release candidate, including major and minor version number, build number, and commit hash]_
1. [ ] Merge in all needed changes to `master`
2. [ ] Check CI, make sure it is passing
3. [ ] Prepare preliminary changelog as a draft PR: _[Enter PR link to changelog here]_
4. [ ] Make a _release_ build and submit to the internal TestFlight group via our new Release candidate workflow in Xcode Cloud.
5. [ ] Prepare short screencast style video with main changes for the announcement
6. [ ] Publish release build to these TestFlight groups:
- [ ] Alpha testers group
- [ ] Translators group
- [ ] Purple group
7. [ ] Publish announcement on Nostr
_[Duplicate this release candidate section if there is more than one release candidate]_
## App Store release
1. [ ] Release candidate checks:
- [ ] Release candidate has been on Purple TestFlight for at least one week
- [ ] No blocker issues came from feedback from Purple users (double-check)
- [ ] Check with stakeholders
- [ ] Check with developers & product for any release showstoppers (e.g., critical newfound bugs)
2. [ ] Thorough check on release notes
3. [ ] Submit to App Store review (with manual publishing setting enabled)
4. [ ] Get App Store approval from Apple
5. [ ] Prepare announcement
7. [ ] Publish on the App Store and make announcement
8. [ ] Publish changelog and tag commit hash corresponding to the release
9. [ ] Perform a version bump on the repository, in preparation for the next release
## Notes/others
_Enter any relevant notes here_

View File

@@ -1,3 +1,56 @@
## [1.12.3] - 2025-02-06
### Added
- Purple members who have been active for more than a year now get a special badge (Daniel DAquino)
### Changed
- Improved clarity of the mute button to indicate it can be used for blocking a user (Daniel DAquino)
- Made the microphone access request message more clear to users (Daniel DAquino)
[v1.12.3]: https://github.com/damus-io/damus/releases/tag/v1.12.3
## [1.12](https://github.com/damus-io/damus/releases/tag/v1.12) - 2024-12-20
### Added
- Render Gif and video files while composing posts (Swift Coder)
- Add profile info text in stretchable banner with follow button (Swift Coder)
- Paste Gif image similar to jpeg and png files (Swift Coder)
### Changed
- Improved UX around the label for searching words (Daniel DAquino)
- Improved accessibility support on some elements (Daniel DAquino)
### Fixed
- Fixed issue where the "next" button would appear hidden and hard to click on the create account view (Daniel DAquino)
- Fix non scrollable wallet screen (Swift Coder)
- Fixed suggested users category titles to be localizable (Terry Yiu)
- Fixed GradientFollowButton to have consistent width and autoscale text limited to 1 line (Terry Yiu)
- Fixed right-to-left localization issues (Terry Yiu)
- Fixed AddMuteItemView to trim leading and trailing whitespaces from mute text and disallow adding text with only whitespaces (Terry Yiu)
- Fixed SideMenuView text to autoscale and limit to 1 line (Terry Yiu)
- Fixed an issue where a profile would need to be input twice in the search to be found (Daniel DAquino)
- Fixed non-breaking spaces in localized strings (Terry Yiu)
- Fixed localization issue on Add mute item button (Terry Yiu)
- Replace non-breaking spaces with regular spaces as Apple's NSLocalizedString macro does not seem to work with it (Terry Yiu)
- Fixed localization issues in RelayConfigView (Terry Yiu)
- Fix duplicate uploads (Swift Coder)
- Remove duplicate pubkey from Follow Suggestion list (Swift Coder)
- Fix Page control indicator (Swift Coder)
- Fix damus sharing issues (Swift Coder)
- Fixed issue where banner edit button is unclickable (Daniel DAquino)
- Handle empty notification pages by displaying suitable text (Swift Coder)
[v1.12](https://github.com/damus-io/damus/releases/tag/v1.12): [https://github.com/damus-io/damus/releases/tag/v1.12]
## [v1.11(10)](https://github.com/damus-io/damus/releases/tag/v1.11-10) - 2024-11-18
### Added

View File

@@ -875,7 +875,6 @@
82D6FC682CD99F7900C925F4 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363AA128296A7E006E126D /* SearchView.swift */; };
82D6FC692CD99F7900C925F4 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; };
82D6FC6A2CD99F7900C925F4 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3AC7A02835A81400E1F516 /* SetupView.swift */; };
82D6FC6B2CD99F7900C925F4 /* ThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E4ED0A295867B900DD7078 /* ThreadView.swift */; };
82D6FC6C2CD99F7900C925F4 /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA2EF9F280E37AC0044ACD8 /* TimelineView.swift */; };
82D6FC6D2CD99F7900C925F4 /* UserRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB55EF4295E679D007FD187 /* UserRelaysView.swift */; };
82D6FC6E2CD99F7900C925F4 /* SideMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647D9A8C2968520300A295DE /* SideMenuView.swift */; };
@@ -1388,7 +1387,6 @@
D73E5F602C6A97F5007EB227 /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5C7E69284EDE2E00A22DF5 /* SearchResultsView.swift */; };
D73E5F612C6A97F5007EB227 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363AA128296A7E006E126D /* SearchView.swift */; };
D73E5F622C6A97F5007EB227 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; };
D73E5F642C6A97F5007EB227 /* ThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E4ED0A295867B900DD7078 /* ThreadView.swift */; };
D73E5F652C6A97F5007EB227 /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA2EF9F280E37AC0044ACD8 /* TimelineView.swift */; };
D73E5F662C6A97F5007EB227 /* UserRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB55EF4295E679D007FD187 /* UserRelaysView.swift */; };
D73E5F682C6A97F5007EB227 /* BannerImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9609F057296E220800069BF3 /* BannerImageView.swift */; };
@@ -1445,10 +1443,22 @@
D74AAFD22B155E78006CF0F4 /* WalletConnect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09612A098D0E00943473 /* WalletConnect.swift */; };
D74AAFD42B155ECB006CF0F4 /* Zaps+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */; };
D74AAFD62B155F0C006CF0F4 /* WalletConnect+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */; };
D74EA08A2D2BF2A7002290DD /* URLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D767066E2C8BB3CE00F09726 /* URLHandler.swift */; };
D74EA08E2D2E271E002290DD /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA08D2D2E271E002290DD /* ErrorView.swift */; };
D74EA08F2D2E271E002290DD /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA08D2D2E271E002290DD /* ErrorView.swift */; };
D74EA0902D2E271E002290DD /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA08D2D2E271E002290DD /* ErrorView.swift */; };
D74EA0912D2E3464002290DD /* URLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D767066E2C8BB3CE00F09726 /* URLHandler.swift */; };
D74EA0932D2E77B9002290DD /* LoadableThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableThreadView.swift */; };
D74EA0942D2E77B9002290DD /* LoadableThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableThreadView.swift */; };
D74EA0952D2E77B9002290DD /* LoadableThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA0922D2E77B9002290DD /* LoadableThreadView.swift */; };
D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F43092B23F0BE00425B75 /* DamusPurple.swift */; };
D74F430C2B23FB9B00425B75 /* StoreObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F430B2B23FB9B00425B75 /* StoreObserver.swift */; };
D753CEAA2BE9DE04001C3A5D /* MutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D753CEA92BE9DE04001C3A5D /* MutingTests.swift */; };
D755B28D2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */; };
D755B28E2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */; };
D755B28F2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */; };
D76556D62B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */; };
D767066F2C8BB3CF00F09726 /* URLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D767066E2C8BB3CE00F09726 /* URLHandler.swift */; };
D76874F32AE3632B00FB0F68 /* ProfileZapLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */; };
D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; };
D773BC602C6D538500349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; };
@@ -1490,6 +1500,10 @@
D7ADD3E22B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */; };
D7B76C902C825042003A16CB /* PushNotificationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */; };
D7B76C912C82507F003A16CB /* NIP98AuthenticatedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */; };
D7BEE6F32D37AE1B00CF659F /* NostrSDK in Frameworks */ = {isa = PBXBuildFile; productRef = D7BEE6F22D37AE1B00CF659F /* NostrSDK */; };
D7BEE6F52D37B20400CF659F /* NostrSDK in Frameworks */ = {isa = PBXBuildFile; productRef = D7BEE6F42D37B20400CF659F /* NostrSDK */; };
D7BEE6F72D37B21400CF659F /* NostrSDK in Frameworks */ = {isa = PBXBuildFile; productRef = D7BEE6F62D37B21400CF659F /* NostrSDK */; };
D7BEE6F92D37B37400CF659F /* DraftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7BEE6F82D37B37400CF659F /* DraftTests.swift */; };
D7C48C0B2D12DE0C00A3BACF /* SwiftyCrop in Frameworks */ = {isa = PBXBuildFile; productRef = D7C48C0A2D12DE0C00A3BACF /* SwiftyCrop */; };
D7C48C0D2D12E34900A3BACF /* SwiftyCrop in Frameworks */ = {isa = PBXBuildFile; productRef = D7C48C0C2D12E34900A3BACF /* SwiftyCrop */; };
D7C48C0F2D12E35600A3BACF /* SwiftyCrop in Frameworks */ = {isa = PBXBuildFile; productRef = D7C48C0E2D12E35600A3BACF /* SwiftyCrop */; };
@@ -1639,7 +1653,6 @@
E0EE9DD42B8E5FEA00F3002D /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0EE9DD32B8E5FEA00F3002D /* ImageProcessing.swift */; };
E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */; };
E990020F2955F837003BBC5A /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E990020E2955F837003BBC5A /* EditMetadataView.swift */; };
E9E4ED0B295867B900DD7078 /* ThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E4ED0A295867B900DD7078 /* ThreadView.swift */; };
F71694EA2A662232001F4053 /* OnboardingSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */; };
F71694EC2A662292001F4053 /* SuggestedUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */; };
F71694EE2A6624F9001F4053 /* suggested_users.json in Resources */ = {isa = PBXBuildFile; fileRef = F71694ED2A6624F9001F4053 /* suggested_users.json */; };
@@ -2425,10 +2438,14 @@
D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapDataModel.swift; sourceTree = "<group>"; };
D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Zaps+.swift"; sourceTree = "<group>"; };
D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WalletConnect+.swift"; sourceTree = "<group>"; };
D74EA08D2D2E271E002290DD /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
D74EA0922D2E77B9002290DD /* LoadableThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableThreadView.swift; sourceTree = "<group>"; };
D74F43092B23F0BE00425B75 /* DamusPurple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurple.swift; sourceTree = "<group>"; };
D74F430B2B23FB9B00425B75 /* StoreObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreObserver.swift; sourceTree = "<group>"; };
D753CEA92BE9DE04001C3A5D /* MutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutingTests.swift; sourceTree = "<group>"; };
D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP37Draft.swift; sourceTree = "<group>"; };
D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleWelcomeView.swift; sourceTree = "<group>"; };
D767066E2C8BB3CE00F09726 /* URLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLHandler.swift; sourceTree = "<group>"; };
D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileZapLinkView.swift; sourceTree = "<group>"; };
D773BC5E2C6D538500349F0A /* CommentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentItem.swift; sourceTree = "<group>"; };
D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = "<group>"; };
@@ -2450,6 +2467,7 @@
D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURL.swift; sourceTree = "<group>"; };
D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURLSheetView.swift; sourceTree = "<group>"; };
D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleVerifyNpubView.swift; sourceTree = "<group>"; };
D7BEE6F82D37B37400CF659F /* DraftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftTests.swift; sourceTree = "<group>"; };
D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP98AuthenticatedRequest.swift; sourceTree = "<group>"; };
D7CB5D3D2B116DAD00AD4105 /* NotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManager.swift; sourceTree = "<group>"; };
D7CB5D442B116FE800AD4105 /* Contacts+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Contacts+.swift"; sourceTree = "<group>"; };
@@ -2483,7 +2501,6 @@
E0EE9DD32B8E5FEA00F3002D /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = "<group>"; };
E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSettingsView.swift; sourceTree = "<group>"; };
E990020E2955F837003BBC5A /* EditMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditMetadataView.swift; sourceTree = "<group>"; };
E9E4ED0A295867B900DD7078 /* ThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadView.swift; sourceTree = "<group>"; };
F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingSuggestionsView.swift; sourceTree = "<group>"; };
F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedUsersViewModel.swift; sourceTree = "<group>"; };
F71694ED2A6624F9001F4053 /* suggested_users.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = suggested_users.json; sourceTree = "<group>"; };
@@ -2506,6 +2523,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D7BEE6F32D37AE1B00CF659F /* NostrSDK in Frameworks */,
4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */,
3A0A30BB2C21397A00F8C9BC /* EmojiPicker in Frameworks */,
D70D90982CDED61800CD0534 /* CodeScanner in Frameworks */,
@@ -2536,6 +2554,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D7BEE6F52D37B20400CF659F /* NostrSDK in Frameworks */,
82D6FC862CD9A4A600C925F4 /* MarkdownUI in Frameworks */,
82D6FC8A2CD9A54600C925F4 /* SwipeActions in Frameworks */,
D7F360292CEBBE34009D34DA /* CodeScanner in Frameworks */,
@@ -2552,6 +2571,7 @@
files = (
D703D7AF2C670FB700A400EA /* MarkdownUI in Frameworks */,
D73E5F9D2C6AA8E3007EB227 /* SwipeActions in Frameworks */,
D7BEE6F72D37B21400CF659F /* NostrSDK in Frameworks */,
D73E5F762C6A997E007EB227 /* EmojiPicker in Frameworks */,
D703D7192C66E47100A400EA /* UniformTypeIdentifiers.framework in Frameworks */,
D7C48C0F2D12E35600A3BACF /* SwiftyCrop in Frameworks */,
@@ -2723,6 +2743,7 @@
D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */,
5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */,
D773BC5E2C6D538500349F0A /* CommentItem.swift */,
D767066E2C8BB3CE00F09726 /* URLHandler.swift */,
);
path = Models;
sourceTree = "<group>";
@@ -3063,6 +3084,7 @@
4C75EFA227FA576C0006080F /* Views */ = {
isa = PBXGroup;
children = (
D74EA08C2D2E26E6002290DD /* ErrorHandling */,
D7D68FF72C9E01A80015A515 /* Utils */,
D78DB85D2C20FE9E00F0AB12 /* Chat */,
D71AC4CA2BA8E3320076268E /* Extensions */,
@@ -3119,7 +3141,6 @@
4C363AA128296A7E006E126D /* SearchView.swift */,
BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */,
4C3AC7A02835A81400E1F516 /* SetupView.swift */,
E9E4ED0A295867B900DD7078 /* ThreadView.swift */,
4CA2EF9F280E37AC0044ACD8 /* TimelineView.swift */,
4CB55EF4295E679D007FD187 /* UserRelaysView.swift */,
647D9A8C2968520300A295DE /* SideMenuView.swift */,
@@ -3134,6 +3155,7 @@
D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */,
D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */,
D71AD8FC2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift */,
D74EA0922D2E77B9002290DD /* LoadableThreadView.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -3557,6 +3579,7 @@
4CE6DEE527F7A08100C66700 /* damus */ = {
isa = PBXGroup;
children = (
D755B28B2D3E7D6500BBEEFA /* NIP37 */,
4C45E5002BED4CE10025A428 /* NIP10 */,
4C1D4FB32A7967990024F453 /* build-git-hash.txt */,
4CA3529C2A76AE47003BB08B /* Notify */,
@@ -3603,6 +3626,7 @@
50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */,
4C90BD1B283AC38E008EE7EF /* Bech32Tests.swift */,
E02B54172B4DFADA0077FF42 /* Bech32ObjectTests.swift */,
D7BEE6F82D37B37400CF659F /* DraftTests.swift */,
4C363A9F2828A8DD006E126D /* LikeTests.swift */,
4C363A9D2828A822006E126D /* ReplyTests.swift */,
4CE6DEF727F7A08200C66700 /* damusTests.swift */,
@@ -3853,6 +3877,14 @@
path = Mocking;
sourceTree = "<group>";
};
D74EA08C2D2E26E6002290DD /* ErrorHandling */ = {
isa = PBXGroup;
children = (
D74EA08D2D2E271E002290DD /* ErrorView.swift */,
);
path = ErrorHandling;
sourceTree = "<group>";
};
D74F43082B23F09300425B75 /* Purple */ = {
isa = PBXGroup;
children = (
@@ -3866,6 +3898,14 @@
path = Purple;
sourceTree = "<group>";
};
D755B28B2D3E7D6500BBEEFA /* NIP37 */ = {
isa = PBXGroup;
children = (
D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */,
);
path = NIP37;
sourceTree = "<group>";
};
D78DB85D2C20FE9E00F0AB12 /* Chat */ = {
isa = PBXGroup;
children = (
@@ -3972,6 +4012,7 @@
D78DB8582C1CE9CA00F0AB12 /* SwipeActions */,
D70D90972CDED61800CD0534 /* CodeScanner */,
D7C48C0A2D12DE0C00A3BACF /* SwiftyCrop */,
D7BEE6F22D37AE1B00CF659F /* NostrSDK */,
);
productName = damus;
productReference = 4CE6DEE327F7A08100C66700 /* damus.app */;
@@ -4038,6 +4079,7 @@
82D6FC892CD9A54600C925F4 /* SwipeActions */,
D7F360282CEBBE34009D34DA /* CodeScanner */,
D7C48C0C2D12E34900A3BACF /* SwiftyCrop */,
D7BEE6F42D37B20400CF659F /* NostrSDK */,
);
productName = "share extension";
productReference = 82D6FA972CD9820500C925F4 /* ShareExtension.appex */;
@@ -4066,6 +4108,7 @@
D73E5F9C2C6AA8E3007EB227 /* SwipeActions */,
D70D909B2CDED7B200CD0534 /* CodeScanner */,
D7C48C0E2D12E35600A3BACF /* SwiftyCrop */,
D7BEE6F62D37B21400CF659F /* NostrSDK */,
);
productName = "highlighter action extension";
productReference = D703D7172C66E47100A400EA /* HighlighterActionExtension.appex */;
@@ -4175,6 +4218,7 @@
D78DB8572C1CE9CA00F0AB12 /* XCRemoteSwiftPackageReference "SwipeActions" */,
D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */,
D7C48C092D12DE0C00A3BACF /* XCRemoteSwiftPackageReference "SwiftyCrop" */,
D7BEE6F12D37AE1B00CF659F /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */,
);
productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */;
projectDirPath = "";
@@ -4436,7 +4480,6 @@
4CA3FA1029F593D000FDB3C3 /* ZapTypePicker.swift in Sources */,
4C32B95D2A9AD44700DC3548 /* Documentation.docc in Sources */,
4C3EA66828FF5F9900C48A62 /* hex.c in Sources */,
E9E4ED0B295867B900DD7078 /* ThreadView.swift in Sources */,
4CD348EF29C3659D00497EB2 /* ImageUploadModel.swift in Sources */,
4C3BEFDC281DCE6100B3DE84 /* Liked.swift in Sources */,
4CF0ABE7298444FD00D66079 /* EventMutingContainerView.swift in Sources */,
@@ -4549,6 +4592,7 @@
4C363A8828236948006E126D /* BlocksView.swift in Sources */,
4C06670628FCB08600038D2A /* ImageCarousel.swift in Sources */,
3A23838E2A297DD200E5AA2E /* ZapButtonModel.swift in Sources */,
D755B28D2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */,
F71694F82A6983AF001F4053 /* GrayGradient.swift in Sources */,
4C1D4FB12A7958E60024F453 /* VersionInfo.swift in Sources */,
D7FF94002AC7AC5300FD969D /* RelayURL.swift in Sources */,
@@ -4635,6 +4679,7 @@
4CA927612A290E340098A105 /* EventShell.swift in Sources */,
4C363AA428296DEE006E126D /* SearchModel.swift in Sources */,
4C8D00CC29DF92DF0036AF10 /* Hashtags.swift in Sources */,
D74EA0942D2E77B9002290DD /* LoadableThreadView.swift in Sources */,
4CEE2AF3280B25C500AB5EEF /* ProfilePicView.swift in Sources */,
4CC7AAF6297F1A6A00430951 /* EventBody.swift in Sources */,
D76556D62B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift in Sources */,
@@ -4742,6 +4787,7 @@
D7CB5D4E2B11728000AD4105 /* NewEventsBits.swift in Sources */,
4CC7AAFA297F64AC00430951 /* EventMenu.swift in Sources */,
B57B4C622B312BD700A232C0 /* ReconnectRelaysNotify.swift in Sources */,
D767066F2C8BB3CF00F09726 /* URLHandler.swift in Sources */,
D7ADD3DE2B53854300F104C4 /* DamusPurpleURL.swift in Sources */,
E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */,
4C75EFBB2804A34C0006080F /* ProofOfWork.swift in Sources */,
@@ -4754,6 +4800,7 @@
4C9AA14A2A4587A6003F49FD /* NotificationStatusModel.swift in Sources */,
D7100C5C2B77016700C59298 /* IAPProductStateView.swift in Sources */,
4CB9D4A72992D02B00A9A7E4 /* ProfileNameView.swift in Sources */,
D74EA0902D2E271E002290DD /* ErrorView.swift in Sources */,
4CE4F0F429D779B5005914DB /* PostBox.swift in Sources */,
BA37598E2ABCCE500018D73B /* VideoCaptureProcessor.swift in Sources */,
4C9B0DF32A65C46800CBDA21 /* ProfileEditButton.swift in Sources */,
@@ -4797,6 +4844,7 @@
75AD872B2AA23A460085EF2C /* Block+Tests.swift in Sources */,
E0E024112B7C19C20075735D /* TranslationTests.swift in Sources */,
F944F56E29EA9CCC0067B3BF /* DamusParseContentTests.swift in Sources */,
D7BEE6F92D37B37400CF659F /* DraftTests.swift in Sources */,
B501062D2B363036003874F5 /* AuthIntegrationTests.swift in Sources */,
4CB883AE2976FA9300DC99E7 /* FormatTests.swift in Sources */,
D72A2D052AD9C1B5002AFF62 /* MockDamusState.swift in Sources */,
@@ -4899,6 +4947,7 @@
82D6FAEC2CD99F7900C925F4 /* OnlyZapsNotify.swift in Sources */,
82D6FAED2CD99F7900C925F4 /* PostNotify.swift in Sources */,
82D6FAEE2CD99F7900C925F4 /* PresentSheetNotify.swift in Sources */,
D74EA0932D2E77B9002290DD /* LoadableThreadView.swift in Sources */,
82D6FAEF2CD99F7900C925F4 /* ProfileUpdatedNotify.swift in Sources */,
82D6FAF02CD99F7900C925F4 /* ReportNotify.swift in Sources */,
82D6FAF12CD99F7900C925F4 /* ScrollToTopNotify.swift in Sources */,
@@ -5064,6 +5113,7 @@
82D6FB922CD99F7900C925F4 /* Wallet.swift in Sources */,
82D6FB932CD99F7900C925F4 /* Report.swift in Sources */,
82D6FB942CD99F7900C925F4 /* LibreTranslateServer.swift in Sources */,
D74EA08E2D2E271E002290DD /* ErrorView.swift in Sources */,
82D6FB952CD99F7900C925F4 /* TranslationService.swift in Sources */,
82D6FB962CD99F7900C925F4 /* DeepLPlan.swift in Sources */,
82D6FB972CD99F7900C925F4 /* ZapsModel.swift in Sources */,
@@ -5076,6 +5126,7 @@
82D6FB9E2CD99F7900C925F4 /* ContentFilters.swift in Sources */,
82D6FB9F2CD99F7900C925F4 /* DamusCacheManager.swift in Sources */,
82D6FBA02CD99F7900C925F4 /* NotificationsManager.swift in Sources */,
D755B28E2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */,
82D6FBA12CD99F7900C925F4 /* Contacts+.swift in Sources */,
82D6FBA22CD99F7900C925F4 /* ZapType.swift in Sources */,
82D6FBA32CD99F7900C925F4 /* NewEventsBits.swift in Sources */,
@@ -5245,6 +5296,7 @@
82D6FC492CD99F7900C925F4 /* BigButton.swift in Sources */,
82D6FC4A2CD99F7900C925F4 /* AddRelayView.swift in Sources */,
82D6FC4B2CD99F7900C925F4 /* BlocksView.swift in Sources */,
D74EA0912D2E3464002290DD /* URLHandler.swift in Sources */,
82D6FC4C2CD99F7900C925F4 /* BookmarksView.swift in Sources */,
82D6FC4D2CD99F7900C925F4 /* CarouselView.swift in Sources */,
82D6FC4E2CD99F7900C925F4 /* ConfigView.swift in Sources */,
@@ -5277,7 +5329,6 @@
82D6FC682CD99F7900C925F4 /* SearchView.swift in Sources */,
82D6FC692CD99F7900C925F4 /* SelectWalletView.swift in Sources */,
82D6FC6A2CD99F7900C925F4 /* SetupView.swift in Sources */,
82D6FC6B2CD99F7900C925F4 /* ThreadView.swift in Sources */,
82D6FC6C2CD99F7900C925F4 /* TimelineView.swift in Sources */,
82D6FC6D2CD99F7900C925F4 /* UserRelaysView.swift in Sources */,
82D6FC6E2CD99F7900C925F4 /* SideMenuView.swift in Sources */,
@@ -5335,6 +5386,7 @@
D73E5E3A2C6A97F4007EB227 /* SwipeToDismiss.swift in Sources */,
D73E5E3B2C6A97F4007EB227 /* MusicController.swift in Sources */,
D73E5E3C2C6A97F4007EB227 /* UserStatusView.swift in Sources */,
D74EA08F2D2E271E002290DD /* ErrorView.swift in Sources */,
D73E5E3E2C6A97F4007EB227 /* SearchHeaderView.swift in Sources */,
D73E5E3F2C6A97F4007EB227 /* DamusGradient.swift in Sources */,
D73E5E402C6A97F4007EB227 /* AlbyGradient.swift in Sources */,
@@ -5429,6 +5481,7 @@
D73E5E9C2C6A97F4007EB227 /* Reply.swift in Sources */,
D73E5E9D2C6A97F4007EB227 /* SearchModel.swift in Sources */,
D73E5E9E2C6A97F4007EB227 /* NostrFilter+Hashable.swift in Sources */,
D74EA0952D2E77B9002290DD /* LoadableThreadView.swift in Sources */,
D73E5F912C6AA71B007EB227 /* InputDismissKeyboard.swift in Sources */,
D73E5E9F2C6A97F4007EB227 /* CreateAccountModel.swift in Sources */,
D73E5EA12C6A97F4007EB227 /* SignalModel.swift in Sources */,
@@ -5634,7 +5687,6 @@
D73E5F602C6A97F5007EB227 /* SearchResultsView.swift in Sources */,
D73E5F612C6A97F5007EB227 /* SearchView.swift in Sources */,
D73E5F622C6A97F5007EB227 /* SelectWalletView.swift in Sources */,
D73E5F642C6A97F5007EB227 /* ThreadView.swift in Sources */,
D73E5F652C6A97F5007EB227 /* TimelineView.swift in Sources */,
D73E5F662C6A97F5007EB227 /* UserRelaysView.swift in Sources */,
D73E5F682C6A97F5007EB227 /* BannerImageView.swift in Sources */,
@@ -5757,6 +5809,7 @@
D703D7622C670ACB00A400EA /* ByteBuffer.swift in Sources */,
D703D79A2C670DFD00A400EA /* bech32.c in Sources */,
D703D7B62C67118200A400EA /* String+extension.swift in Sources */,
D74EA08A2D2BF2A7002290DD /* URLHandler.swift in Sources */,
D703D76C2C670B3900A400EA /* Post.swift in Sources */,
D703D77A2C670BEB00A400EA /* VeriferOptions.swift in Sources */,
D73E5F9E2C6AA9F7007EB227 /* nostrscript.c in Sources */,
@@ -5764,6 +5817,7 @@
D703D7472C67092700A400EA /* UserSettingsStore.swift in Sources */,
D703D7852C670C6100A400EA /* Notify.swift in Sources */,
D703D7532C670A2600A400EA /* Wallet.swift in Sources */,
D755B28F2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */,
D703D75F2C670AA200A400EA /* NostrEvent.swift in Sources */,
D703D7442C67086800A400EA /* HeadlessDamusState.swift in Sources */,
D703D7922C670D2900A400EA /* RelayURL.swift in Sources */,
@@ -6230,7 +6284,7 @@
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
INFOPLIST_KEY_NSCameraUsageDescription = "Damus needs access to your camera if you want to scan QR codes and upload photos from it";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Local authentication to access private key";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Damus needs access to your microphone if you want to upload recorded videos from it";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Damus needs access to your microphone to allow you to create video recordings that you can choose to post publicly on the network";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Granting Damus access to your photos allows you to save images.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -6247,7 +6301,7 @@
"$(inherited)",
"$(PROJECT_DIR)",
);
MARKETING_VERSION = 1.12;
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -6282,7 +6336,7 @@
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
INFOPLIST_KEY_NSCameraUsageDescription = "Damus needs access to your camera if you want to scan QR codes and upload photos from it";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Local authentication to access private key";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Damus needs access to your microphone if you want to upload recorded videos from it";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Damus needs access to your microphone to allow you to create video recordings that you can choose to post publicly on the network";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Granting Damus access to your photos allows you to save images.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -6299,7 +6353,7 @@
"$(inherited)",
"$(PROJECT_DIR)",
);
MARKETING_VERSION = 1.12;
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -6406,7 +6460,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.share-extension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -6440,7 +6494,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.share-extension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -6474,7 +6528,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.highlighter-action-extension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -6509,7 +6563,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.highlighter-action-extension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -6528,6 +6582,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_ENTITLEMENTS = DamusNotificationService/DamusNotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = XK7H4JAB3D;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -6542,6 +6597,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2.DamusNotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -6561,6 +6617,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_ENTITLEMENTS = DamusNotificationService/DamusNotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = XK7H4JAB3D;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -6575,6 +6632,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2.DamusNotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -6719,6 +6777,14 @@
minimumVersion = 1.14.1;
};
};
D7BEE6F12D37AE1B00CF659F /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/rust-nostr/nostr-sdk-swift";
requirement = {
kind = revision;
revision = 27711a03ea7d977162595eea1d9b2d5a45f0b628;
};
};
D7C48C092D12DE0C00A3BACF /* XCRemoteSwiftPackageReference "SwiftyCrop" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/benedom/SwiftyCrop";
@@ -6840,6 +6906,21 @@
package = D7A343EC2AD0D77C00CED48B /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */;
productName = SnapshotTesting;
};
D7BEE6F22D37AE1B00CF659F /* NostrSDK */ = {
isa = XCSwiftPackageProductDependency;
package = D7BEE6F12D37AE1B00CF659F /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */;
productName = NostrSDK;
};
D7BEE6F42D37B20400CF659F /* NostrSDK */ = {
isa = XCSwiftPackageProductDependency;
package = D7BEE6F12D37AE1B00CF659F /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */;
productName = NostrSDK;
};
D7BEE6F62D37B21400CF659F /* NostrSDK */ = {
isa = XCSwiftPackageProductDependency;
package = D7BEE6F12D37AE1B00CF659F /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */;
productName = NostrSDK;
};
D7C48C0A2D12DE0C00A3BACF /* SwiftyCrop */ = {
isa = XCSwiftPackageProductDependency;
package = D7C48C092D12DE0C00A3BACF /* XCRemoteSwiftPackageReference "SwiftyCrop" */;

View File

@@ -1,5 +1,5 @@
{
"originHash" : "0d806129a33991730dd1aa3d38c47a745f9e9e6ff44999f4a7f28b695f024832",
"originHash" : "fa2b0ad84b4bd1a962ffbe49810548db7c9d7131f4a1fd4b4af06ff4c6de0a44",
"pins" : [
{
"identity" : "codescanner",
@@ -45,6 +45,14 @@
"version" : "7.6.1"
}
},
{
"identity" : "nostr-sdk-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/rust-nostr/nostr-sdk-swift",
"state" : {
"revision" : "27711a03ea7d977162595eea1d9b2d5a45f0b628"
}
},
{
"identity" : "secp256k1.swift",
"kind" : "remoteSourceControl",

View File

@@ -63,7 +63,7 @@ struct SelectableText: View {
})) {
if let event, case .show_highlight_post_view(let highlighted_text) = self.selectedTextActionState {
PostView(
action: .highlighting(.init(selected_text: highlighted_text, source: .event(event))),
action: .highlighting(.init(selected_text: highlighted_text, source: .event(event.id))),
damus_state: damus_state
)
.presentationDragIndicator(.visible)

View File

@@ -12,6 +12,14 @@ struct SupporterBadge: View {
let purple_account: DamusPurple.Account?
let style: Style
let text_color: Color
var badge_variant: BadgeVariant {
if purple_account?.attributes.contains(.memberForMoreThanOneYear) == true {
return .oneYearSpecial
}
else {
return .normal
}
}
init(percent: Int?, purple_account: DamusPurple.Account? = nil, style: Style, text_color: Color = .secondary) {
self.percent = percent
@@ -26,13 +34,18 @@ struct SupporterBadge: View {
HStack {
if let purple_account, purple_account.active == true {
HStack(spacing: 1) {
Image("star.fill")
.resizable()
.frame(width:size, height:size)
.foregroundStyle(GoldGradient)
if self.style == .full {
let date = format_date(date: purple_account.created_at, time_style: .none)
Text(date)
switch self.badge_variant {
case .normal:
StarShape()
.frame(width:size, height:size)
.foregroundStyle(GoldGradient)
case .oneYearSpecial:
DoubleStar(size: size)
}
if self.style == .full,
let ordinal = self.purple_account?.ordinal() {
Text(ordinal)
.foregroundStyle(text_color)
.font(.caption)
}
@@ -56,8 +69,102 @@ struct SupporterBadge: View {
case full // Shows the entire badge with a purple subscriber number if present
case compact // Does not show purple subscriber number. Only shows the star (if applicable)
}
enum BadgeVariant {
/// A normal badge that people are used to
case normal
/// A special badge for users who have been members for more than a year
case oneYearSpecial
}
}
struct StarShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius: CGFloat = min(rect.width, rect.height) / 2
let points = 5
let adjustment: CGFloat = .pi / 2
for i in 0..<points * 2 {
let angle = (CGFloat(i) * .pi / CGFloat(points)) - adjustment
let pointRadius = i % 2 == 0 ? radius : radius * 0.4
let point = CGPoint(x: center.x + pointRadius * cos(angle), y: center.y + pointRadius * sin(angle))
if i == 0 {
path.move(to: point)
} else {
path.addLine(to: point)
}
}
path.closeSubpath()
return path
}
}
struct DoubleStar: View {
let size: CGFloat
var starOffset: CGFloat = 5
var body: some View {
if #available(iOS 17.0, *) {
DoubleStarShape(starOffset: starOffset)
.frame(width: size, height: size)
.foregroundStyle(GoldGradient)
.padding(.trailing, starOffset)
} else {
Fallback(size: size, starOffset: starOffset)
}
}
@available(iOS 17.0, *)
struct DoubleStarShape: Shape {
var strokeSize: CGFloat = 3
var starOffset: CGFloat
func path(in rect: CGRect) -> Path {
let normalSizedStarPath = StarShape().path(in: rect)
let largerStarPath = StarShape().path(in: rect.insetBy(dx: -strokeSize, dy: -strokeSize))
let finalPath = normalSizedStarPath
.subtracting(
largerStarPath.offsetBy(dx: starOffset, dy: 0)
)
.union(
normalSizedStarPath.offsetBy(dx: starOffset, dy: 0)
)
return finalPath
}
}
/// A fallback view for those who cannot run iOS 17
struct Fallback: View {
var size: CGFloat
var starOffset: CGFloat
var body: some View {
HStack {
StarShape()
.frame(width: size, height: size)
.foregroundStyle(GoldGradient)
StarShape()
.fill(GoldGradient)
.overlay(
StarShape()
.stroke(Color.damusAdaptableWhite, lineWidth: 1)
)
.frame(width: size + 1, height: size + 1)
.padding(.leading, -size - starOffset)
}
.padding(.trailing, -3)
}
}
}
func support_level_color(_ percent: Int) -> Color {
if percent == 0 {
return .gray
@@ -86,7 +193,7 @@ struct SupporterBadge_Previews: PreviewProvider {
HStack(alignment: .center) {
SupporterBadge(
percent: nil,
purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: subscriber_number, active: true),
purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: subscriber_number, active: true, attributes: []),
style: .full
)
.frame(width: 100)
@@ -118,4 +225,52 @@ struct SupporterBadge_Previews: PreviewProvider {
}
}
#Preview("1 yr badge") {
VStack {
HStack(alignment: .center) {
SupporterBadge(
percent: nil,
purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: 3, active: true, attributes: []),
style: .full
)
.frame(width: 100)
}
HStack(alignment: .center) {
SupporterBadge(
percent: nil,
purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: 3, active: true, attributes: [.memberForMoreThanOneYear]),
style: .full
)
.frame(width: 100)
}
Text("Double star (just shape itself, with alt background color, to show it adapts to background well)")
.multilineTextAlignment(.center)
if #available(iOS 17.0, *) {
HStack(alignment: .center) {
DoubleStar.DoubleStarShape(starOffset: 5)
.frame(width: 17, height: 17)
.padding(.trailing, -8)
}
.background(Color.blue)
}
Text("Double star (fallback for iOS 16 and below)")
HStack(alignment: .center) {
DoubleStar.Fallback(size: 17, starOffset: 5)
}
Text("Double star (fallback for iOS 16 and below, with alt color limitation shown)")
.multilineTextAlignment(.center)
HStack(alignment: .center) {
DoubleStar.Fallback(size: 17, starOffset: 5)
}
.background(Color.blue)
}
}

View File

@@ -31,7 +31,8 @@ enum Sheets: Identifiable {
case onboardingSuggestions
case purple(DamusPurpleURL)
case purple_onboarding
case error(ErrorView.UserPresentableError)
static func zap(target: ZapTarget, lnurl: String) -> Sheets {
return .zap(ZapSheet(target: target, lnurl: lnurl))
}
@@ -53,6 +54,7 @@ enum Sheets: Identifiable {
case .onboardingSuggestions: return "onboarding-suggestions"
case .purple(let purple_url): return "purple" + purple_url.url_string()
case .purple_onboarding: return "purple_onboarding"
case .error(_): return "error"
}
}
}
@@ -339,36 +341,14 @@ struct ContentView: View {
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
case .purple_onboarding:
DamusPurpleNewUserOnboardingView(damus_state: damus_state)
case .error(let error):
ErrorView(damus_state: damus_state!, error: error)
}
}
.onOpenURL { url in
on_open_url(state: damus_state!, url: url) { res in
guard let res else {
return
}
switch res {
case .filter(let filt): self.open_search(filt: filt)
case .profile(let pk): self.open_profile(pubkey: pk)
case .event(let ev): self.open_event(ev: ev)
case .wallet_connect(let nwc): self.open_wallet(nwc: nwc)
case .script(let data): self.open_script(data)
case .purple(let purple_url):
if case let .welcome(checkout_id) = purple_url.variant {
// If this is a welcome link, do the following before showing the onboarding screen:
// 1. Check if this is legitimate and good to go.
// 2. Mark as complete if this is good to go.
Task {
let is_good_to_go = try? await damus_state.purple.check_and_mark_ln_checkout_is_good_to_go(checkout_id: checkout_id)
if is_good_to_go == true {
self.active_sheet = .purple(purple_url)
}
}
}
else {
self.active_sheet = .purple(purple_url)
}
}
Task {
let open_action = await DamusURLHandler.handle_opening_url_and_compute_view_action(damus_state: self.damus_state, url: url)
self.execute_open_action(open_action)
}
}
.onReceive(handle_notify(.compose)) { action in
@@ -608,7 +588,7 @@ struct ContentView: View {
}, message: {
Text("No mute list found, create a new one? This will overwrite any previous mute lists.", comment: "Alert message prompt that asks if the user wants to create a new mute list, overwriting previous mute lists.")
})
.alert(NSLocalizedString("Mute User", comment: "Title of alert for muting a user."), isPresented: $confirm_mute, actions: {
.alert(NSLocalizedString("Mute/Block User", comment: "Title of alert for muting/blocking a user."), isPresented: $confirm_mute, actions: {
Button(NSLocalizedString("Cancel", comment: "Alert button to cancel out of alert for muting a user."), role: .cancel) {
confirm_mute = false
}
@@ -783,6 +763,39 @@ struct ContentView: View {
break
}
}
/// An open action within the app
/// This is used to model, store, and communicate a desired view action to be taken as a result of opening an object,
/// for example a URL
///
/// ## Implementation notes
///
/// - The reason this was created was to separate URL parsing logic, the underlying actions that mutate the state of the app, and the action to be taken on the view layer as a result. This makes it easier to test, to read the URL handling code, and to add new functionality in between the two (e.g. a confirmation screen before proceeding with a given open action)
enum ViewOpenAction {
/// Open a page route
case route(Route)
/// Open a sheet
case sheet(Sheets)
/// Do nothing.
///
/// ## Implementation notes
/// - This is used here instead of Optional values to make semantics explicit and force better programming intent, instead of accidentally doing nothing because of Swift's syntax sugar.
case no_action
}
/// Executes an action to open something in the app view
///
/// - Parameter open_action: The action to perform
func execute_open_action(_ open_action: ViewOpenAction) {
switch open_action {
case .route(let route):
navigationCoordinator.push(route: route)
case .sheet(let sheet):
self.active_sheet = sheet
case .no_action:
return
}
}
}
struct TopbarSideMenuButton: View {
@@ -934,10 +947,38 @@ enum FoundEvent {
case event(NostrEvent)
}
/// Finds an event from NostrDB if it exists, or from the network
///
/// This is the callback version. There is also an asyc/await version of this function.
///
/// - Parameters:
/// - state: Damus state
/// - query_: The query, including the event being looked for, and the relays to use when looking
/// - callback: The function to call with results
func find_event(state: DamusState, query query_: FindEvent, callback: @escaping (FoundEvent?) -> ()) {
return find_event_with_subid(state: state, query: query_, subid: UUID().description, callback: callback)
}
/// Finds an event from NostrDB if it exists, or from the network
///
/// This is a the async/await version of `find_event`. Use this when using callbacks is impossible or cumbersome.
///
/// - Parameters:
/// - state: Damus state
/// - query_: The query, including the event being looked for, and the relays to use when looking
/// - callback: The function to call with results
func find_event(state: DamusState, query query_: FindEvent) async -> FoundEvent? {
await withCheckedContinuation { continuation in
find_event(state: state, query: query_) { event in
var already_resumed = false
if !already_resumed { // Ensure we do not resume twice, as it causes a crash
continuation.resume(returning: event)
already_resumed = true
}
}
}
}
func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: String, callback: @escaping (FoundEvent?) -> ()) {
var filter: NostrFilter? = nil
@@ -1008,6 +1049,15 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
}
}
/// Finds a replaceable event based on an `naddr` address.
///
/// This is the callback version of the function. There is another function that makes use of async/await
///
/// - Parameters:
/// - damus_state: The Damus state
/// - naddr: the `naddr` address
/// - callback: A function to handle the found event
func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (NostrEvent?) -> ()) {
var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
@@ -1036,6 +1086,26 @@ func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (Nos
}
}
/// Finds a replaceable event based on an `naddr` address.
///
/// This is the async/await version of the function. Another version of this function which makes use of callback functions also exists .
///
/// - Parameters:
/// - damus_state: The Damus state
/// - naddr: the `naddr` address
/// - callback: A function to handle the found event
func naddrLookup(damus_state: DamusState, naddr: NAddr) async -> NostrEvent? {
await withCheckedContinuation { continuation in
var already_resumed = false
naddrLookup(damus_state: damus_state, naddr: naddr) { event in
if !already_resumed { // Ensure we do not resume twice, as it causes a crash
continuation.resume(returning: event)
already_resumed = true
}
}
}
}
func timeline_name(_ timeline: Timeline?) -> String {
guard let timeline else {
return ""
@@ -1147,63 +1217,6 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
}
enum OpenResult {
case profile(Pubkey)
case filter(NostrFilter)
case event(NostrEvent)
case wallet_connect(WalletConnectURL)
case script([UInt8])
case purple(DamusPurpleURL)
}
func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) -> Void) {
if let purple_url = DamusPurpleURL(url: url) {
result(.purple(purple_url))
return
}
if let nwc = WalletConnectURL(str: url.absoluteString) {
result(.wallet_connect(nwc))
return
}
guard let link = decode_nostr_uri(url.absoluteString) else {
result(nil)
return
}
switch link {
case .ref(let ref):
switch ref {
case .pubkey(let pk):
result(.profile(pk))
case .event(let noteid):
find_event(state: state, query: .event(evid: noteid)) { res in
guard let res, case .event(let ev) = res else { return }
result(.event(ev))
}
case .hashtag(let ht):
result(.filter(.filter_hashtag([ht.hashtag])))
case .param, .quote, .reference:
// doesn't really make sense here
break
case .naddr(let naddr):
naddrLookup(damus_state: state, naddr: naddr) { res in
guard let res = res else { return }
result(.event(res))
}
}
case .filter(let filt):
result(.filter(filt))
break
// TODO: handle filter searches?
case .script(let script):
result(.script(script))
break
}
}
func logout(_ state: DamusState?)
{
state?.close()

View File

@@ -73,6 +73,6 @@
<key>NSAppleMusicUsageDescription</key>
<string>Damus needs access to your media library for playback statuses</string>
<key>NSMicrophoneUsageDescription</key>
<string>Damus needs access to your microphone for creating video recording posts</string>
<string>Damus needs access to your microphone to allow you to create video recordings that you can choose to post publicly on the network</string>
</dict>
</plist>

View File

@@ -175,6 +175,9 @@ class DamusState: HeadlessDamusState {
func close() {
print("txn: damus close")
Task {
try await self.push_notification_client.revoke_token()
}
wallet.disconnect()
pool.close()
ndb.close()

View File

@@ -6,14 +6,45 @@
//
import Foundation
import SwiftUICore
import UIKit
/// Represents artifacts in a post draft, which is rendered by `PostView`
///
/// ## Implementation notes
///
/// - This is NOT `Codable` because we store these persistently as NIP-37 drafts in NostrDB, instead of directly encoding the object.
/// - `NSMutableAttributedString` is the bottleneck for making this `Codable`, and replacing that with another type requires a very large refactor.
/// - Encoding/decoding logic is lossy, and is not fully round-trippable. This class does a best effort attempt at encoding and recovering as much information as possible, but the information is dispersed into many different places, types, and functions around the code, making round-trip guarantees very difficult without severely refactoring `PostView`, `TextViewWrapper`, and other associated classes, unfortunately. These are the known limitations at the moment:
/// - Image metadata is lost on decoding
/// - The `filtered_pubkeys` filter effectively gets applied upon encoding, causing them to change upon decoding
///
class DraftArtifacts: Equatable {
/// The text content of the note draft
///
/// ## Implementation notes
///
/// - This serves as the backing model for `PostView` and `TextViewWrapper`. It might be cleaner to use a specialized data model for this in the future and render to attributed string in real time, but that will require a big refactor. See https://github.com/damus-io/damus/issues/1862#issuecomment-2585756932
var content: NSMutableAttributedString
/// A list of media items that have been attached to the note draft.
var media: [UploadedMedia]
/// The references for this note, which will be translated into tags once the event is published.
var references: [RefId]
/// Pubkeys that should be filtered out from the references
///
/// For example, when replying to an event, the user can select which pubkey mentions they want to keep, and which ones to remove.
var filtered_pubkeys: Set<Pubkey> = []
init(content: NSMutableAttributedString = NSMutableAttributedString(string: ""), media: [UploadedMedia] = []) {
/// A unique ID for this draft that allows us to address these if we need to.
///
/// This will be the unique identifier in the NIP-37 note
let id: String
init(content: NSMutableAttributedString = NSMutableAttributedString(string: ""), media: [UploadedMedia] = [], references: [RefId], id: String) {
self.content = content
self.media = media
self.references = references
self.id = id
}
static func == (lhs: DraftArtifacts, rhs: DraftArtifacts) -> Bool {
@@ -22,11 +53,217 @@ class DraftArtifacts: Equatable {
lhs.content.string == rhs.content.string // Comparing the text content is not perfect but acceptable in this case because attributes for our post editor are determined purely from text content
)
}
// MARK: Encoding and decoding functions to and from NIP-37 nostr events
/// Converts the draft artifacts into a NIP-37 draft event that can be saved into NostrDB or any Nostr relay
///
/// - Parameters:
/// - action: The post action for this draft, which provides necessary context for the draft (e.g. Is it meant to highlight something? Reply to something?)
/// - damus_state: The damus state, needed for encrypting, fetching Nostr data depedencies, and forming the NIP-37 draft
/// - references: references in the post?
/// - Returns: The NIP-37 draft packaged in a way that can be easily wrapped/unwrapped.
func to_nip37_draft(action: PostAction, damus_state: DamusState) throws -> NIP37Draft? {
guard let keypair = damus_state.keypair.to_full() else { return nil }
let post = build_post(state: damus_state, action: action, draft: self)
guard let note = post.to_event(keypair: keypair) else { return nil }
return try NIP37Draft(unwrapped_note: note, draft_id: self.id, keypair: keypair)
}
/// Instantiates a draft object from a NIP-37 draft
/// - Parameters:
/// - nip37_draft: The NIP-37 draft object
/// - damus_state: Damus state of the user who wants to load this draft object. Needed for pulling profiles from Ndb, and decrypting contents.
/// - Returns: A draft artifacts object, or `nil` if such cannot be loaded.
static func from(nip37_draft: NIP37Draft, damus_state: DamusState) -> DraftArtifacts? {
return Self.from(
event: nip37_draft.unwrapped_note,
draft_id: nip37_draft.id ?? UUID().uuidString, // Generate random UUID as the draft ID if none is specified. It is always better to have an ID that we can use for addressing later.
damus_state: damus_state
)
}
/// Load a draft artifacts object from a plain, unwrapped NostrEvent
///
/// This function will parse the contents of a Nostr Event and turn it into an editable draft that we can use.
///
/// - Parameters:
/// - event: The Nostr event to use as a template
/// - draft_id: The unique ID of this draft, used for keeping draft identities stable. UUIDs are recommended but not required.
/// - damus_state: The user's Damus state, used for fetching profiles in NostrDB
/// - Returns: The draft that can be loaded into `PostView`.
static func from(event: NostrEvent, draft_id: String, damus_state: DamusState) -> DraftArtifacts {
let parsed_blocks = parse_note_content(content: .init(note: event, keypair: damus_state.keypair))
return Self.from(parsed_blocks: parsed_blocks, references: Array(event.references), draft_id: draft_id, damus_state: damus_state)
}
/// Load a draft artifacts object from parsed Nostr event blocks
///
/// - Parameters:
/// - parsed_blocks: The blocks parsed from a Nostr event
/// - references: The references in the Nostr event
/// - draft_id: The unique ID of the draft as per NIP-37
/// - damus_state: Damus state, used for fetching profile info in NostrDB
/// - Returns: The draft that can be loaded into `PostView`.
static func from(parsed_blocks: Blocks, references: [RefId], draft_id: String, damus_state: DamusState) -> DraftArtifacts {
let rich_text_content: NSMutableAttributedString = .init(string: "")
var media: [UploadedMedia] = []
for block in parsed_blocks.blocks {
switch block {
case .mention(let mention):
if case .pubkey(let pubkey) = mention.ref {
// A profile reference, format things properly.
let profile = damus_state.ndb.lookup_profile(pubkey)?.unsafeUnownedValue?.profile
let profile_name = parse_display_name(profile: profile, pubkey: pubkey).username
guard let url_address = URL(string: block.asString) else {
rich_text_content.append(.init(string: block.asString))
continue
}
let attributed_string = NSMutableAttributedString(
string: "@\(profile_name)",
attributes: [
.link: url_address,
.foregroundColor: UIColor(Color.accentColor)
]
)
rich_text_content.append(attributed_string)
}
else if case .note(_) = mention.ref {
// These note references occur when we quote a note, and since that is tracked via `PostAction` in `PostView`, ignore it here to avoid attaching the same event twice in a note
continue
}
else {
// Other references
rich_text_content.append(.init(string: block.asString))
}
case .url(let url):
if isSupportedImage(url: url) {
// Image, add that to our media attachments
// TODO: Add metadata decoding support
media.append(UploadedMedia(localURL: url, uploadedURL: url, metadata: .none))
continue
}
else {
// Normal URL, plain text
rich_text_content.append(.init(string: block.asString))
}
case .invoice(_), .relay(_), .hashtag(_), .text(_):
// Everything else is currently plain text.
rich_text_content.append(.init(string: block.asString))
}
}
return DraftArtifacts(content: rich_text_content, media: media, references: references, id: draft_id)
}
}
/// Holds and keeps track of the note post drafts throughout the app.
class Drafts: ObservableObject {
@Published var post: DraftArtifacts? = nil
@Published var replies: [NostrEvent: DraftArtifacts] = [:]
@Published var quotes: [NostrEvent: DraftArtifacts] = [:]
@Published var highlights: [HighlightSource: DraftArtifacts] = [:]
@Published var replies: [NoteId: DraftArtifacts] = [:]
@Published var quotes: [NoteId: DraftArtifacts] = [:]
/// The drafts we have for highlights
///
/// ## Implementation notes
/// - Although in practice we also load drafts based on the highlight source for better UX (making it easier to find a draft), we need the keys to be of type `HighlightContentDraft` because we need the selected text information to be able to construct the NIP-37 draft, as well as to load that into post view.
@Published var highlights: [HighlightContentDraft: DraftArtifacts] = [:]
/// Loads drafts from storage (NostrDB + UserDefaults)
func load(from damus_state: DamusState) {
guard let note_ids = damus_state.settings.draft_event_ids?.compactMap({ NoteId(hex: $0) }) else { return }
for note_id in note_ids {
let txn = damus_state.ndb.lookup_note(note_id)
guard let note = txn?.unsafeUnownedValue else { continue }
// Implementation note: This currently fails silently, because:
// 1. Errors are unlikely and not expected
// 2. It is not mission critical to recover from this error
// 3. The changes that add a error view sheet with useful info is not yet merged in as of writing.
try? self.load(wrapped_draft_note: note, with: damus_state)
}
}
/// Loads a specific NIP-37 note into this class
func load(wrapped_draft_note: NdbNote, with damus_state: DamusState) throws {
// Extract draft info from the NIP-37 note
guard let full_keypair = damus_state.keypair.to_full() else { return }
guard let nip37_draft = try NIP37Draft(wrapped_note: wrapped_draft_note, keypair: full_keypair) else { return }
guard let known_kind = nip37_draft.unwrapped_note.known_kind else { return }
guard let draft_artifacts = DraftArtifacts.from(
nip37_draft: nip37_draft,
damus_state: damus_state
) else { return }
// Find out where to place these drafts
let blocks = parse_note_content(content: .note(nip37_draft.unwrapped_note))
switch known_kind {
case .text:
if let replied_to_note_id = nip37_draft.unwrapped_note.direct_replies() {
self.replies[replied_to_note_id] = draft_artifacts
}
else {
for block in blocks.blocks {
if case .mention(let mention) = block {
if case .note(let note_id) = mention.ref {
self.quotes[note_id] = draft_artifacts
return
}
}
}
self.post = draft_artifacts
}
case .highlight:
guard let highlight = HighlightContentDraft(from: nip37_draft.unwrapped_note) else { return }
self.highlights[highlight] = draft_artifacts
default:
return
}
}
/// Saves the drafts tracked by this class persistently using NostrDB + UserDefaults
func save(damus_state: DamusState) {
var draft_events: [NdbNote] = []
post_artifact_block: if let post_artifacts = self.post {
let nip37_draft = try? post_artifacts.to_nip37_draft(action: .posting(.user(damus_state.pubkey)), damus_state: damus_state)
guard let wrapped_note = nip37_draft?.wrapped_note else { break post_artifact_block }
draft_events.append(wrapped_note)
}
for (replied_to_note_id, reply_artifacts) in self.replies {
guard let replied_to_note = damus_state.ndb.lookup_note(replied_to_note_id)?.unsafeUnownedValue?.to_owned() else { continue }
let nip37_draft = try? reply_artifacts.to_nip37_draft(action: .replying_to(replied_to_note), damus_state: damus_state)
guard let wrapped_note = nip37_draft?.wrapped_note else { continue }
draft_events.append(wrapped_note)
}
for (quoted_note_id, quote_note_artifacts) in self.quotes {
guard let quoted_note = damus_state.ndb.lookup_note(quoted_note_id)?.unsafeUnownedValue?.to_owned() else { continue }
let nip37_draft = try? quote_note_artifacts.to_nip37_draft(action: .quoting(quoted_note), damus_state: damus_state)
guard let wrapped_note = nip37_draft?.wrapped_note else { continue }
draft_events.append(wrapped_note)
}
for (highlight, highlight_note_artifacts) in self.highlights {
let nip37_draft = try? highlight_note_artifacts.to_nip37_draft(action: .highlighting(highlight), damus_state: damus_state)
guard let wrapped_note = nip37_draft?.wrapped_note else { continue }
draft_events.append(wrapped_note)
}
for draft_event in draft_events {
// Implementation note: We do not support draft synchronization with relays yet.
// TODO: Once it is time to implement draft syncing with relays, please consider the following:
// - Privacy: Sending drafts to the network leaks metadata about app activity, and may break user expectations
// - Down-sync conflict resolution: Consider how to solve conflicts for different draft versions holding the same ID (e.g. edited in Damus, then another client, then Damus again)
damus_state.pool.send_raw_to_local_ndb(.typical(.event(draft_event)))
}
damus_state.settings.draft_event_ids = draft_events.map({ $0.id.hex() })
}
}
// MARK: - Convenience extensions
fileprivate extension Array {
mutating func appendIfNotNil(_ element: Element?) {
if let element = element {
self.append(element)
}
}
}

View File

@@ -188,17 +188,29 @@ extension HighlightEvent {
struct HighlightContentDraft: Hashable {
let selected_text: String
let source: HighlightSource
init(selected_text: String, source: HighlightSource) {
self.selected_text = selected_text
self.source = source
}
init?(from note: NdbNote) {
guard let source = HighlightSource.from(tags: note.tags.strings()) else { return nil }
self.source = source
self.selected_text = note.content
}
}
enum HighlightSource: Hashable {
static let TAG_SOURCE_ELEMENT = "source"
case event(NostrEvent)
case event(NoteId)
case external_url(URL)
func tags() -> [[String]] {
switch self {
case .event(let event):
return [ ["e", "\(event.id)", HighlightSource.TAG_SOURCE_ELEMENT] ]
case .event(let event_id):
return [ ["e", "\(event_id)", HighlightSource.TAG_SOURCE_ELEMENT] ]
case .external_url(let url):
return [ ["r", "\(url)", HighlightSource.TAG_SOURCE_ELEMENT] ]
}
@@ -206,12 +218,26 @@ enum HighlightSource: Hashable {
func ref() -> RefId {
switch self {
case .event(let event):
return .event(event.id)
case .event(let event_id):
return .event(event_id)
case .external_url(let url):
return .reference(url.absoluteString)
}
}
static func from(tags: [[String]]) -> HighlightSource? {
for tag in tags {
if tag.count == 3 && tag[0] == "e" && tag[2] == HighlightSource.TAG_SOURCE_ELEMENT {
guard let event_id = NoteId(hex: tag[1]) else { continue }
return .event(event_id)
}
if tag.count == 3 && tag[0] == "r" && tag[2] == HighlightSource.TAG_SOURCE_ELEMENT {
guard let url = URL(string: tag[1]) else { continue }
return .external_url(url)
}
}
return nil
}
}
struct ShareContent {

View File

@@ -122,6 +122,7 @@ class HomeModel: ContactsDelegate {
/// This is called whenever DamusState gets set. This function is used to load or setup anything we need from the new DamusState
func load_our_stuff_from_damus_state() {
self.load_latest_contact_event_from_damus_state()
self.load_drafts_from_damus_state()
}
/// This loads the latest contact event we have on file from NostrDB. This should be called as soon as we get the new DamusState
@@ -134,6 +135,10 @@ class HomeModel: ContactsDelegate {
process_contact_event(state: damus_state, ev: latest_contact_event)
}
func load_drafts_from_damus_state() {
damus_state.drafts.load(from: damus_state)
}
// MARK: - ContactsDelegate functions
func latest_contact_event_changed(new_event: NostrEvent) {
@@ -215,6 +220,10 @@ class HomeModel: ContactsDelegate {
break
case .status:
handle_status_event(ev)
case .draft:
// TODO: Implement draft syncing with relays. We intentionally do not support that as of writing. See `DraftsModel.swift` for other details
// try? damus_state.drafts.load(wrapped_draft_note: ev, with: damus_state)
break
}
}

View File

@@ -2,7 +2,7 @@
// LongformEvent.swift
// damus
//
// Created by Daniel Nogueira on 2023-11-24.
// Created by Daniel D'Aquino on 2023-11-24.
//
import Foundation

View File

@@ -122,6 +122,10 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
}
}
protocol URLEncodable {
func url() -> URL?
}
struct Mention<T: Equatable>: Equatable {
let index: Int?
let ref: T

View File

@@ -363,6 +363,42 @@ class DamusPurple: StoreObserverDelegate {
return freshly_completed_checkouts
}
/// Handles a Purple URL
/// - Parameter purple_url: The Purple URL being opened
/// - Returns: A view to be shown in the UI
@MainActor
func handle(purple_url: DamusPurpleURL) async -> ContentView.ViewOpenAction {
if case let .welcome(checkout_id) = purple_url.variant {
// If this is a welcome link, do the following before showing the onboarding screen:
// 1. Check if this is legitimate and good to go.
// 2. Mark as complete if this is good to go.
let is_good_to_go = try? await check_and_mark_ln_checkout_is_good_to_go(checkout_id: checkout_id)
switch is_good_to_go {
case .some(let is_good_to_go):
if is_good_to_go {
return .sheet(.purple(purple_url)) // ALL GOOD, SHOW WELCOME SHEET
}
else {
return .sheet(.error(.init(
user_visible_description: NSLocalizedString("You clicked on a Purple welcome link, but it does not look like the checkout is completed yet. This is likely a bug.", comment: "Error label upon continuing in the app from a Damus Purple purchase"),
tip: NSLocalizedString("Please double-check the checkout web page, or go to the Side Menu → \"Purple\" to check your account status. If you have already paid, but still don't see your account active, please save the URL of the checkout page where you came from, contact our support, and give us the URL to help you with this issue.", comment: "User-facing tips on what to do if a Purple welcome link doesn't work"),
technical_info: "Handling Purple URL \"\(purple_url)\" failed, the `is_good_to_go` result was `\(is_good_to_go)`"
)))
}
case .none:
return .sheet(.error(.init(
user_visible_description: NSLocalizedString("You clicked on a Purple welcome link, but we could not find your checkout. This is likely a bug.", comment: "Error label upon continuing in the app from a Damus Purple purchase"),
tip: NSLocalizedString("Please double-check the checkout web page, or go to the Side Menu → \"Purple\" to check your account status. If you have already paid, but still don't see your account active, please save the URL of the checkout page where you came from, contact our support, and give us the URL to help you with this issue", comment: "User-facing tips on what to do if a Purple welcome link doesn't work"),
technical_info: "Handling Purple URL \"\(purple_url)\" failed, the `is_good_to_go` result was `\(String(describing: is_good_to_go))`"
)))
}
}
else {
// Show the purple url contents
return .sheet(.purple(purple_url))
}
}
@MainActor
/// This function checks the status of a specific checkout id with the server
/// You should use this result immediately, since it will internally be marked as handled
@@ -382,6 +418,13 @@ class DamusPurple: StoreObserverDelegate {
let expiry: Date
let subscriber_number: Int
let active: Bool
let attributes: PurpleAccountAttributes
struct PurpleAccountAttributes: OptionSet {
let rawValue: Int
static let memberForMoreThanOneYear = PurpleAccountAttributes(rawValue: 1 << 0)
}
func ordinal() -> String? {
let number = Int(self.subscriber_number)
@@ -402,7 +445,8 @@ class DamusPurple: StoreObserverDelegate {
created_at: Date.init(timeIntervalSince1970: TimeInterval(payload.created_at)),
expiry: Date.init(timeIntervalSince1970: TimeInterval(payload.expiry)),
subscriber_number: Int(payload.subscriber_number),
active: payload.active
active: payload.active,
attributes: (payload.attributes?.member_for_more_than_one_year ?? false) ? [.memberForMoreThanOneYear] : []
)
}
@@ -412,6 +456,11 @@ class DamusPurple: StoreObserverDelegate {
let expiry: UInt64 // Unix timestamp
let subscriber_number: UInt
let active: Bool
let attributes: Attributes?
struct Attributes: Codable {
let member_for_more_than_one_year: Bool
}
}
}
}

View File

@@ -2,7 +2,7 @@
// DamusPurpleURL.swift
// damus
//
// Created by Daniel Nogueira on 2024-01-13.
// Created by Daniel D'Aquino on 2024-01-13.
//
import Foundation

View File

@@ -7,42 +7,75 @@
import Foundation
/// manages the lifetime of a thread
/// manages the lifetime of a thread in a thread view such as `ChatroomThreadView`
/// Makes a subscription to the relay pool to get events related to the thread
/// It also keeps track of a selected event in the thread, and can pinpoint all of its parents and reply chain
@MainActor
class ThreadModel: ObservableObject {
@Published var event: NostrEvent
/// The original event where this thread was loaded from
/// We use this to know the starting point from which we try to load the rest of the thread
/// This is immutable because this is our starting point of the thread, and we don't expect this to ever change during the lifetime of a thread view
let original_event: NostrEvent
let highlight: String?
var event_map: Set<NostrEvent>
/// A map of events, the reply chain, etc
/// This can be read by the view, but it can only be updated internally, because it is this classes' responsibility to ensure we load the proper events
@Published private(set) var event_map: ThreadEventMap
/// The currently selected event
/// Can only be directly changed internally. Views should set this via the `select` methods
@Published private(set) var selected_event: NostrEvent
init(event: NostrEvent, damus_state: DamusState, highlight: String? = nil) {
self.damus_state = damus_state
self.event_map = Set()
self.event = event
self.original_event = event
self.highlight = highlight
add_event(event, keypair: damus_state.keypair)
/// All of the parent events of `selected_event` in the thread, sorted from the highest level in the thread (The root of the thread), down to the direct parent
///
/// ## Implementation notes
///
/// This is a computed property because we then don't need to worry about keeping things in sync
var parent_events: [NostrEvent] {
return event_map.parent_events(of: selected_event)
}
func events() -> [NostrEvent] {
return Array(event_map).sorted(by: { a, b in
return a.created_at < b.created_at
/// All of the direct and indirect replies of `selected_event` in the thread. sorted chronologically
///
/// ## Implementation notes
///
/// This is a computed property because we then don't need to worry about keeping things in sync
var sorted_child_events: [NostrEvent] {
event_map.sorted_recursive_child_events(of: selected_event).filter({
should_show_event(event: $0, damus_state: damus_state) // Hide muted events from chatroom conversation
})
}
var is_original: Bool {
return original_event.id == event.id
}
/// The damus state, needed to access the relay pool and load the thread events
let damus_state: DamusState
let profiles_subid = UUID().description
let base_subid = UUID().description
let meta_subid = UUID().description
var subids: [String] {
private let profiles_subid = UUID().description
private let base_subid = UUID().description
private let meta_subid = UUID().description
private var subids: [String] {
return [profiles_subid, base_subid, meta_subid]
}
// MARK: Initialization
/// Initialize this model
///
/// You should also call `subscribe()` to start loading thread events from the relay pool.
/// This is done manually to ensure we only load stuff when needed (e.g. when a view appears)
init(event: NostrEvent, damus_state: DamusState) {
self.damus_state = damus_state
self.event_map = ThreadEventMap()
self.original_event = event
self.selected_event = event
add_event(event, keypair: damus_state.keypair)
}
/// All events in the thread, sorted in chronological order
var events: [NostrEvent] {
return event_map.sorted_events
}
// MARK: Relay pool subscription management
/// Unsubscribe from events in the relay pool. Call this when unloading the view
func unsubscribe() {
self.damus_state.pool.remove_handler(sub_id: base_subid)
self.damus_state.pool.remove_handler(sub_id: meta_subid)
@@ -50,33 +83,25 @@ class ThreadModel: ObservableObject {
self.damus_state.pool.unsubscribe(sub_id: base_subid)
self.damus_state.pool.unsubscribe(sub_id: meta_subid)
self.damus_state.pool.unsubscribe(sub_id: profiles_subid)
print("unsubscribing from thread \(event.id) with sub_id \(base_subid)")
}
@discardableResult
func set_active_event(_ ev: NostrEvent, keypair: Keypair) -> Bool {
self.event = ev
add_event(ev, keypair: keypair)
//self.objectWillChange.send()
return false
Log.info("unsubscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
}
/// Subscribe to events in this thread. Call this when loading the view.
func subscribe() {
var meta_events = NostrFilter()
var quote_events = NostrFilter()
var event_filter = NostrFilter()
var ref_events = NostrFilter()
let thread_id = event.thread_id()
let thread_id = original_event.thread_id()
ref_events.referenced_ids = [thread_id, event.id]
ref_events.referenced_ids = [thread_id, original_event.id]
ref_events.kinds = [.text]
ref_events.limit = 1000
event_filter.ids = [thread_id, event.id]
event_filter.ids = [thread_id, original_event.id]
meta_events.referenced_ids = [event.id]
meta_events.referenced_ids = [original_event.id]
var kinds: [NostrKind] = [.zap, .text, .boost]
if !damus_state.settings.onlyzaps_mode {
@@ -86,33 +111,40 @@ class ThreadModel: ObservableObject {
meta_events.limit = 1000
quote_events.kinds = [.text]
quote_events.quotes = [event.id]
quote_events.quotes = [original_event.id]
quote_events.limit = 1000
let base_filters = [event_filter, ref_events]
let meta_filters = [meta_events, quote_events]
print("subscribing to thread \(event.id) with sub_id \(base_subid)")
Log.info("subscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
damus_state.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event)
damus_state.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event)
}
/// Adds an event to this thread.
/// Normally this does not need to be called externally because it is the responsibility of this class to load the events, not the view's.
/// However, this can be called externally for testing purposes (e.g. injecting events for testing)
func add_event(_ ev: NostrEvent, keypair: Keypair) {
if event_map.contains(ev) {
if event_map.contains(id: ev.id) {
return
}
damus_state.events.upsert(ev)
_ = damus_state.events.upsert(ev)
damus_state.replies.count_replies(ev, keypair: keypair)
damus_state.events.add_replies(ev: ev, keypair: keypair)
event_map.insert(ev)
event_map.add(event: ev)
// Publish changes
objectWillChange.send()
}
/// Handles an incoming event from a relay pool
///
/// Marked as private because it is this class' responsibility to load events, not the view's. Simplify the interface
@MainActor
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
let (sub_id, done) = handle_subid_event(pool: damus_state.pool, relay_id: relay_id, ev: ev) { sid, ev in
guard subids.contains(sid) else {
return
@@ -125,7 +157,7 @@ class ThreadModel: ObservableObject {
} else if ev.is_textlike {
// handle thread quote reposts, we just count them instead of
// adding them to the thread
if let target = ev.is_quote_repost, target == self.event.id {
if let target = ev.is_quote_repost, target == self.selected_event.id {
//let _ = self.damus_state.quote_reposts.add_event(ev, target: target)
} else {
self.add_event(ev, keypair: damus_state.keypair)
@@ -139,10 +171,167 @@ class ThreadModel: ObservableObject {
if sub_id == self.base_subid {
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
load_profiles(context: "thread", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(Array(event_map)), damus_state: damus_state, txn: txn)
load_profiles(context: "thread", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(Array(event_map.events)), damus_state: damus_state, txn: txn)
}
}
// MARK: External control interface
// Control methods created for the thread view
/// Change the currently selected event
///
/// - Parameter event: Event to select
func select(event: NostrEvent) {
self.selected_event = event
add_event(event, keypair: damus_state.keypair)
}
}
/// A thread event map, a model that holds events, indexes them, and can efficiently answer questions about a thread.
///
/// Add events that are part of a thread to this model, and use one of its many convenience functions to get answers about the hierarchy of the thread.
///
/// This does NOT perform any event loading, networking, or storage operations. This is simply a convenient/efficient way to keep and query about a thread
struct ThreadEventMap {
/// A map for keeping nostr events, and efficiently querying them by note id
///
/// Marked as `private` because:
/// - We want to hide this complexity from the user of this struct
/// - It is this struct's responsibility to keep this in sync with `event_reply_index`
private var event_map: [NoteId: NostrEvent] = [:]
/// An index of the reply hierarchy, which allows replies to be found in O(1) efficiency
///
/// ## Implementation notes
///
/// Marked as `private` because:
/// - We want to hide this complexity from the user of this struct
/// - It is this struct's responsibility to keep this in sync with `event_map`
///
/// We only store note ids to save space, as we can easily get them from `event_map`
private var event_reply_index: [NoteId: Set<NoteId>] = [:]
// MARK: External interface
/// Events in the thread, in no particular order
/// Use this when the order does not matter
var events: Set<NostrEvent> {
return Set(event_map.values)
}
/// Events in the thread, sorted chronologically. Use this when the order matters.
/// Use `.events` when the order doesn't matter, as it is more computationally efficient.
var sorted_events: [NostrEvent] {
return events.sorted(by: { a, b in
return a.created_at < b.created_at
})
}
/// Add an event to this map
///
/// Efficiency: O(1)
///
/// - Parameter event: The event to be added
mutating func add(event: NostrEvent) {
self.event_map[event.id] = event
// Update our efficient reply index
if let note_id_replied_to = event.direct_replies() {
if event_reply_index[note_id_replied_to] == nil {
event_reply_index[note_id_replied_to] = [event.id]
}
else {
event_reply_index[note_id_replied_to]?.insert(event.id)
}
}
}
/// Whether the thread map contains a given note, referenced by ID
///
/// Efficiency: O(1)
///
/// - Parameter id: The ID to look for
/// - Returns: True if it does, false otherwise
func contains(id: NoteId) -> Bool {
return self.event_map[id] != nil
}
/// Gets a note from the thread by its id
///
/// Efficiency: O(1)
///
/// - Parameter id: The note id
/// - Returns: The note, if it exists in the thread map.
func get(id: NoteId) -> NostrEvent? {
return self.event_map[id]
}
/// Returns all the parent events in a thread, relative to a given event
///
/// Efficiency: O(N) in the worst case
///
/// - Parameter query_event: The event for which to find the parents for
/// - Returns: An array of parent events, sorted from the highest level in the thread (The root of the thread), down to the direct parent of the query event. If query event is not found, this will return an empty array
func parent_events(of query_event: NostrEvent) -> [NostrEvent] {
var parents: [NostrEvent] = []
var event = query_event
while true {
guard let direct_reply = event.direct_replies(),
let parent_event = self.get(id: direct_reply), parent_event != event
else {
break
}
parents.append(parent_event)
event = parent_event
}
return parents.reversed()
}
/// All of the replies in a thread for a given event, including indirect replies (reply of a reply), sorted in chronological order
///
/// Efficiency: O(Nlog(N)) in the worst case scenario, coming from Swift's built-in sorting algorithm "Timsort"
///
/// - Parameter query_event: The event for which to find the children for
/// - Returns: All of the direct and indirect replies for an event, sorted in chronological order. If query event is not present, this will be an empty array.
func sorted_recursive_child_events(of query_event: NostrEvent) -> [NostrEvent] {
let all_recursive_child_events = self.recursive_child_events(of: query_event)
return all_recursive_child_events.sorted(by: { a, b in
return a.created_at < b.created_at
})
}
/// All of the replies in a thread for a given event, including indirect replies (reply of a reply), in any order
///
/// Use this when the order does not matter, as it is more efficient
///
/// Efficiency: O(N) in the worst case scenario.
///
/// - Parameter query_event: The event for which to find the children for
/// - Returns: All of the direct and indirect replies for an event, sorted in chronological order. If query event is not present, this will be an empty array.
func recursive_child_events(of query_event: NostrEvent) -> Set<NostrEvent> {
let immediate_children_ids = self.event_reply_index[query_event.id] ?? []
var immediate_children: Set<NostrEvent> = []
for immediate_child_id in immediate_children_ids {
guard let immediate_child = self.event_map[immediate_child_id] else {
// This is an internal inconsistency.
// Crash the app in debug mode to increase awareness, but let it go in production mode (not mission critical)
assertionFailure("Desync between `event_map` and `event_reply_index` should never happen in `ThreadEventMap`!")
continue
}
immediate_children.insert(immediate_child)
}
var indirect_children: Set<NdbNote> = []
for immediate_child in immediate_children {
let recursive_children = self.recursive_child_events(of: immediate_child)
indirect_children = indirect_children.union(recursive_children)
}
return immediate_children.union(indirect_children)
}
}

View File

@@ -0,0 +1,107 @@
//
// URLHandler.swift
// damus
//
// Created by Daniel DAquino on 2024-09-06.
//
import Foundation
/// Parses URLs into actions within the app.
///
/// ## Implementation notes
///
/// - This exists so that we can separate the logic of parsing the URL and the actual action within the app. That makes the code more readable, testable, and extensible
struct DamusURLHandler {
/// Parses a URL, handles any needed actions within damus state, and returns the view to be opened in the app
///
/// Side effects: May mutate `damus_state` in some circumstances
///
/// - Parameters:
/// - damus_state: The Damus state. May be mutated as part of this function
/// - url: The URL to be opened
/// - Returns: A view to be shown to the user
static func handle_opening_url_and_compute_view_action(damus_state: DamusState, url: URL) async -> ContentView.ViewOpenAction {
let parsed_url_info = parse_url(url: url)
switch parsed_url_info {
case .profile(let pubkey):
return .route(.ProfileByKey(pubkey: pubkey))
case .filter(let nostrFilter):
let search = SearchModel(state: damus_state, search: nostrFilter)
return .route(.Search(search: search))
case .event(let nostrEvent):
let thread = await ThreadModel(event: nostrEvent, damus_state: damus_state)
return .route(.Thread(thread: thread))
case .event_reference(let event_reference):
return .route(.ThreadFromReference(note_reference: event_reference))
case .wallet_connect(let walletConnectURL):
damus_state.wallet.new(walletConnectURL)
return .route(.Wallet(wallet: damus_state.wallet))
case .script(let data):
let model = ScriptModel(data: data, state: .not_loaded)
return .route(.Script(script: model))
case .purple(let purple_url):
return await damus_state.purple.handle(purple_url: purple_url)
case nil:
break
}
return .sheet(.error(ErrorView.UserPresentableError(
user_visible_description: NSLocalizedString("Could not parse the URL you are trying to open.", comment: "User visible error description"),
tip: NSLocalizedString("Please try again, check the URL for typos, or contact support for further help.", comment: "User visible error tips"),
technical_info: "Could not find a suitable open action. User tried to open this URL: \(url.absoluteString)"
)))
}
/// Parses a URL into a structured information object.
///
/// This function does not cause any mutations on the app, or any side-effects.
///
/// - Parameter url: The URL to be parsed
/// - Returns: Structured information about the contents inside the URL. Returns `nil` if URL is not compatible, invalid, or could not be parsed for some reason.
static func parse_url(url: URL) -> ParsedURLInfo? {
if let purple_url = DamusPurpleURL(url: url) {
return .purple(purple_url)
}
if let nwc = WalletConnectURL(str: url.absoluteString) {
return .wallet_connect(nwc)
}
guard let link = decode_nostr_uri(url.absoluteString) else {
return nil
}
switch link {
case .ref(let ref):
switch ref {
case .pubkey(let pk):
return .profile(pk)
case .event(let noteid):
return .event_reference(.note_id(noteid))
case .hashtag(let ht):
return .filter(.filter_hashtag([ht.hashtag]))
case .param, .quote, .reference:
// doesn't really make sense here
break
case .naddr(let naddr):
return .event_reference(.naddr(naddr))
}
case .filter(let filt):
return .filter(filt)
case .script(let script):
return .script(script)
}
return nil
}
enum ParsedURLInfo {
case profile(Pubkey)
case filter(NostrFilter)
case event(NostrEvent)
case event_reference(LoadableThreadModel.NoteReference)
case wallet_connect(WalletConnectURL)
case script([UInt8])
case purple(DamusPurpleURL)
}
}

View File

@@ -324,9 +324,13 @@ class UserSettingsStore: ObservableObject {
// MARK: Internal, hidden settings
// TODO: Get rid of this once we have NostrDB query capabilities integrated
@Setting(key: "latest_contact_event_id", default_value: nil)
var latest_contact_event_id_hex: String?
// TODO: Get rid of this once we have NostrDB query capabilities integrated
@Setting(key: "draft_event_ids", default_value: nil)
var draft_event_ids: [String]?
// MARK: Helper types

View File

@@ -0,0 +1,146 @@
//
// NIP37Draft.swift
// damus
//
// Created by Daniel DAquino on 2025-01-20.
//
import NostrSDK
import Foundation
/// This models a NIP-37 draft.
///
/// It is an immutable data structure that automatically makes both sides of a NIP-37 draft available: Its unwrapped form and wrapped form.
///
/// This is useful for keeping it or passing it around to other functions when both sides will be used, or it is not known which side of it will be used.
///
/// Just initialize it, and read its properties.
struct NIP37Draft {
// MARK: Properties
// Implementation note: Must be immutable to maintain integrity of the structure.
/// The wrapped version of the draft. That is, a NIP-37 note with draft contents encrypted.
let wrapped_note: NdbNote
/// The unwrapped version of the draft. That is, the actual note that was being drafted.
let unwrapped_note: NdbNote
/// The unique ID of the draft, as per NIP-37
var id: String? {
return self.wrapped_note.referenced_params.first?.param.string()
}
// MARK: Initialization
/// Basic initializer
///
/// ## Implementation notes
///
/// - Using this externally defeats the whole purpose of using this struct, so this is kept private.
private init(wrapped_note: NdbNote, unwrapped_note: NdbNote) {
self.wrapped_note = wrapped_note
self.unwrapped_note = unwrapped_note
}
/// Initializes object with a wrapped NIP-37 note, if the keys can decrypt it.
/// - Parameters:
/// - wrapped_note: NIP-37 note
/// - keypair: The keys to decrypt
init?(wrapped_note: NdbNote, keypair: FullKeypair) throws {
self.wrapped_note = wrapped_note
guard let unwrapped_note = try Self.unwrap(note: wrapped_note, keypair: keypair) else { return nil }
self.unwrapped_note = unwrapped_note
}
/// Initializes object with an event to be wrapped into a NIP-37 draft
/// - Parameters:
/// - unwrapped_note: a note to be wrapped
/// - draft_id: the unique ID of this draft, as per NIP-37
/// - keypair: the keys to use for encrypting
init?(unwrapped_note: NdbNote, draft_id: String, keypair: FullKeypair) throws {
self.unwrapped_note = unwrapped_note
guard let wrapped_note = try Self.wrap(note: unwrapped_note, draft_id: draft_id, keypair: keypair) else { return nil }
self.wrapped_note = wrapped_note
}
// MARK: Static functions
// Use these when you just need to wrap/unwrap once
/// A function that wraps a note into NIP-37 draft event
/// - Parameters:
/// - note: the note that needs to be wrapped
/// - draft_id: the unique ID of the draft, as per NIP-37
/// - keypair: the keys to use for encrypting
/// - Returns: A NIP-37 draft, if it succeeds.
static func wrap(note: NdbNote, draft_id: String, keypair: FullKeypair) throws -> NdbNote? {
let note_json_data = try JSONEncoder().encode(note)
guard let note_json_string = String(data: note_json_data, encoding: .utf8) else {
throw NIP37DraftEventError.encoding_error
}
guard let secret_key = SecretKey.from(privkey: keypair.privkey) else {
throw NIP37DraftEventError.invalid_keypair
}
guard let pubkey = PublicKey.from(pubkey: keypair.pubkey) else {
throw NIP37DraftEventError.invalid_keypair
}
guard let contents = try? nip44Encrypt(secretKey: secret_key, publicKey: pubkey, content: note_json_string, version: Nip44Version.v2) else {
return nil
}
var tags = [
["d", draft_id],
["k", String(note.kind)],
]
if let replied_to_note = note.direct_replies() {
tags.append(["e", replied_to_note.hex()])
}
guard let wrapped_event = NostrEvent(
content: contents,
keypair: keypair.to_keypair(),
kind: NostrKind.draft.rawValue,
tags: tags
) else { return nil }
return wrapped_event
}
/// A function that unwraps and decrypts a NIP-37 draft
/// - Parameters:
/// - note: NIP-37 note to be unwrapped
/// - keypair: The keys to use for decrypting
/// - Returns: The unwrapped note, if it can be decrypted/unwrapped.
static func unwrap(note: NdbNote, keypair: FullKeypair) throws -> NdbNote? {
let wrapped_note = note
guard wrapped_note.known_kind == .draft else { return nil }
guard let private_key = SecretKey.from(privkey: keypair.privkey) else {
throw NIP37DraftEventError.invalid_keypair
}
guard let pubkey = PublicKey.from(pubkey: keypair.pubkey) else {
throw NIP37DraftEventError.invalid_keypair
}
guard let draft_event_json = try? nip44Decrypt(
secretKey: private_key,
publicKey: pubkey,
payload: wrapped_note.content
) else { return nil }
return NdbNote.owned_from_json(json: draft_event_json)
}
enum NIP37DraftEventError: Error {
case invalid_keypair
case encoding_error
}
}
// MARK: - Convenience extensions
fileprivate extension PublicKey {
static func from(pubkey: Pubkey) -> PublicKey? {
return try? PublicKey.parse(publicKey: pubkey.hex())
}
}
fileprivate extension SecretKey {
static func from(privkey: Privkey) -> SecretKey? {
return try? SecretKey.parse(secretKey: privkey.hex())
}
}

View File

@@ -19,6 +19,7 @@ enum NostrKind: UInt32, Codable {
case chat = 42
case mute_list = 10000
case list_deprecated = 30000
case draft = 31234
case longform = 30023
case zap = 9735
case zap_request = 9734

View File

@@ -7,6 +7,10 @@
import Foundation
/// General app-wide constants
///
/// ## Implementation notes:
/// - Force unwrapping in this class is generally ok, because the contents are static, and so we can easily provide guarantees that they will not crash the app.
class Constants {
//static let EXAMPLE_DEMOS: DamusState = .empty
static let DAMUS_APP_GROUP_IDENTIFIER: String = "group.com.damus"
@@ -32,6 +36,9 @@ class Constants {
static let DAMUS_WEBSITE_STAGING_URL: URL = URL(string: "https://staging.damus.io")!
static let DAMUS_WEBSITE_PRODUCTION_URL: URL = URL(string: "https://damus.io")!
// MARK: Damus Company Info
static let SUPPORT_PUBKEY: Pubkey = Pubkey(hex: "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681")!
// MARK: General constants
static let GIF_IMAGE_TYPE: String = "com.compuserve.gif"
}

View File

@@ -32,6 +32,7 @@ enum Route: Hashable {
case DeveloperSettings(settings: UserSettingsStore)
case FirstAidSettings(settings: UserSettingsStore)
case Thread(thread: ThreadModel)
case ThreadFromReference(note_reference: LoadableThreadModel.NoteReference)
case Reposts(reposts: EventsModel)
case QuoteReposts(quotes: EventsModel)
case Reactions(reactions: EventsModel)
@@ -96,6 +97,8 @@ enum Route: Hashable {
case .Thread(let thread):
ChatroomThreadView(damus: damusState, thread: thread)
//ThreadView(state: damusState, thread: thread)
case .ThreadFromReference(let note_reference):
LoadableThreadView(state: damusState, note_reference: note_reference)
case .Reposts(let reposts):
RepostsView(damus_state: damusState, model: reposts)
case .QuoteReposts(let quote_reposts):
@@ -186,7 +189,10 @@ enum Route: Hashable {
hasher.combine("firstAidSettings")
case .Thread(let threadModel):
hasher.combine("thread")
hasher.combine(threadModel.event.id)
hasher.combine(threadModel.original_event.id)
case .ThreadFromReference(note_reference: let note_reference):
hasher.combine("thread_from_reference")
hasher.combine(note_reference)
case .Reposts(let reposts):
hasher.combine("reposts")
hasher.combine(reposts.target)

View File

@@ -13,47 +13,24 @@ struct ChatroomThreadView: View {
@State var once: Bool = false
let damus: DamusState
@ObservedObject var thread: ThreadModel
@State var selected_note_id: NoteId? = nil
@State var highlighted_note_id: NoteId? = nil
@State var user_just_posted_flag: Bool = false
@Namespace private var animation
@State var parent_events: [NostrEvent] = []
@State var sorted_child_events: [NostrEvent] = []
func compute_events(selected_event: NostrEvent? = nil) {
let selected_event = selected_event ?? thread.event
self.parent_events = damus.events.parent_events(event: selected_event, keypair: damus.keypair)
let all_recursive_child_events = self.recursive_child_events(event: selected_event)
self.sorted_child_events = all_recursive_child_events.filter({
should_show_event(event: $0, damus_state: damus) // Hide muted events from chatroom conversation
}).sorted(by: { a, b in
return a.created_at < b.created_at
})
}
func recursive_child_events(event: NdbNote) -> [NdbNote] {
let immediate_children = damus.events.child_events(event: event)
var indirect_children: [NdbNote] = []
for immediate_child in immediate_children {
indirect_children.append(contentsOf: self.recursive_child_events(event: immediate_child))
}
return immediate_children + indirect_children
}
func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) {
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: .top)
selected_note_id = note_id
highlighted_note_id = note_id
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
withAnimation {
selected_note_id = nil
highlighted_note_id = nil
}
})
}
func set_active_event(scroller: ScrollViewProxy, ev: NdbNote) {
withAnimation {
self.compute_events(selected_event: ev)
thread.set_active_event(ev, keypair: self.damus.keypair)
self.thread.select(event: ev)
self.go_to_event(scroller: scroller, note_id: ev.id)
}
}
@@ -63,7 +40,7 @@ struct ChatroomThreadView: View {
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: 8) {
// MARK: - Parents events view
ForEach(parent_events, id: \.id) { parent_event in
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)
@@ -93,7 +70,7 @@ struct ChatroomThreadView: View {
// MARK: - Actual event view
EventMutingContainerView(
damus_state: damus,
event: self.thread.event,
event: self.thread.selected_event,
muteBox: { event_shown, muted_reason in
AnyView(
EventMutedBoxView(shown: event_shown, reason: muted_reason)
@@ -101,19 +78,19 @@ struct ChatroomThreadView: View {
)
}
) {
SelectedEventView(damus: damus, event: self.thread.event, size: .selected)
.matchedGeometryEffect(id: self.thread.event.id.hex(), in: animation, anchor: .center)
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.event.id)
.id(self.thread.selected_event.id)
// MARK: - Children view
let events = sorted_child_events
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.event,
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,
@@ -124,7 +101,7 @@ struct ChatroomThreadView: View {
focus_event: {
self.set_active_event(scroller: scroller, ev: ev)
},
highlight_bubble: selected_note_id == ev.id,
highlight_bubble: highlighted_note_id == ev.id,
bar: make_actionbar_model(ev: ev.id, damus: damus)
)
.padding(.horizontal)
@@ -148,16 +125,14 @@ struct ChatroomThreadView: View {
}
})
.onReceive(thread.objectWillChange) {
self.compute_events()
if let last_event = thread.events().last, last_event.pubkey == damus.pubkey, user_just_posted_flag {
if let last_event = thread.events.last, last_event.pubkey == damus.pubkey, user_just_posted_flag {
self.go_to_event(scroller: scroller, note_id: last_event.id)
user_just_posted_flag = false
}
}
.onAppear() {
thread.subscribe()
self.compute_events()
scroll_to_event(scroller: scroller, id: thread.event.id, delay: 0.1, animate: false)
scroll_to_event(scroller: scroller, id: thread.selected_event.id, delay: 0.1, animate: false)
}
.onDisappear() {
thread.unsubscribe()
@@ -193,6 +168,7 @@ struct ChatroomView_Previews: PreviewProvider {
}
}
@MainActor
func scroll_after_load(thread: ThreadModel, proxy: ScrollViewProxy) {
scroll_to_event(scroller: proxy, id: thread.event.id, delay: 0.1, animate: false)
scroll_to_event(scroller: proxy, id: thread.selected_event.id, delay: 0.1, animate: false)
}

View File

@@ -0,0 +1,119 @@
//
// ErrorView.swift
// damus
//
// Created by Daniel D'Aquino on 2025-01-08.
//
import SwiftUI
/// A generic user-presentable error view
///
/// Use this to handle and display errors to the user when it does not make sense to create a custom error view.
/// This includes good error handling UX practices, such as:
/// - Clear description
/// - Actionable advice for the user on what to do next.
/// - One-click support contact options
struct ErrorView: View {
let damus_state: DamusState?
let error: UserPresentableError
@Environment(\.dismiss) var dismiss
var body: some View {
VStack(spacing: 6) {
Image(systemName: "exclamationmark.circle")
.resizable()
.frame(width: 30, height: 30)
.foregroundStyle(.red)
.accessibilityHidden(true)
Text("Oops!", comment: "Heading for an error screen")
.font(.title)
.bold()
.padding(.bottom, 10)
.accessibilityHeading(.h1)
Text(error.user_visible_description)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 5) {
Image(systemName: "sparkles")
.accessibilityHidden(true)
Text("Advice", comment: "Heading for some advice text to help the user with an error")
.font(.headline)
.accessibilityHeading(.h3)
}
Text(error.tip)
}
.padding()
.background(Color.secondary.opacity(0.2))
.cornerRadius(10)
.padding(.vertical, 30)
Spacer()
if let damus_state, damus_state.is_privkey_user {
Button(action: {
damus_state.nav.push(route: .DMChat(dms: .init(our_pubkey: damus_state.keypair.pubkey, pubkey: Constants.SUPPORT_PUBKEY)))
dismiss()
}, label: {
Text("Contact support via DMs", comment: "Button label to contact support from an error screen")
})
.padding(.vertical, 20)
}
Text("Contact support via email at [support@damus.io](mailto:support@damus.io)", comment: "Text to contact support via email")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(20)
.padding(.top, 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
///
/// **Requirements:**
/// - This should not be technical. It should use accessible language
/// - Should be localized
/// - It should try to explain the user what happened, and if possible why.
let user_visible_description: String
/// Helpful tip/advice to the user, to help them overcome the error
///
/// **Requirements:**
/// - Should provide actionable advice to the user
/// - This should not be overly technical
/// - Should be localized
/// - Should NOT include support contact (The view that will display this will already include support contact options)
///
/// **Implementation notes:**
/// - This is NOT an optional value, because part of good UX is making sure error messages are actionable, which is something that is often forgotten. It's not uncommon for error messages to be written in vague, technical, and/or unactionable terms, but this is when the user needs help the most. And so this field is made mandatory to force developers to write actionable content to the user
let tip: String
/// Technical information about the error, which will be sendable to developers
///
/// Note: This is still unutilized, but this will be used in the future.
///
/// **Requirements**
/// - Should never include any sensitive info
/// - Should be in English. The developers are the main audience.
/// - Should include helping info, such as context in which the error happens.
/// - Should be technical
let technical_info: String?
}
}
#Preview {
ErrorView(
damus_state: test_damus_state,
error: .init(
user_visible_description: "We are still too early",
tip: "Stay humble, keep building, stack sats",
technical_info: nil
)
)
}

View File

@@ -39,7 +39,7 @@ struct EventBody: View {
HighlightBodyView(state: damus_state, ev: event, options: options)
.onTapGesture {
if let highlighted_note = event.highlighted_note_id().flatMap({ damus_state.events.lookup($0) }) {
let thread = ThreadModel(event: highlighted_note, damus_state: damus_state, highlight: event.content)
let thread = ThreadModel(event: highlighted_note, damus_state: damus_state)
damus_state.nav.push(route: Route.Thread(thread: thread))
}
}

View File

@@ -135,7 +135,7 @@ struct MenuItems: View {
MuteDurationMenu { duration in
notify(.mute(.user(target_pubkey, duration?.date_from_now)))
} label: {
Label(NSLocalizedString("Mute user", comment: "Context menu option for muting users."), image: "mute")
Label(NSLocalizedString("Mute/Block user", comment: "Context menu option for muting/blocking users."), image: "mute")
}
}
}

View File

@@ -0,0 +1,216 @@
//
// LoadableThreadView.swift
// damus
//
// Created by Daniel D'Aquino on 2025-01-08.
//
import SwiftUI
/// A view model for `LoadableThreadView`
///
/// This takes a note reference, automatically tries to load it, and updates itself to reflect its current state
///
///
class LoadableThreadModel: ObservableObject {
let damus_state: DamusState
let note_reference: NoteReference
@Published var state: ThreadModelLoadingState = .loading
/// The time period after which it will give up loading the view.
/// Written in nanoseconds
let TIMEOUT: UInt64 = 10 * 1_000_000_000 // 10 seconds
init(damus_state: DamusState, note_reference: NoteReference) {
self.damus_state = damus_state
self.note_reference = note_reference
Task { await self.load() }
}
func load() async {
// Start the loading process in a separate task to manage the timeout independently.
let loadTask = Task { @MainActor in
self.state = await executeLoadingLogic()
}
// Setup a timer to cancel the load after the timeout period
let timeoutTask = Task { @MainActor in
try await Task.sleep(nanoseconds: TIMEOUT)
loadTask.cancel() // This sends a cancellation signal to the load task.
self.state = .not_found
}
await loadTask.value
timeoutTask.cancel() // Cancel the timeout task if loading finishes earlier.
}
private func executeLoadingLogic() async -> ThreadModelLoadingState {
switch note_reference {
case .note_id(let note_id):
let res = await find_event(state: damus_state, query: .event(evid: note_id))
guard let res, case .event(let ev) = res else { return .not_found }
return .loaded(model: await ThreadModel(event: ev, damus_state: damus_state))
case .naddr(let naddr):
guard let event = await naddrLookup(damus_state: damus_state, naddr: naddr) else { return .not_found }
return .loaded(model: await ThreadModel(event: event, damus_state: damus_state))
}
}
enum ThreadModelLoadingState {
case loading
case loaded(model: ThreadModel)
case not_found
}
enum NoteReference: Hashable {
case note_id(NoteId)
case naddr(NAddr)
}
}
struct LoadableThreadView: View {
let state: DamusState
@StateObject var loadable_thread: LoadableThreadModel
var loading: Bool {
switch loadable_thread.state {
case .loading:
return true
case .loaded, .not_found:
return false
}
}
init(state: DamusState, note_reference: LoadableThreadModel.NoteReference) {
self.state = state
self._loadable_thread = StateObject.init(wrappedValue: LoadableThreadModel(damus_state: state, note_reference: note_reference))
}
var body: some View {
switch self.loadable_thread.state {
case .loading:
ScrollView(.vertical) {
self.skeleton
.redacted(reason: loading ? .placeholder : [])
.shimmer(loading)
.accessibilityElement(children: .ignore)
.accessibilityLabel(NSLocalizedString("Loading thread", comment: "Accessibility label for the thread view when it is loading"))
}
case .loaded(model: let thread_model):
ChatroomThreadView(damus: state, thread: thread_model)
case .not_found:
self.not_found
}
}
var not_found: some View {
VStack(spacing: 6) {
Image(systemName: "questionmark.app")
.resizable()
.frame(width: 30, height: 30)
.accessibilityHidden(true)
Text("Note not found", comment: "Heading for the thread view in a not found error state")
.font(.title)
.bold()
.padding(.bottom, 10)
Text("We were unable to find the note you were looking for.", comment: "Text for the thread view when it is unable to find the note the user is looking for")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 5) {
Image(systemName: "sparkles")
.accessibilityHidden(true)
Text("Advice", comment: "Heading for some advice text to help the user with an error")
.font(.headline)
}
Text("Try checking the link again, your internet connection, whether you need to connect to a specific relay to access this content.", comment: "Tips on what to do if a note cannot be found.")
}
.padding()
.background(Color.secondary.opacity(0.2))
.cornerRadius(10)
.padding(.vertical, 30)
}
.padding()
}
// MARK: Skeleton views
// Implementation notes
// - No localization is needed because the text will be redacted
// - No accessibility label is needed because these will be summarized into a single accessibility label at the top-level view. See `body` in this struct
var skeleton: some View {
VStack(alignment: .leading, spacing: 40) {
self.skeleton_selected_event
self.skeleton_chat_event(message: "Nice! Have you tried Damus?", right: false)
self.skeleton_chat_event(message: "Yes, it's awesome.", right: true)
Spacer()
}
.padding()
}
func skeleton_chat_event(message: String, right: Bool) -> some View {
HStack(alignment: .center) {
if !right {
self.skeleton_chat_user_avatar
}
else {
Spacer()
}
ChatBubble(
direction: right ? .right : .left,
stroke_content: Color.accentColor.opacity(0),
stroke_style: .init(lineWidth: 4),
background_style: Color.secondary.opacity(0.5),
content: {
Text(message)
.padding()
}
)
if right {
self.skeleton_chat_user_avatar
}
else {
Spacer()
}
}
}
var skeleton_selected_event: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Circle()
.frame(width: 50, height: 50)
.foregroundStyle(.secondary.opacity(0.5))
Text("Satoshi Nakamoto")
.bold()
}
Text("Nostr is the super app. Because its actually an ecosystem of apps, all of which make each other better. People havent grasped that yet. They will when its more accessible and onboarding is more straightforward and intuitive.")
HStack {
self.skeleton_action_item
Spacer()
self.skeleton_action_item
Spacer()
self.skeleton_action_item
Spacer()
self.skeleton_action_item
}
}
}
var skeleton_chat_user_avatar: some View {
Circle()
.fill(.secondary.opacity(0.5))
.frame(width: 35, height: 35)
.padding(.bottom, -21)
}
var skeleton_action_item: some View {
Circle()
.fill(Color.secondary.opacity(0.5))
.frame(width: 25, height: 25)
}
}
#Preview("Loadable") {
LoadableThreadView(state: test_damus_state, note_reference: .note_id(test_thread_note_1.id))
}

View File

@@ -51,21 +51,29 @@ enum PostAction {
}
struct PostView: View {
@State var post: NSMutableAttributedString = NSMutableAttributedString()
@State var uploadedMedias: [UploadedMedia] = []
@State var references: [RefId] = []
/// Pubkeys that should be filtered out from the references
///
/// For example, when replying to an event, the user can select which pubkey mentions they want to keep, and which ones to remove.
@State var filtered_pubkeys: Set<Pubkey> = []
@FocusState var focus: Bool
@State var attach_media: Bool = false
@State var attach_camera: Bool = false
@State var error: String? = nil
@State var uploadedMedias: [UploadedMedia] = []
@State var image_upload_confirm: Bool = false
@State var imagePastedFromPasteboard: PreUploadedMedia? = nil
@State var imageUploadConfirmPasteboard: Bool = false
@State var references: [RefId] = []
@State var imageUploadConfirmDamusShare: Bool = false
@State var filtered_pubkeys: Set<Pubkey> = []
@State var focusWordAttributes: (String?, NSRange?) = (nil, nil)
@State var newCursorIndex: Int?
@State var textHeight: CGFloat? = nil
@State var saved_state: SaveState = .needs_saving()
/// A timer that helps us add a delay between when changes occur and when they are saved persistently (to avoid too many disk writes and a jittery save indicator)
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@State var preUploadedMedia: [PreUploadedMedia] = []
@@ -81,6 +89,16 @@ struct PostView: View {
let placeholder_messages: [String]
let initial_text_suffix: String?
enum SaveState: Equatable {
/// The draft has been modified and needs saving.
/// Saving should occur in N seconds
case needs_saving(seconds_remaining: Int = 3)
/// A saving operation is in progress
case saving
/// The draft has been saved to disk.
case saved
}
init(
action: PostAction,
damus_state: DamusState,
@@ -109,24 +127,7 @@ struct PostView: View {
}
func send_post() {
// don't add duplicate pubkeys but retain order
var pkset = Set<Pubkey>()
// we only want pubkeys really
let pks = references.reduce(into: Array<Pubkey>()) { acc, ref in
guard case .pubkey(let pk) = ref else {
return
}
if pkset.contains(pk) || filtered_pubkeys.contains(pk) {
return
}
pkset.insert(pk)
acc.append(pk)
}
let new_post = build_post(state: damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, pubkeys: pks)
let new_post = build_post(state: self.damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: self.references, filtered_pubkeys: filtered_pubkeys)
notify(.post(.post(new_post)))
@@ -182,10 +183,33 @@ struct PostView: View {
})
}
var save_state_indicator: some View {
HStack {
switch saved_state {
case .needs_saving:
EmptyView()
.accessibilityHidden(true) // Probably no need to show this to visually impaired users, might be too noisy
case .saving:
ProgressView()
.accessibilityHidden(true) // Probably no need to show this to visually impaired users, might be too noisy
case .saved:
Image(systemName: "checkmark")
.accessibilityHidden(true)
Text("Saved", comment: "Small label indicating that the user's draft has been saved to storage")
.accessibilityLabel(NSLocalizedString("Your draft has been saved to storage", comment: "Accessibility label indicating that a user's post draft has been saved, meant only for visually impaired users"))
.font(.caption)
}
}
.padding(6)
.foregroundStyle(.secondary)
}
var AttachmentBar: some View {
HStack(alignment: .center, spacing: 15) {
ImageButton
CameraButton
Spacer()
self.save_state_indicator
}
.disabled(uploading_disabled)
}
@@ -222,13 +246,13 @@ struct PostView: View {
func clear_draft() {
switch action {
case .replying_to(let replying_to):
damus_state.drafts.replies.removeValue(forKey: replying_to)
damus_state.drafts.replies.removeValue(forKey: replying_to.id)
case .quoting(let quoting):
damus_state.drafts.quotes.removeValue(forKey: quoting)
damus_state.drafts.quotes.removeValue(forKey: quoting.id)
case .posting:
damus_state.drafts.post = nil
case .highlighting(let draft):
damus_state.drafts.highlights.removeValue(forKey: draft.source)
damus_state.drafts.highlights.removeValue(forKey: draft)
case .sharing(_):
damus_state.drafts.post = nil
}
@@ -239,23 +263,31 @@ struct PostView: View {
guard let draft = load_draft_for_post(drafts: self.damus_state.drafts, action: self.action) else {
self.post = NSMutableAttributedString("")
self.uploadedMedias = []
self.saved_state = .needs_saving()
return false
}
self.uploadedMedias = draft.media
self.post = draft.content
self.saved_state = .saved
return true
}
/// Use this to signal that the post contents have changed. This will do two things:
///
/// 1. Save the new contents into our in-memory drafts
/// 2. Signal that we need to save drafts persistently, which will happen after a certain wait period
func post_changed(post: NSMutableAttributedString, media: [UploadedMedia]) {
if let draft = load_draft_for_post(drafts: damus_state.drafts, action: action) {
draft.content = post
draft.media = media
draft.media = uploadedMedias
draft.references = references
draft.filtered_pubkeys = filtered_pubkeys
} else {
let artifacts = DraftArtifacts(content: post, media: media)
let artifacts = DraftArtifacts(content: post, media: uploadedMedias, references: references, id: UUID().uuidString)
set_draft_for_post(drafts: damus_state.drafts, action: action, artifacts: artifacts)
}
self.saved_state = .needs_saving()
}
var TextEntry: some View {
@@ -356,7 +388,7 @@ struct PostView: View {
}
let blurhash = await blurhash
let meta = blurhash.map { bh in calculate_image_metadata(url: url, img: img, blurhash: bh) }
let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, representingImage: img, metadata: meta)
let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, metadata: meta)
uploadedMedias.append(uploadedMedia)
return true
@@ -570,6 +602,21 @@ struct PostView: View {
preUploadedMedia.removeAll()
}
}
.onReceive(timer) { time in
switch self.saved_state {
case .needs_saving(seconds_remaining: let seconds_remaining):
if seconds_remaining <= 0 {
self.saved_state = .saving
damus_state.drafts.save(damus_state: damus_state)
self.saved_state = .saved
}
else {
self.saved_state = .needs_saving(seconds_remaining: seconds_remaining - 1)
}
case .saving, .saved:
break
}
}
}
}
@@ -628,7 +675,7 @@ struct PVImageCarouselView: View {
.cornerRadius(10)
.padding()
.contextMenu { contextMenuContent(for: media[index]) }
} else if is_animated_image(url: media[index].uploadedURL) {
} else {
KFAnimatedImage(media[index].uploadedURL)
.imageContext(.note, disable_animation: false)
.configure { view in
@@ -638,14 +685,6 @@ struct PVImageCarouselView: View {
.cornerRadius(10)
.padding()
.contextMenu { contextMenuContent(for: media[index]) }
} else {
Image(uiImage: media[index].representingImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: media.count == 1 ? deviceWidth * 0.8 : 250, height: media.count == 1 ? 400 : 250)
.cornerRadius(10)
.padding()
.contextMenu { contextMenuContent(for: media[index]) }
}
VStack { // Set spacing to 0 to remove the gap between items
@@ -732,7 +771,6 @@ fileprivate func getImage(media: MediaUpload) -> UIImage {
struct UploadedMedia: Equatable {
let localURL: URL
let uploadedURL: URL
let representingImage: UIImage
let metadata: ImageMetadata?
}
@@ -740,13 +778,13 @@ struct UploadedMedia: Equatable {
func set_draft_for_post(drafts: Drafts, action: PostAction, artifacts: DraftArtifacts) {
switch action {
case .replying_to(let ev):
drafts.replies[ev] = artifacts
drafts.replies[ev.id] = artifacts
case .quoting(let ev):
drafts.quotes[ev] = artifacts
drafts.quotes[ev.id] = artifacts
case .posting:
drafts.post = artifacts
case .highlighting(let draft):
drafts.highlights[draft.source] = artifacts
drafts.highlights[draft] = artifacts
case .sharing(_):
drafts.post = artifacts
}
@@ -755,13 +793,21 @@ func set_draft_for_post(drafts: Drafts, action: PostAction, artifacts: DraftArti
func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts? {
switch action {
case .replying_to(let ev):
return drafts.replies[ev]
return drafts.replies[ev.id]
case .quoting(let ev):
return drafts.quotes[ev]
return drafts.quotes[ev.id]
case .posting:
return drafts.post
case .highlighting(let draft):
return drafts.highlights[draft.source]
case .highlighting(let highlight):
if let exact_match = drafts.highlights[highlight] {
return exact_match // Always prefer to return the draft for that exact same highlight
}
// If there are no exact matches to the highlight, try to load a draft for the same highlight source
// We do this to improve UX, because we don't want to leave the post view blank if they only selected a slightly different piece of text from before.
var other_matches = drafts.highlights
.filter { $0.key.source == highlight.source }
// It's not an exact match, so there is no way of telling which one is the preferred draft. So just load the first one we found.
return other_matches.first?.value
case .sharing(_):
return drafts.post
}
@@ -788,7 +834,53 @@ func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair) -> [[String]] {
return tags
}
func build_post(state: DamusState, post: NSMutableAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], pubkeys: [Pubkey]) -> NostrPost {
func build_post(state: DamusState, action: PostAction, draft: DraftArtifacts) -> NostrPost {
return build_post(
state: state,
post: draft.content,
action: action,
uploadedMedias: draft.media,
references: draft.references,
filtered_pubkeys: draft.filtered_pubkeys
)
}
func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId], filtered_pubkeys: Set<Pubkey>) -> NostrPost {
// don't add duplicate pubkeys but retain order
var pkset = Set<Pubkey>()
// we only want pubkeys really
let pks = references.reduce(into: Array<Pubkey>()) { acc, ref in
guard case .pubkey(let pk) = ref else {
return
}
if pkset.contains(pk) || filtered_pubkeys.contains(pk) {
return
}
pkset.insert(pk)
acc.append(pk)
}
return build_post(state: state, post: post, action: action, uploadedMedias: uploadedMedias, pubkeys: pks)
}
/// This builds a Nostr post from draft data from `PostView` or other draft-related classes
///
/// ## Implementation notes
///
/// - This function _likely_ causes no side-effects, and _should not_ cause side-effects to any of the inputs.
///
/// - Parameters:
/// - state: The damus state, needed to fetch more Nostr data to form this event
/// - post: The text content from `PostView`.
/// - action: The intended action of the post (highlighting? replying?)
/// - uploadedMedias: The medias attached to this post
/// - pubkeys: The referenced pubkeys
/// - Returns: A NostrPost, which can then be signed into an event.
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 nextCharIndex = range.upperBound
@@ -876,3 +968,11 @@ func isSupportedVideo(url: URL?) -> Bool {
return false
}
}
func isSupportedImage(url: URL) -> Bool {
let fileExtension = url.pathExtension.lowercased()
// It would be better to pull this programmatically from Apple's APIs, but there seems to be no such call
let supportedTypes = ["jpg", "png", "gif"]
return supportedTypes.contains(fileExtension)
}

View File

@@ -136,7 +136,8 @@ struct DamusPurpleAccountView: View {
created_at: Date.now,
expiry: Date.init(timeIntervalSinceNow: 60 * 60 * 24 * 30),
subscriber_number: 7,
active: true
active: true,
attributes: []
)
)
}
@@ -149,7 +150,8 @@ struct DamusPurpleAccountView: View {
created_at: Date.init(timeIntervalSinceNow: -60 * 60 * 24 * 37),
expiry: Date.init(timeIntervalSinceNow: -60 * 60 * 24 * 7),
subscriber_number: 7,
active: false
active: false,
attributes: []
)
)
}

View File

@@ -1,130 +0,0 @@
//
// ThreadV2View.swift
// damus
//
// Created by Thomas Tastet on 25/12/2022.
//
import SwiftUI
struct ThreadView: View {
let state: DamusState
@ObservedObject var thread: ThreadModel
@Environment(\.dismiss) var dismiss
var parent_events: [NostrEvent] {
state.events.parent_events(event: thread.event, keypair: state.keypair)
}
var sorted_child_events: [NostrEvent] {
state.events.child_events(event: thread.event).sorted(by: { a, b in
let a_is_muted = !should_show_event(event: a, damus_state: state)
let b_is_muted = !should_show_event(event: b, damus_state: state)
if a_is_muted == b_is_muted {
// If both are muted or unmuted, sort them based on their creation date.
return a.created_at < b.created_at
}
else {
// Muting status is different
// Prioritize the replies that are not muted
return !a_is_muted && b_is_muted
}
})
}
var body: some View {
//let top_zap = get_top_zap(events: state.events, evid: thread.event.id)
ScrollViewReader { reader in
ScrollView {
LazyVStack {
// MARK: - Parents events view
ForEach(parent_events, id: \.id) { parent_event in
EventMutingContainerView(damus_state: state, event: parent_event) {
EventView(damus: state, event: parent_event)
}
.padding(.horizontal)
.onTapGesture {
thread.set_active_event(parent_event, keypair: self.state.keypair)
scroll_to_event(scroller: reader, id: parent_event.id, delay: 0.1, animate: false)
}
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: state,
event: self.thread.event,
muteBox: { event_shown, muted_reason in
AnyView(
EventMutedBoxView(shown: event_shown, reason: muted_reason)
.padding(5)
)
}
) {
SelectedEventView(damus: state, event: self.thread.event, size: .selected)
}
.id(self.thread.event.id)
/*
if let top_zap {
ZapEvent(damus: state, zap: top_zap, is_top_zap: true)
.padding(.horizontal)
}
*/
ForEach(sorted_child_events, id: \.id) { child_event in
EventMutingContainerView(
damus_state: state,
event: child_event
) {
EventView(damus: state, event: child_event)
}
.padding(.horizontal)
.onTapGesture {
thread.set_active_event(child_event, keypair: state.keypair)
scroll_to_event(scroller: reader, id: child_event.id, delay: 0.1, animate: false)
}
Divider()
.padding([.top], 4)
}
}
}.navigationBarTitle(NSLocalizedString("Thread", comment: "Navigation bar title for note thread."))
.onAppear {
thread.subscribe()
let anchor: UnitPoint = self.thread.event.known_kind == .longform ? .top : .bottom
scroll_to_event(scroller: reader, id: self.thread.event.id, delay: 0.0, animate: false, anchor: anchor)
}
.onDisappear {
thread.unsubscribe()
}
.onReceive(handle_notify(.switched_timeline)) { notif in
dismiss()
}
}
}
}
struct ThreadView_Previews: PreviewProvider {
static var previews: some View {
let state = test_damus_state
let thread = ThreadModel(event: test_note, damus_state: state)
ThreadView(state: state, thread: thread)
}
}

Binary file not shown.

View File

@@ -533,10 +533,10 @@ Text for button to conect to Nostr Wallet Connect lightning wallet.</note>
<target>Connect to Alby Wallet</target>
<note>Button to attach an Alby Wallet, a service that provides a Lightning wallet for zapping sats. Alby is the name of the service and should not be translated.</note>
</trans-unit>
<trans-unit id="Connect to Mutiny Wallet" xml:space="preserve">
<source>Connect to Mutiny Wallet</source>
<target>Connect to Mutiny Wallet</target>
<note>Button to attach an Mutiny Wallet, a service that provides a Lightning wallet for zapping sats. Mutiny is the name of the service and should not be translated.</note>
<trans-unit id="Connect to Coinos" xml:space="preserve">
<source>Connect to Coinos</source>
<target>Connect to Coinos</target>
<note>Button to attach a Coinos Wallet, a service that provides a Lightning wallet for zapping sats. Coinos is the name of the service and should not be translated.</note>
</trans-unit>
<trans-unit id="Connecting" xml:space="preserve">
<source>Connecting</source>
@@ -779,7 +779,8 @@ Button to disconnect from the relay.</note>
<trans-unit id="Dismiss" xml:space="preserve">
<source>Dismiss</source>
<target>Dismiss</target>
<note>Button to dismiss alert</note>
<note>Button to dismiss alert
Button to dismiss error</note>
</trans-unit>
<trans-unit id="Done" xml:space="preserve">
<source>Done</source>
@@ -815,11 +816,21 @@ The duration in which to mute the given item.</note>
Button to enter edit mode for modifying the list of relays.
Edit Button for editing profile</note>
</trans-unit>
<trans-unit id="Edit Image" xml:space="preserve">
<source>Edit Image</source>
<target>Edit Image</target>
<note>Accessibility label for a button that edits an image</note>
</trans-unit>
<trans-unit id="Edit banner image" xml:space="preserve">
<source>Edit banner image</source>
<target>Edit banner image</target>
<note>Accessibility label for edit banner image button</note>
</trans-unit>
<trans-unit id="Edit profile picture" xml:space="preserve">
<source>Edit profile picture</source>
<target>Edit profile picture</target>
<note>Accessibility label for a button that edits a profile picture</note>
</trans-unit>
<trans-unit id="Enable Purple auto-translations" xml:space="preserve">
<source>Enable Purple auto-translations</source>
<target>Enable Purple auto-translations</target>
@@ -878,6 +889,16 @@ Title indicating that an error has occurred.</note>
<target>Error syncing up push notifications preferences with the server: %@</target>
<note>Error label shown when system tries to sync up notification preferences to the push notification server but something fails</note>
</trans-unit>
<trans-unit id="Error uploading profile image. Please check your internet connection and try again. If error persists, please contact Damus support (support@damus.io)." xml:space="preserve">
<source>Error uploading profile image. Please check your internet connection and try again. If error persists, please contact Damus support (support@damus.io).</source>
<target>Error uploading profile image. Please check your internet connection and try again. If error persists, please contact Damus support (support@damus.io).</target>
<note>Error label when uploading profile image</note>
</trans-unit>
<trans-unit id="Error while cropping image" xml:space="preserve">
<source>Error while cropping image</source>
<target>Error while cropping image</target>
<note>Heading on cropping error page</note>
</trans-unit>
<trans-unit id="Error, please try again" xml:space="preserve">
<source>Error, please try again</source>
<target>Error, please try again</target>
@@ -913,6 +934,11 @@ Title indicating that an error has occurred.</note>
<target>Expiry date</target>
<note>Label for Purple subscription expiry date</note>
</trans-unit>
<trans-unit id="Failed to generate media for upload. Please try again. If error persists, please contact Damus support at support@damus.io" xml:space="preserve">
<source>Failed to generate media for upload. Please try again. If error persists, please contact Damus support at support@damus.io</source>
<target>Failed to generate media for upload. Please try again. If error persists, please contact Damus support at support@damus.io</target>
<note>Error label forming media for upload after user crops the image.</note>
</trans-unit>
<trans-unit id="Failed to get push notification preferences from the server" xml:space="preserve">
<source>Failed to get push notification preferences from the server</source>
<target>Failed to get push notification preferences from the server</target>
@@ -1148,7 +1174,13 @@ Hope to meet folks who are on their own journeys to a peaceful and free life!</t
<trans-unit id="Image URL" xml:space="preserve">
<source>Image URL</source>
<target>Image URL</target>
<note>Option to enter a url</note>
<note>Label for image url text field
Option to enter a url</note>
</trans-unit>
<trans-unit id="Image is setup" xml:space="preserve">
<source>Image is setup</source>
<target>Image is setup</target>
<note>Accessibility value on image control</note>
</trans-unit>
<trans-unit id="Image uploader" xml:space="preserve">
<source>Image uploader</source>
@@ -1190,6 +1222,11 @@ Hope to meet folks who are on their own journeys to a peaceful and free life!</t
<target>Invalid Tip Address</target>
<note>Title of alerting as invalid tip address.</note>
</trans-unit>
<trans-unit id="Invalid URL" xml:space="preserve">
<source>Invalid URL</source>
<target>Invalid URL</target>
<note>Error label when user enters an invalid URL</note>
</trans-unit>
<trans-unit id="Invalid key" xml:space="preserve">
<source>Invalid key</source>
<target>Invalid key</target>
@@ -1448,6 +1485,11 @@ User confirm No</note>
<target>No contact list was found. You might experience issues using the app. If you suspect you have permanently lost your contact list (or if you never had one), you can fix this by resetting it</target>
<note>Section footer for Contact list first aid tools</note>
</trans-unit>
<trans-unit id="No image is currently setup" xml:space="preserve">
<source>No image is currently setup</source>
<target>No image is currently setup</target>
<note>Accessibility value on image control</note>
</trans-unit>
<trans-unit id="No logs to display" xml:space="preserve">
<source>No logs to display</source>
<target>No logs to display</target>
@@ -1463,6 +1505,11 @@ User confirm No</note>
<target>No one will see that you zapped</target>
<note>Description of anonymous zap type where the zap is sent anonymously and does not identify the user who sent it.</note>
</trans-unit>
<trans-unit id="No profile picture is currently setup" xml:space="preserve">
<source>No profile picture is currently setup</source>
<target>No profile picture is currently setup</target>
<note>Accessibility value on profile picture image control</note>
</trans-unit>
<trans-unit id="No results" xml:space="preserve">
<source>No results</source>
<target>No results</target>
@@ -1747,6 +1794,11 @@ Label indicating the production environment for Push notification functionality<
<target>Profile action sheets allow you to follow, zap, or DM profiles more quickly without having to view their full profile</target>
<note>Section footer clarifying what the profile action sheet feature does</note>
</trans-unit>
<trans-unit id="Profile picture is setup" xml:space="preserve">
<source>Profile picture is setup</source>
<target>Profile picture is setup</target>
<note>Accessibility value on profile picture image control</note>
</trans-unit>
<trans-unit id="Profiles" xml:space="preserve">
<source>Profiles</source>
<target>Profiles</target>
@@ -2217,6 +2269,11 @@ Button to show more of a long profile description.</note>
<target>Show wallet selector</target>
<note>Toggle to show or hide selection of wallet.</note>
</trans-unit>
<trans-unit id="Shows options to edit the image" xml:space="preserve">
<source>Shows options to edit the image</source>
<target>Shows options to edit the image</target>
<note>Accessibility hint for a button that edits an image</note>
</trans-unit>
<trans-unit id="Side menu" xml:space="preserve">
<source>Side menu</source>
<target>Side menu</target>
@@ -2267,6 +2324,11 @@ Button to show more of a long profile description.</note>
<target>Someone zapped you ⚡️</target>
<note>Title label for a push notification where someone zapped the user</note>
</trans-unit>
<trans-unit id="Sorry, but for some reason there has been an issue while trying to crop this image. Please try again later. If the error persists, please contact [Damus support](mailto:support@damus.io)" xml:space="preserve">
<source>Sorry, but for some reason there has been an issue while trying to crop this image. Please try again later. If the error persists, please contact [Damus support](mailto:support@damus.io)</source>
<target>Sorry, but for some reason there has been an issue while trying to crop this image. Please try again later. If the error persists, please contact [Damus support](mailto:support@damus.io)</target>
<note>Cropping error message</note>
</trans-unit>
<trans-unit id="Sorry, this QR code looks incompatible with Damus. Please try another one." xml:space="preserve">
<source>Sorry, this QR code looks incompatible with Damus. Please try another one.</source>
<target>Sorry, this QR code looks incompatible with Damus. Please try another one.</target>
@@ -4011,10 +4073,10 @@ Text for button to conect to Nostr Wallet Connect lightning wallet.</note>
<target state="new">Connect to Alby Wallet</target>
<note>Button to attach an Alby Wallet, a service that provides a Lightning wallet for zapping sats. Alby is the name of the service and should not be translated.</note>
</trans-unit>
<trans-unit id="Connect to Mutiny Wallet" xml:space="preserve">
<source>Connect to Mutiny Wallet</source>
<target state="new">Connect to Mutiny Wallet</target>
<note>Button to attach an Mutiny Wallet, a service that provides a Lightning wallet for zapping sats. Mutiny is the name of the service and should not be translated.</note>
<trans-unit id="Connect to Coinos" xml:space="preserve">
<source>Connect to Coinos</source>
<target state="new">Connect to Coinos</target>
<note>Button to attach a Coinos Wallet, a service that provides a Lightning wallet for zapping sats. Coinos is the name of the service and should not be translated.</note>
</trans-unit>
<trans-unit id="Connecting" xml:space="preserve">
<source>Connecting</source>
@@ -4257,7 +4319,8 @@ Button to disconnect from the relay.</note>
<trans-unit id="Dismiss" xml:space="preserve">
<source>Dismiss</source>
<target state="new">Dismiss</target>
<note>Button to dismiss alert</note>
<note>Button to dismiss alert
Button to dismiss error</note>
</trans-unit>
<trans-unit id="Done" xml:space="preserve">
<source>Done</source>
@@ -4293,11 +4356,21 @@ The duration in which to mute the given item.</note>
Button to enter edit mode for modifying the list of relays.
Edit Button for editing profile</note>
</trans-unit>
<trans-unit id="Edit Image" xml:space="preserve">
<source>Edit Image</source>
<target state="new">Edit Image</target>
<note>Accessibility label for a button that edits an image</note>
</trans-unit>
<trans-unit id="Edit banner image" xml:space="preserve">
<source>Edit banner image</source>
<target state="new">Edit banner image</target>
<note>Accessibility label for edit banner image button</note>
</trans-unit>
<trans-unit id="Edit profile picture" xml:space="preserve">
<source>Edit profile picture</source>
<target state="new">Edit profile picture</target>
<note>Accessibility label for a button that edits a profile picture</note>
</trans-unit>
<trans-unit id="Enable Purple auto-translations" xml:space="preserve">
<source>Enable Purple auto-translations</source>
<target state="new">Enable Purple auto-translations</target>
@@ -4356,6 +4429,16 @@ Title indicating that an error has occurred.</note>
<target state="new">Error syncing up push notifications preferences with the server: %@</target>
<note>Error label shown when system tries to sync up notification preferences to the push notification server but something fails</note>
</trans-unit>
<trans-unit id="Error uploading profile image. Please check your internet connection and try again. If error persists, please contact Damus support (support@damus.io)." xml:space="preserve">
<source>Error uploading profile image. Please check your internet connection and try again. If error persists, please contact Damus support (support@damus.io).</source>
<target state="new">Error uploading profile image. Please check your internet connection and try again. If error persists, please contact Damus support (support@damus.io).</target>
<note>Error label when uploading profile image</note>
</trans-unit>
<trans-unit id="Error while cropping image" xml:space="preserve">
<source>Error while cropping image</source>
<target state="new">Error while cropping image</target>
<note>Heading on cropping error page</note>
</trans-unit>
<trans-unit id="Error, please try again" xml:space="preserve">
<source>Error, please try again</source>
<target state="new">Error, please try again</target>
@@ -4391,6 +4474,11 @@ Title indicating that an error has occurred.</note>
<target state="new">Expiry date</target>
<note>Label for Purple subscription expiry date</note>
</trans-unit>
<trans-unit id="Failed to generate media for upload. Please try again. If error persists, please contact Damus support at support@damus.io" xml:space="preserve">
<source>Failed to generate media for upload. Please try again. If error persists, please contact Damus support at support@damus.io</source>
<target state="new">Failed to generate media for upload. Please try again. If error persists, please contact Damus support at support@damus.io</target>
<note>Error label forming media for upload after user crops the image.</note>
</trans-unit>
<trans-unit id="Failed to get push notification preferences from the server" xml:space="preserve">
<source>Failed to get push notification preferences from the server</source>
<target state="new">Failed to get push notification preferences from the server</target>
@@ -4626,7 +4714,13 @@ Hope to meet folks who are on their own journeys to a peaceful and free life!</t
<trans-unit id="Image URL" xml:space="preserve">
<source>Image URL</source>
<target state="new">Image URL</target>
<note>Option to enter a url</note>
<note>Label for image url text field
Option to enter a url</note>
</trans-unit>
<trans-unit id="Image is setup" xml:space="preserve">
<source>Image is setup</source>
<target state="new">Image is setup</target>
<note>Accessibility value on image control</note>
</trans-unit>
<trans-unit id="Image uploader" xml:space="preserve">
<source>Image uploader</source>
@@ -4668,6 +4762,11 @@ Hope to meet folks who are on their own journeys to a peaceful and free life!</t
<target state="new">Invalid Tip Address</target>
<note>Title of alerting as invalid tip address.</note>
</trans-unit>
<trans-unit id="Invalid URL" xml:space="preserve">
<source>Invalid URL</source>
<target state="new">Invalid URL</target>
<note>Error label when user enters an invalid URL</note>
</trans-unit>
<trans-unit id="Invalid key" xml:space="preserve">
<source>Invalid key</source>
<target state="new">Invalid key</target>
@@ -4926,6 +5025,11 @@ User confirm No</note>
<target state="new">No content available to share</target>
<note>Title indicating that there was no available content to share</note>
</trans-unit>
<trans-unit id="No image is currently setup" xml:space="preserve">
<source>No image is currently setup</source>
<target state="new">No image is currently setup</target>
<note>Accessibility value on image control</note>
</trans-unit>
<trans-unit id="No logs to display" xml:space="preserve">
<source>No logs to display</source>
<target state="new">No logs to display</target>
@@ -4941,6 +5045,11 @@ User confirm No</note>
<target state="new">No one will see that you zapped</target>
<note>Description of anonymous zap type where the zap is sent anonymously and does not identify the user who sent it.</note>
</trans-unit>
<trans-unit id="No profile picture is currently setup" xml:space="preserve">
<source>No profile picture is currently setup</source>
<target state="new">No profile picture is currently setup</target>
<note>Accessibility value on profile picture image control</note>
</trans-unit>
<trans-unit id="No results" xml:space="preserve">
<source>No results</source>
<target state="new">No results</target>
@@ -5210,6 +5319,11 @@ Label indicating the production environment for Push notification functionality<
<target state="new">Profile action sheets allow you to follow, zap, or DM profiles more quickly without having to view their full profile</target>
<note>Section footer clarifying what the profile action sheet feature does</note>
</trans-unit>
<trans-unit id="Profile picture is setup" xml:space="preserve">
<source>Profile picture is setup</source>
<target state="new">Profile picture is setup</target>
<note>Accessibility value on profile picture image control</note>
</trans-unit>
<trans-unit id="Profiles" xml:space="preserve">
<source>Profiles</source>
<target state="new">Profiles</target>
@@ -5690,6 +5804,11 @@ Button to show more of a long profile description.</note>
<target state="new">Show wallet selector</target>
<note>Toggle to show or hide selection of wallet.</note>
</trans-unit>
<trans-unit id="Shows options to edit the image" xml:space="preserve">
<source>Shows options to edit the image</source>
<target state="new">Shows options to edit the image</target>
<note>Accessibility hint for a button that edits an image</note>
</trans-unit>
<trans-unit id="Side menu" xml:space="preserve">
<source>Side menu</source>
<target state="new">Side menu</target>
@@ -5740,6 +5859,11 @@ Button to show more of a long profile description.</note>
<target state="new">Someone zapped you ⚡️</target>
<note>Title label for a push notification where someone zapped the user</note>
</trans-unit>
<trans-unit id="Sorry, but for some reason there has been an issue while trying to crop this image. Please try again later. If the error persists, please contact [Damus support](mailto:support@damus.io)" xml:space="preserve">
<source>Sorry, but for some reason there has been an issue while trying to crop this image. Please try again later. If the error persists, please contact [Damus support](mailto:support@damus.io)</source>
<target state="new">Sorry, but for some reason there has been an issue while trying to crop this image. Please try again later. If the error persists, please contact [Damus support](mailto:support@damus.io)</target>
<note>Cropping error message</note>
</trans-unit>
<trans-unit id="Sorry, this QR code looks incompatible with Damus. Please try another one." xml:space="preserve">
<source>Sorry, this QR code looks incompatible with Damus. Please try another one.</source>
<target state="new">Sorry, this QR code looks incompatible with Damus. Please try another one.</target>

View File

@@ -315,8 +315,8 @@
"Connect to Alby Wallet" : {
"comment" : "Button to attach an Alby Wallet, a service that provides a Lightning wallet for zapping sats. Alby is the name of the service and should not be translated."
},
"Connect to Mutiny Wallet" : {
"comment" : "Button to attach an Mutiny Wallet, a service that provides a Lightning wallet for zapping sats. Mutiny is the name of the service and should not be translated."
"Connect to Coinos" : {
"comment" : "Button to attach a Coinos Wallet, a service that provides a Lightning wallet for zapping sats. Coinos is the name of the service and should not be translated."
},
"Connecting" : {
"comment" : "Relay status label that indicates a relay is connecting."
@@ -454,7 +454,7 @@
"comment" : "Text for button to disconnect from Nostr Wallet Connect lightning wallet."
},
"Dismiss" : {
"comment" : "Button to dismiss alert"
"comment" : "Button to dismiss alert\nButton to dismiss error"
},
"DMs" : {
"comment" : "Navigation title for DMs view, where DM is the English abbreviation for Direct Message.\nNavigation title for view of DMs, where DM is an English abbreviation for Direct Message.\nPicker option for DM selector for seeing only DMs that have been responded to. DM is the English abbreviation for Direct Message.\nSetting to enable DM Local Notification\nToolbar label for DMs view, where DM is the English abbreviation for Direct Message."
@@ -477,6 +477,12 @@
"Edit banner image" : {
"comment" : "Accessibility label for edit banner image button"
},
"Edit Image" : {
"comment" : "Accessibility label for a button that edits an image"
},
"Edit profile picture" : {
"comment" : "Accessibility label for a button that edits a profile picture"
},
"Enable experimental Purple API support" : {
"comment" : "Developer mode setting to enable experimental Purple API support."
},
@@ -510,6 +516,12 @@
"Error syncing up push notifications preferences with the server: %@" : {
"comment" : "Error label shown when system tries to sync up notification preferences to the push notification server but something fails"
},
"Error uploading profile image. Please check your internet connection and try again. If error persists, please contact Damus support (support@damus.io)." : {
"comment" : "Error label when uploading profile image"
},
"Error while cropping image" : {
"comment" : "Heading on cropping error page"
},
"Error, please try again" : {
"comment" : "Text on QR code camera view indicating an error"
},
@@ -534,6 +546,9 @@
"Expiry date" : {
"comment" : "Label for Purple subscription expiry date"
},
"Failed to generate media for upload. Please try again. If error persists, please contact Damus support at support@damus.io" : {
"comment" : "Error label forming media for upload after user crops the image."
},
"Failed to get push notification preferences from the server" : {
"comment" : "Error label indicating about a failure in fetching notification preferences"
},
@@ -679,11 +694,14 @@
"Illegal Content" : {
"comment" : "Description of report type for illegal content."
},
"Image is setup" : {
"comment" : "Accessibility value on image control"
},
"Image uploader" : {
"comment" : "Prompt selection of user's image uploader"
},
"Image URL" : {
"comment" : "Option to enter a url"
"comment" : "Label for image url text field\nOption to enter a url"
},
"Images" : {
"comment" : "Section title for images configuration."
@@ -712,6 +730,9 @@
"Invalid Tip Address" : {
"comment" : "Title of alerting as invalid tip address."
},
"Invalid URL" : {
"comment" : "Error label when user enters an invalid URL"
},
"It seems that you already have a translation service configured. Would you like to switch to Damus Purple as your translator?" : {
"comment" : "Confirmation dialog question asking users if they want their translation settings to be automatically switched to the Damus Purple translation service"
},
@@ -856,6 +877,9 @@
"No content available to share" : {
"comment" : "Title indicating that there was no available content to share"
},
"No image is currently setup" : {
"comment" : "Accessibility value on image control"
},
"No logs to display" : {
"comment" : "Label to indicate that there are no developer mode logs available to be displayed on the screen"
},
@@ -865,6 +889,9 @@
"No one will see that you zapped" : {
"comment" : "Description of anonymous zap type where the zap is sent anonymously and does not identify the user who sent it."
},
"No profile picture is currently setup" : {
"comment" : "Accessibility value on profile picture image control"
},
"No results" : {
"comment" : "A label indicating that note search resulted in no results"
},
@@ -1060,6 +1087,9 @@
"Profile action sheets allow you to follow, zap, or DM profiles more quickly without having to view their full profile" : {
"comment" : "Section footer clarifying what the profile action sheet feature does"
},
"Profile picture is setup" : {
"comment" : "Accessibility value on profile picture image control"
},
"Profiles" : {
"comment" : "Section title for profile view configuration."
},
@@ -1347,6 +1377,9 @@
"Show wallet selector" : {
"comment" : "Toggle to show or hide selection of wallet."
},
"Shows options to edit the image" : {
"comment" : "Accessibility hint for a button that edits an image"
},
"Side menu" : {
"comment" : "Accessibility label for the side menu button at the topbar"
},
@@ -1380,6 +1413,9 @@
"Someone zapped you ⚡️" : {
"comment" : "Title label for a push notification where someone zapped the user"
},
"Sorry, but for some reason there has been an issue while trying to crop this image. Please try again later. If the error persists, please contact [Damus support](mailto:support@damus.io)" : {
"comment" : "Cropping error message"
},
"Sorry, this QR code looks incompatible with Damus. Please try another one." : {
"comment" : "Text on QR code camera view telling the user a QR is incompatible"
},

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,21 @@
//
// DraftTests.swift
// damusTests
//
// Created by Daniel DAquino on 2025-01-15
import XCTest
@testable import damus
class DraftTests: XCTestCase {
func testRoundtripNIP37Draft() {
let test_note =
NostrEvent(
content: "Test",
keypair: test_keypair_full.to_keypair(),
createdAt: UInt32(Date().timeIntervalSince1970 - 100)
)!
let draft = try! NIP37Draft(unwrapped_note: test_note, draft_id: "test", keypair: test_keypair_full)!
XCTAssertEqual(draft.unwrapped_note, test_note)
}
}