Compare commits
1 Commits
hide-hellt
...
offline-tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
e456ac864d
|
52
.github/ISSUE_TEMPLATE/app_release.md
vendored
@@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
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_
|
|
||||||
|
|
||||||
37
.github/pull_request_template.md
vendored
@@ -1,37 +0,0 @@
|
|||||||
## Summary
|
|
||||||
|
|
||||||
_[Please provide a summary of the changes in this PR.]_
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
- [ ] I have read (or I am familiar with) the [Contribution Guidelines](../docs/CONTRIBUTING.md)
|
|
||||||
- [ ] I have tested the changes in this PR
|
|
||||||
- [ ] I have opened or referred to an existing github issue related to this change.
|
|
||||||
- [ ] My PR is either small, or I have split it into smaller logical commits that are easier to review
|
|
||||||
- [ ] I have added the signoff line to all my commits. See [Signing off your work](../docs/CONTRIBUTING.md#sign-your-work---the-developers-certificate-of-origin)
|
|
||||||
- [ ] I have added appropriate changelog entries for the changes in this PR. See [Adding changelog entries](../docs/CONTRIBUTING.md#add-changelog-changed-changelog-fixed-etc)
|
|
||||||
- [ ] I do not need to add a changelog entry. Reason: _[Please provide a reason]_
|
|
||||||
- [ ] I have added appropriate `Closes:` or `Fixes:` tags in the commit messages wherever applicable, or made sure those are not needed. See [Submitting patches](https://github.com/damus-io/damus/blob/master/docs/CONTRIBUTING.md#submitting-patches)
|
|
||||||
|
|
||||||
## Test report
|
|
||||||
|
|
||||||
_Please provide a test report for the changes in this PR. You can use the template below, but feel free to modify it as needed._
|
|
||||||
|
|
||||||
**Device:** _[Please specify the device you used for testing]_
|
|
||||||
|
|
||||||
**iOS:** _[Please specify the iOS version you used for testing]_
|
|
||||||
|
|
||||||
**Damus:** _[Please specify the Damus version or commit hash you used for testing]_
|
|
||||||
|
|
||||||
**Setup:** _[Please provide a brief description of the setup you used for testing, if applicable]_
|
|
||||||
|
|
||||||
**Steps:** _[Please provide a list of steps you took to test the changes in this PR]_
|
|
||||||
|
|
||||||
**Results:**
|
|
||||||
- [ ] PASS
|
|
||||||
- [ ] Partial PASS
|
|
||||||
- Details: _[Please provide details of the partial pass]_
|
|
||||||
|
|
||||||
## Other notes
|
|
||||||
|
|
||||||
_[Please provide any other information that you think is relevant to this PR.]_
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
### Acknowledgements and licenses
|
|
||||||
|
|
||||||
1. This product contains code derived from [Nostr SDK iOS](https://github.com/nostr-sdk/nostr-sdk-ios). [License](https://github.com/nostr-sdk/nostr-sdk-ios/blob/40df800c6749d7ce0b6fd7328e76cbc0dc71c87b/LICENSE)
|
|
||||||
2. This product includes software developed by the "Marcin Krzyzanowski" (http://krzyzanowskim.com/). [License](https://github.com/krzyzanowskim/CryptoSwift/blob/e74bbbfbef939224b242ae7c342a90e60b88b5ce/LICENSE)
|
|
||||||
|
|
||||||
216
CHANGELOG.md
@@ -1,219 +1,3 @@
|
|||||||
## [1.13.1] - 2025-03-21
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fixed an issue where threads would not load properly (Daniel D’Aquino)
|
|
||||||
|
|
||||||
|
|
||||||
[1.13.1]: https://github.com/damus-io/damus/releases/tag/v1.13.1
|
|
||||||
|
|
||||||
|
|
||||||
## [1.13] - 2025-03-14
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Added local persistence of note drafts (Daniel D’Aquino)
|
|
||||||
- Added user-friendly error view for errors around the app that would not fit in other places (Daniel D’Aquino)
|
|
||||||
- Coinos connection button in Wallet view (ericholguin)
|
|
||||||
- Added Alby Go to mobile wallets selection menu (Tomek ⚡ K)
|
|
||||||
- Minor accessibility improvements around picture editing and onboarding (Daniel D’Aquino)
|
|
||||||
- Profile image cropping tools (Daniel D’Aquino)
|
|
||||||
- Added Conversations tab to profiles (Terry Yiu)
|
|
||||||
- Added profile pictures to push notifications (William Casarin)
|
|
||||||
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Don't show reposts for the same note more than once in your home feed (William Casarin)
|
|
||||||
- Improved profile image bandwidth optimization (Daniel D’Aquino)
|
|
||||||
- Improved reliability of picture selector (Daniel D’Aquino)
|
|
||||||
- Changed spaces to newlines in new posts to provide cleaner separation between text, uploaded media, and quoted notes (Terry Yiu)
|
|
||||||
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fixed issue where some push notifications would not open in the app and leave users confused (Daniel D’Aquino)
|
|
||||||
- Fixed issue where app would need a restart for new NWC wallets to work (Daniel D’Aquino)
|
|
||||||
- Fixed overly sensitive horizontal swipe on thread chat view (Daniel D’Aquino)
|
|
||||||
- Trim whitespaces from Lightning addresses (Terry Yiu)
|
|
||||||
- Fixed translation export script by upgrading nostr-sdk-swift dependency to support Mac Catalyst (Terry Yiu)
|
|
||||||
- Fixed issue where users continue to receive push notifications after logout (Daniel D’Aquino)
|
|
||||||
- Fixed an issue where events on a thread view would occasionally disappear (Daniel D’Aquino)
|
|
||||||
- Improved robustness of the URL handler (Daniel D’Aquino)
|
|
||||||
- Translate notes even if they are in a preferred language but not the current language as that is what users expect (Terry Yiu)
|
|
||||||
- Cancel ongoing uploading operations after the user cancels the post (Swift Coder)
|
|
||||||
- Fixed link and photo sharing support on macOS (Swift Coder)
|
|
||||||
- Fix bug where profile view was showing more than just the notes and replies on the notes / notes & replies tabs (Terry Yiu)
|
|
||||||
- Fixed reposts banner to be localizable (Terry Yiu)
|
|
||||||
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Removed language filtering from Universe feed because language detection can be inaccurate (Terry Yiu)
|
|
||||||
- Removed mystery tabs meant to fix tab switching bug that no longer exists (Terry Yiu)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[1.13](https://github.com/damus-io/damus/releases/tag/v1.13): https://github.com/damus-io/damus/releases/tag/v1.13
|
|
||||||
|
|
||||||
|
|
||||||
## [1.12.3] - 2025-02-06
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Purple members who have been active for more than a year now get a special badge (Daniel D’Aquino)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Improved clarity of the mute button to indicate it can be used for blocking a user (Daniel D’Aquino)
|
|
||||||
- Made the microphone access request message more clear to users (Daniel D’Aquino)
|
|
||||||
|
|
||||||
[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 D’Aquino)
|
|
||||||
- Improved accessibility support on some elements (Daniel D’Aquino)
|
|
||||||
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fixed issue where the "next" button would appear hidden and hard to click on the create account view (Daniel D’Aquino)
|
|
||||||
- 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 D’Aquino)
|
|
||||||
- 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 D’Aquino)
|
|
||||||
- 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
|
|
||||||
|
|
||||||
- Add Damus Share Feature (Swift)
|
|
||||||
- Added new easy to use video controls for full screen video (Daniel D’Aquino)
|
|
||||||
- Add Edit, Share, and Tap-gesture in Profile pic image viewer (Swift Coder)
|
|
||||||
- Disappearing header, tabbar, and post button on scroll (ericholguin)
|
|
||||||
- Add Apple translation popovers for notes for iOS 17.4+ and macOS 14.4+ (Terry Yiu)
|
|
||||||
- Added NDB search functionality to the universe view (ericholguin)
|
|
||||||
- Added mute button to ProfileActionSheet (chungwwei)
|
|
||||||
- Added mute action to selected text menu (ericholguin)
|
|
||||||
- Added support for pasting images from the clipboard to the post composer (Swift Coder)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Improved image carousel image fill behavior (Daniel D’Aquino)
|
|
||||||
- Improved video syncing and bandwidth usage when switching between timeline video and full screen mode (Daniel D’Aquino)
|
|
||||||
- Swipe to dismiss on full screen carousel now shows an opacity effect for improved UX (Daniel D’Aquino)
|
|
||||||
- Removed event contents from full screen media carousel for cleaner view (Daniel D’Aquino)
|
|
||||||
- Add share button for images on full screen image carousel view (Swift)
|
|
||||||
- Changed boldness of font in side menu labels. (ericholguin)
|
|
||||||
- Changed search notes button with searched keyword (ericholguin)
|
|
||||||
- Changed opacity of tabbar and post button (ericholguin)
|
|
||||||
- Allow multiple images to be uploaded at the same time (swiftcoder) (William Casarin)
|
|
||||||
- Changed side menu design (ericholguin)
|
|
||||||
- Truncate fulltext search results (William Casarin)
|
|
||||||
- Expanded profile search results to 128 (William Casarin)
|
|
||||||
- Expand nostrdb text search results to 128 items (William Casarin)
|
|
||||||
- Use LazyVStack in text search results (William Casarin)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fixed missing tab bar on navigation (Swift Coder)
|
|
||||||
- Fixed some issues where QR code would not work, and improved UX (Daniel D’Aquino)
|
|
||||||
- Fixed iOS 18 gesture issues that would take user to the thread view when clicking on a video or unmuting it (Daniel D’Aquino)
|
|
||||||
- Fixed several issues that would cause video to automatically play or pause incorrectly (Daniel D’Aquino)
|
|
||||||
- Fixed issue where full screen video would disappear when going to landscape mode (Daniel D’Aquino)
|
|
||||||
- Fixed portrait video size on full screen carousel (Daniel D’Aquino)
|
|
||||||
- Fix avatar image on qrcode view (Swift Coder)
|
|
||||||
- Fix banner image upload (Swift Coder)
|
|
||||||
- Fix dismiss button visibility (Swift Coder)
|
|
||||||
- Fix quote repost counting (William Casarin)
|
|
||||||
- Fixed overlapping text in Universe View (ericholguin)
|
|
||||||
- Fixed localization issues and exported strings (Terry Yiu)
|
|
||||||
- Fix sensitive long-press gesture on event chat bubble in iOS 18 (Daniel D’Aquino)
|
|
||||||
- Fixed bottom padding for tabbar (ericholguin)
|
|
||||||
- Fixed localization build failures (Terry Yiu)
|
|
||||||
- Fixed back nav button placement in profile edit view (ericholguin)
|
|
||||||
- Friend profiles will now more likely show up in profile search (William Casarin)
|
|
||||||
- Fix broken QR code scanner and fix landscape mode (Terry Yiu)
|
|
||||||
|
|
||||||
[1.11(10)](https://github.com/damus-io/damus/releases/tag/v1.11-10): https://github.com/damus-io/damus/releases/tag/v1.11-10
|
|
||||||
|
|
||||||
## [1.10.1] - 2024-09-22
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Push notification support (Daniel D’Aquino)
|
|
||||||
- Added profile edit safe guards (Eric Holguin)
|
|
||||||
- Tor relay icon (ericholguin)
|
|
||||||
- Add highlighter for web pages (Daniel D’Aquino)
|
|
||||||
- Add support for adding comments when creating a highlight (Daniel D’Aquino)
|
|
||||||
- Add support for rendering highlights with comments (Daniel D’Aquino)
|
|
||||||
- Ability to create highlights (ericholguin)
|
|
||||||
- Highlights (NIP-84) (ericholguin)
|
|
||||||
- Revamp emoji picker to be less error-prone and add search, frequently used, and multiple skin tone support capabilities (Terry Yiu)
|
|
||||||
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Improve notification view filtering UX (Daniel D’Aquino)
|
|
||||||
- Improve visibility of friends filter button (Daniel D’Aquino)
|
|
||||||
- Changed the default banner from ostriches to damoose (Eric Holguin)
|
|
||||||
- Changed image and banner url text fields to new sheet view (Eric Holguin)
|
|
||||||
- Onboarding design (ericholguin)
|
|
||||||
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fix items that became unclickable on iOS 18 (Daniel D’Aquino)
|
|
||||||
- Fix many reconnection issues (William Casarin)
|
|
||||||
- Fixed issue where theme would be changed to black and can't be switched back on iOS 18 (cr0bar)
|
|
||||||
- Fixed some scenarios where the contact list would never be saved locally and cause issues when switching relays. (Daniel D’Aquino)
|
|
||||||
- Fix albyhub zaps not appearing (William Casarin)
|
|
||||||
- Fix inadvertent escape from mention suggestion menu when typing a space character (Daniel D’Aquino)
|
|
||||||
- Fix profile view toolbar alignment bug in iOS 18 (Terry Yiu)
|
|
||||||
- Create Account model now uses correct metadata (ericholguin)
|
|
||||||
- Restore localization for custom tabs (William Casarin)
|
|
||||||
- Fix iOS 18 reflection runtime error for custom picker (William Casarin)
|
|
||||||
|
|
||||||
|
|
||||||
[1.10.1]: https://github.com/damus-io/damus/releases/tag/v1.10.1
|
|
||||||
|
|
||||||
|
|
||||||
## [1.9.1 (4)] - 2024-08-13
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fix crash when viewing notes with invalid image dimension metadata (Daniel D’Aquino)
|
|
||||||
|
|
||||||
[1.9.1 (4)]: https://github.com/damus-io/damus/releases/tag/v1.9.1-4
|
|
||||||
|
|
||||||
|
|
||||||
## [1.9 (14)] - 2024-07-14
|
## [1.9 (14)] - 2024-07-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.developer.usernotifications.communication</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
|
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
|||||||
@@ -5,32 +5,15 @@
|
|||||||
// Created by Daniel D’Aquino on 2023-11-10.
|
// Created by Daniel D’Aquino on 2023-11-10.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Kingfisher
|
|
||||||
import ImageIO
|
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import Foundation
|
import Foundation
|
||||||
import UniformTypeIdentifiers
|
|
||||||
import Intents
|
|
||||||
|
|
||||||
class NotificationService: UNNotificationServiceExtension {
|
class NotificationService: UNNotificationServiceExtension {
|
||||||
|
|
||||||
var contentHandler: ((UNNotificationContent) -> Void)?
|
var contentHandler: ((UNNotificationContent) -> Void)?
|
||||||
var bestAttemptContent: UNMutableNotificationContent?
|
var bestAttemptContent: UNMutableNotificationContent?
|
||||||
|
|
||||||
private func configureKingfisherCache() {
|
|
||||||
guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachePath = groupURL.appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME)
|
|
||||||
if let cache = try? ImageCache(name: "sharedCache", cacheDirectoryURL: cachePath) {
|
|
||||||
KingfisherManager.shared.cache = cache
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||||
configureKingfisherCache()
|
|
||||||
|
|
||||||
self.contentHandler = contentHandler
|
self.contentHandler = contentHandler
|
||||||
|
|
||||||
guard let nostr_event_json = request.content.userInfo["nostr_event"] as? String,
|
guard let nostr_event_json = request.content.userInfo["nostr_event"] as? String,
|
||||||
@@ -57,16 +40,9 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let sender_profile = {
|
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
|
||||||
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
|
let profile = txn?.unsafeUnownedValue?.profile
|
||||||
let profile = txn?.unsafeUnownedValue?.profile
|
let name = Profile.displayName(profile: profile, pubkey: nostr_event.pubkey).displayName
|
||||||
let picture = ((profile?.picture.map { URL(string: $0) }) ?? URL(string: robohash(nostr_event.pubkey)))!
|
|
||||||
return ProfileBuf(picture: picture,
|
|
||||||
name: profile?.name,
|
|
||||||
display_name: profile?.display_name,
|
|
||||||
nip05: profile?.nip05)
|
|
||||||
}()
|
|
||||||
let sender_pubkey = nostr_event.pubkey
|
|
||||||
|
|
||||||
// Don't show notification details that match mute list.
|
// Don't show notification details that match mute list.
|
||||||
// TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
|
// TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
|
||||||
@@ -80,7 +56,7 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
contentHandler(content)
|
contentHandler(content)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard should_display_notification(state: state, event: nostr_event, mode: .push) else {
|
guard should_display_notification(state: state, event: nostr_event, mode: .push) else {
|
||||||
Log.debug("should_display_notification failed", for: .push_notifications)
|
Log.debug("should_display_notification failed", for: .push_notifications)
|
||||||
// We should not display notification for this event. Suppress notification.
|
// We should not display notification for this event. Suppress notification.
|
||||||
@@ -89,7 +65,7 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
contentHandler(request.content)
|
contentHandler(request.content)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else {
|
guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else {
|
||||||
Log.debug("generate_local_notification_object failed", for: .push_notifications)
|
Log.debug("generate_local_notification_object failed", for: .push_notifications)
|
||||||
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
|
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
|
||||||
@@ -98,58 +74,15 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
contentHandler(request.content)
|
contentHandler(request.content)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
let sender_dn = DisplayName(name: sender_profile.name, display_name: sender_profile.display_name, pubkey: sender_pubkey)
|
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: name, notify: notification_object, state: state) else {
|
||||||
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: sender_dn.displayName, notify: notification_object, state: state) else {
|
|
||||||
|
|
||||||
Log.debug("NotificationFormatter.format_message failed", for: .push_notifications)
|
Log.debug("NotificationFormatter.format_message failed", for: .push_notifications)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
contentHandler(improvedContent)
|
||||||
var options: [AnyHashable: Any] = [:]
|
|
||||||
if let imageSource = CGImageSourceCreateWithURL(sender_profile.picture as CFURL, nil),
|
|
||||||
let uti = CGImageSourceGetType(imageSource) {
|
|
||||||
options[UNNotificationAttachmentOptionsTypeHintKey] = uti
|
|
||||||
}
|
|
||||||
|
|
||||||
let attachment = try UNNotificationAttachment(identifier: sender_profile.picture.absoluteString, url: sender_profile.picture, options: options)
|
|
||||||
improvedContent.attachments = [attachment]
|
|
||||||
} catch {
|
|
||||||
Log.error("failed to get notification attachment: %s", for: .push_notifications, error.localizedDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
let kind = nostr_event.known_kind
|
|
||||||
|
|
||||||
// these aren't supported yet
|
|
||||||
if !(kind == .text || kind == .dm) {
|
|
||||||
contentHandler(improvedContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// rich communication notifications for kind1, dms, etc
|
|
||||||
|
|
||||||
let message_intent = await message_intent_from_note(ndb: state.ndb,
|
|
||||||
sender_profile: sender_profile,
|
|
||||||
content: improvedContent.body,
|
|
||||||
note: nostr_event,
|
|
||||||
our_pubkey: state.keypair.pubkey)
|
|
||||||
|
|
||||||
improvedContent.threadIdentifier = nostr_event.thread_id().hex()
|
|
||||||
improvedContent.categoryIdentifier = "COMMUNICATION"
|
|
||||||
|
|
||||||
let interaction = INInteraction(intent: message_intent, response: nil)
|
|
||||||
interaction.direction = .incoming
|
|
||||||
do {
|
|
||||||
try await interaction.donate()
|
|
||||||
let updated = try improvedContent.updating(from: message_intent)
|
|
||||||
contentHandler(updated)
|
|
||||||
} catch {
|
|
||||||
Log.error("failed to donate interaction: %s", for: .push_notifications, error.localizedDescription)
|
|
||||||
contentHandler(improvedContent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,162 +95,3 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ProfileBuf {
|
|
||||||
let picture: URL
|
|
||||||
let name: String?
|
|
||||||
let display_name: String?
|
|
||||||
let nip05: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: String, note: NdbNote, our_pubkey: Pubkey) async -> INSendMessageIntent {
|
|
||||||
let sender_pk = note.pubkey
|
|
||||||
let sender = await profile_to_inperson(name: sender_profile.name,
|
|
||||||
display_name: sender_profile.display_name,
|
|
||||||
picture: sender_profile.picture.absoluteString,
|
|
||||||
nip05: sender_profile.nip05,
|
|
||||||
pubkey: sender_pk,
|
|
||||||
our_pubkey: our_pubkey)
|
|
||||||
|
|
||||||
let conversationIdentifier = note.thread_id().hex()
|
|
||||||
var recipients: [INPerson] = []
|
|
||||||
var pks: [Pubkey] = []
|
|
||||||
let meta = INSendMessageIntentDonationMetadata()
|
|
||||||
|
|
||||||
// gather recipients
|
|
||||||
if let recipient_note_id = note.direct_replies() {
|
|
||||||
let replying_to = ndb.lookup_note(recipient_note_id)
|
|
||||||
if let replying_to_pk = replying_to?.unsafeUnownedValue?.pubkey {
|
|
||||||
meta.isReplyToCurrentUser = replying_to_pk == our_pubkey
|
|
||||||
|
|
||||||
if replying_to_pk != sender_pk {
|
|
||||||
// we push the actual person being replied to first
|
|
||||||
pks.append(replying_to_pk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let pubkeys = Array(note.referenced_pubkeys)
|
|
||||||
meta.recipientCount = pubkeys.count
|
|
||||||
if pubkeys.contains(sender_pk) {
|
|
||||||
meta.recipientCount -= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
for pk in pubkeys.prefix(3) {
|
|
||||||
if pk == sender_pk || pks.contains(pk) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !meta.isReplyToCurrentUser && pk == our_pubkey {
|
|
||||||
meta.mentionsCurrentUser = true
|
|
||||||
}
|
|
||||||
|
|
||||||
pks.append(pk)
|
|
||||||
}
|
|
||||||
|
|
||||||
for pk in pks {
|
|
||||||
let recipient = await pubkey_to_inperson(ndb: ndb, pubkey: pk, our_pubkey: our_pubkey)
|
|
||||||
recipients.append(recipient)
|
|
||||||
}
|
|
||||||
|
|
||||||
// we enable default formatting this way
|
|
||||||
var groupName = INSpeakableString(spokenPhrase: "")
|
|
||||||
|
|
||||||
// otherwise we just say its a DM
|
|
||||||
if note.known_kind == .dm {
|
|
||||||
groupName = INSpeakableString(spokenPhrase: "DM")
|
|
||||||
}
|
|
||||||
|
|
||||||
let intent = INSendMessageIntent(recipients: recipients,
|
|
||||||
outgoingMessageType: .outgoingMessageText,
|
|
||||||
content: content,
|
|
||||||
speakableGroupName: groupName,
|
|
||||||
conversationIdentifier: conversationIdentifier,
|
|
||||||
serviceName: "kind\(note.kind)",
|
|
||||||
sender: sender,
|
|
||||||
attachments: nil)
|
|
||||||
intent.donationMetadata = meta
|
|
||||||
|
|
||||||
// this is needed for recipients > 0
|
|
||||||
if let img = sender.image {
|
|
||||||
intent.setImage(img, forParameterNamed: \.speakableGroupName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return intent
|
|
||||||
}
|
|
||||||
|
|
||||||
func pubkey_to_inperson(ndb: Ndb, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson {
|
|
||||||
let profile_txn = ndb.lookup_profile(pubkey)
|
|
||||||
let profile = profile_txn?.unsafeUnownedValue?.profile
|
|
||||||
let name = profile?.name
|
|
||||||
let display_name = profile?.display_name
|
|
||||||
let nip05 = profile?.nip05
|
|
||||||
let picture = profile?.picture
|
|
||||||
|
|
||||||
return await profile_to_inperson(name: name,
|
|
||||||
display_name: display_name,
|
|
||||||
picture: picture,
|
|
||||||
nip05: nip05,
|
|
||||||
pubkey: pubkey,
|
|
||||||
our_pubkey: our_pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetch_pfp(picture: URL) async throws -> RetrieveImageResult {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: picture)) { result in
|
|
||||||
switch result {
|
|
||||||
case .success(let img):
|
|
||||||
continuation.resume(returning: img)
|
|
||||||
case .failure(let error):
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func profile_to_inperson(name: String?, display_name: String?, picture: String?, nip05: String?, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson {
|
|
||||||
let npub = pubkey.npub
|
|
||||||
let handle = INPersonHandle(value: npub, type: .unknown)
|
|
||||||
var aliases: [INPersonHandle] = []
|
|
||||||
|
|
||||||
if let nip05 {
|
|
||||||
aliases.append(INPersonHandle(value: nip05, type: .emailAddress))
|
|
||||||
}
|
|
||||||
|
|
||||||
let nostrName = DisplayName(name: name, display_name: display_name, pubkey: pubkey)
|
|
||||||
let nameComponents = nostrName.nameComponents()
|
|
||||||
let displayName = nostrName.displayName
|
|
||||||
let contactIdentifier = npub
|
|
||||||
let customIdentifier = npub
|
|
||||||
let suggestionType = INPersonSuggestionType.socialProfile
|
|
||||||
|
|
||||||
var image: INImage? = nil
|
|
||||||
|
|
||||||
if let picture,
|
|
||||||
let url = URL(string: picture),
|
|
||||||
let img = try? await fetch_pfp(picture: url),
|
|
||||||
let imgdata = img.data()
|
|
||||||
{
|
|
||||||
image = INImage(imageData: imgdata)
|
|
||||||
} else {
|
|
||||||
Log.error("Failed to fetch pfp (%s) for %s", for: .push_notifications, picture ?? "nil", displayName)
|
|
||||||
}
|
|
||||||
|
|
||||||
let person = INPerson(personHandle: handle,
|
|
||||||
nameComponents: nameComponents,
|
|
||||||
displayName: displayName,
|
|
||||||
image: image,
|
|
||||||
contactIdentifier: contactIdentifier,
|
|
||||||
customIdentifier: customIdentifier,
|
|
||||||
isMe: pubkey == our_pubkey,
|
|
||||||
suggestionType: suggestionType
|
|
||||||
)
|
|
||||||
|
|
||||||
return person
|
|
||||||
}
|
|
||||||
|
|
||||||
func robohash(_ pk: Pubkey) -> String {
|
|
||||||
return "https://robohash.org/" + pk.hex()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,3 @@
|
|||||||
// swift-tools-version: 6.0
|
dependencies: [
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
.Package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main")
|
||||||
|
]
|
||||||
import PackageDescription
|
|
||||||
|
|
||||||
let package = Package(
|
|
||||||
name: "damus",
|
|
||||||
platforms: [
|
|
||||||
.iOS(.v16),
|
|
||||||
.macOS(.v12)
|
|
||||||
],
|
|
||||||
products: [
|
|
||||||
.library(
|
|
||||||
name: "damus",
|
|
||||||
targets: ["damus"]),
|
|
||||||
],
|
|
||||||
dependencies: [
|
|
||||||
.package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main")
|
|
||||||
],
|
|
||||||
targets: [
|
|
||||||
.target(
|
|
||||||
name: "damus",
|
|
||||||
dependencies: [
|
|
||||||
.product(name: "secp256k1", package: "secp256k1.swift")
|
|
||||||
],
|
|
||||||
path: "damus"),
|
|
||||||
.testTarget(
|
|
||||||
name: "damusTests",
|
|
||||||
dependencies: ["damus"],
|
|
||||||
path: "damusTests"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS.
|
|||||||
|
|
||||||
[nostr]: https://github.com/fiatjaf/nostr
|
[nostr]: https://github.com/fiatjaf/nostr
|
||||||
|
|
||||||
## How is Damus better than X/Twitter?
|
## How is Damus better than twitter?
|
||||||
There are no toxic algorithms.\
|
There are no toxic algorithms.\
|
||||||
You can send or receive zaps (satoshis) without asking for permission.\
|
You can send or receive zaps (satoshis) without asking for permission.\
|
||||||
[There is no central database](https://fiatjaf.com/nostr.html). Therefore, Damus is censorship resistant.\
|
[There is no central database](https://fiatjaf.com/nostr.html). Therefore, Damus is censorship resistant.\
|
||||||
|
|||||||
@@ -1,22 +1,6 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "085cf0f645323bf77edb52886489bf77b309a0a2d2b78a54beaf8520b540d596",
|
"originHash" : "babaf4d5748afecf49bbb702530d8e9576460692f478b0a50ee43195dd4440e2",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
|
||||||
"identity" : "codescanner",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/twostraws/CodeScanner.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "9fa582f4b36c69c2a55bff5fb3377eb170ae273c"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "cryptoswift",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/krzyzanowskim/CryptoSwift.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "e74bbbfbef939224b242ae7c342a90e60b88b5ce"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "emojikit",
|
"identity" : "emojikit",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -105,20 +89,13 @@
|
|||||||
"version" : "0.1.2"
|
"version" : "0.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "swiftycrop",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/benedom/SwiftyCrop",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "454d0a0d4faf6f3a19c8d817ab9d7d27524bd79f"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "swipeactions",
|
"identity" : "swipeactions",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/damus-io/SwipeActions.git",
|
"location" : "https://github.com/aheze/SwipeActions",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "33d99756c3112e1a07c1732e3cddc5ad5bd0c5f4"
|
"revision" : "41e6f6dce02d8cfa164f8c5461a41340850ca3ab",
|
||||||
|
"version" : "1.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
<TestableReference
|
<TestableReference
|
||||||
skipped = "NO">
|
skipped = "YES">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "4CE6DEFC27F7A08200C66700"
|
BlueprintIdentifier = "4CE6DEFC27F7A08200C66700"
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "alby.svg",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "alby.svg",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "alby.svg",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 72 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "coinos.png",
|
"filename" : "profile-banner.jpeg",
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
BIN
damus/Assets.xcassets/Profile/profile-banner.imageset/profile-banner.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "alby-go.png",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
damus/Assets.xcassets/alby-go.imageset/alby-go.png
vendored
|
Before Width: | Height: | Size: 40 KiB |
23
damus/Assets.xcassets/alby.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "alby.svg",
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "2x",
|
||||||
|
"filename": "alby.svg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "3x",
|
||||||
|
"filename": "alby.svg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 511 KiB After Width: | Height: | Size: 511 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 547 KiB After Width: | Height: | Size: 547 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -46,6 +46,7 @@ struct CustomPicker<SelectionValue: Hashable>: View {
|
|||||||
.accentColor(tag == selection ? textColor() : .gray)
|
.accentColor(tag == selection ? textColor() : .gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.background(Color(UIColor.systemBackground))
|
||||||
}
|
}
|
||||||
|
|
||||||
func textColor() -> Color {
|
func textColor() -> Color {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ struct DamusBackground: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.frame(maxWidth: .infinity, maxHeight: maxHeight, alignment: .center)
|
.frame(maxWidth: .infinity, maxHeight: maxHeight, alignment: .center)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
damus/Components/Gradients/MutinyGradient.swift
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// MutinyGradient.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 3/9/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
fileprivate let mutiny_grad_c1 = hex_col(r: 39, g: 95, b: 161)
|
||||||
|
fileprivate let mutiny_grad_c2 = hex_col(r: 13, g: 33, b: 56)
|
||||||
|
fileprivate let mutiny_grad = [mutiny_grad_c2, mutiny_grad_c1]
|
||||||
|
|
||||||
|
let MutinyGradient: LinearGradient =
|
||||||
|
LinearGradient(colors: mutiny_grad, startPoint: .top, endPoint: .bottom)
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Kingfisher
|
import Kingfisher
|
||||||
import Combine
|
|
||||||
|
|
||||||
// TODO: all this ShareSheet complexity can be replaced with ShareLink once we update to iOS 16
|
// TODO: all this ShareSheet complexity can be replaced with ShareLink once we update to iOS 16
|
||||||
struct ShareSheet: UIViewControllerRepresentable {
|
struct ShareSheet: UIViewControllerRepresentable {
|
||||||
@@ -96,203 +95,64 @@ enum ImageShape {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `CarouselModel` helps `ImageCarousel` with some state management logic, keeping track of media sizes, and the ideal display size
|
|
||||||
///
|
|
||||||
/// This model is necessary because the state management logic required to keep track of media sizes for each one of the carousel items,
|
|
||||||
/// and the ideal display size at each moment is not a trivial task.
|
|
||||||
///
|
|
||||||
/// The rules for the media fill are as follows:
|
|
||||||
/// 1. The media item should generally have a width that completely fills the width of its parent view
|
|
||||||
/// 2. The height of the carousel should be adjusted accordingly
|
|
||||||
/// 3. The only exception to rules 1 and 2 is when the total height would be 20% larger than the height of the device
|
|
||||||
/// 4. If none of the above can be computed (e.g. due to missing information), default to a reasonable height, where the media item will fit into.
|
|
||||||
///
|
|
||||||
/// ## Usage notes
|
|
||||||
///
|
|
||||||
/// The view is has the following state management responsibilities:
|
|
||||||
/// 1. Watching the size of the images (via the `.observe_image_size` modifier)
|
|
||||||
/// 2. Notifying this class of geometry reader changes, by setting `geo_size`
|
|
||||||
///
|
|
||||||
/// ## Implementation notes
|
|
||||||
///
|
|
||||||
/// This class is organized in a way to reduce stateful behavior and the transiency bugs it can cause.
|
|
||||||
///
|
|
||||||
/// This is accomplished through the following pattern:
|
|
||||||
/// 1. The `current_item_fill` is a published property so that any updates instantly re-render the view
|
|
||||||
/// 2. However, `current_item_fill` has a mathematical dependency on other members of this class
|
|
||||||
/// 3. Therefore, the members on which the fill property depends on all have `didSet` observers that will cause the `current_item_fill` to be recalculated and published.
|
|
||||||
///
|
|
||||||
/// This pattern helps ensure that the state is always consistent and that the view is always up-to-date.
|
|
||||||
///
|
|
||||||
/// This class is marked as `@MainActor` since most of its properties are published and should be accessed from the main thread to avoid inconsistent SwiftUI state during renders
|
|
||||||
@MainActor
|
|
||||||
class CarouselModel: ObservableObject {
|
class CarouselModel: ObservableObject {
|
||||||
// MARK: Immutable object attributes
|
var current_url: URL?
|
||||||
// These are some attributes that are not expected to change throughout the lifecycle of this object
|
var fillHeight: CGFloat
|
||||||
// These should not be modified after initialization to avoid state inconsistency
|
var maxHeight: CGFloat
|
||||||
|
var firstImageHeight: CGFloat?
|
||||||
/// The state of the app
|
|
||||||
let damus_state: DamusState
|
|
||||||
/// All urls in the carousel
|
|
||||||
let urls: [MediaUrl]
|
|
||||||
/// The default fill height for the carousel, if we cannot calculate a more appropriate height
|
|
||||||
/// **Usage note:** Default to this when `current_item_fill` is nil
|
|
||||||
let default_fill_height: CGFloat
|
|
||||||
/// The maximum height for any carousel item
|
|
||||||
let max_height: CGFloat
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: Miscellaneous
|
|
||||||
|
|
||||||
/// Holds items that allows us to cancel video size observers during de-initialization
|
|
||||||
private var all_cancellables: [AnyCancellable] = []
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: State management properties
|
|
||||||
/// Properties relevant to state management.
|
|
||||||
/// These should be made into computed/functional properties when possible to avoid stateful behavior
|
|
||||||
/// When that is not possible (e.g. when dealing with an observed published property), establish its mathematical dependencies,
|
|
||||||
/// and use `didSet` observers to ensure that the state is always re-computed when necessary.
|
|
||||||
|
|
||||||
/// Stores information about the size of each media item in `urls`.
|
@Published var open_sheet: Bool
|
||||||
/// **Usage note:** The view is responsible for setting the size of image urls
|
@Published var selectedIndex: Int
|
||||||
var media_size_information: [URL: CGSize] {
|
@Published var video_size: CGSize?
|
||||||
didSet {
|
@Published var image_fill: ImageFill?
|
||||||
guard let current_url else { return }
|
|
||||||
// Upon updating information, update the carousel fill size if the size for the current url has changed
|
|
||||||
if oldValue[current_url] != media_size_information[current_url] {
|
|
||||||
self.refresh_current_item_fill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// Stores information about the geometry reader
|
|
||||||
/// **Usage note:** The view is responsible for setting this value
|
|
||||||
var geo_size: CGSize? {
|
|
||||||
didSet { self.refresh_current_item_fill() }
|
|
||||||
}
|
|
||||||
/// The index of the currently selected item
|
|
||||||
/// **Usage note:** The view is responsible for setting this value
|
|
||||||
@Published var selectedIndex: Int {
|
|
||||||
didSet { self.refresh_current_item_fill() }
|
|
||||||
}
|
|
||||||
/// The current fill for the media item.
|
|
||||||
/// **Usage note:** This property is read-only and should not be set directly. Update `selectedIndex` to update the current item being viewed.
|
|
||||||
var current_url: URL? {
|
|
||||||
return urls[safe: selectedIndex]?.url
|
|
||||||
}
|
|
||||||
/// Holds the ideal fill dimensions for the current item.
|
|
||||||
/// **Usage note:** This property is automatically updated when other properties are set, and should not be set directly
|
|
||||||
/// **Implementation note:** This property is mathematically dependent on geo_size, media_size_information, and `selectedIndex`,
|
|
||||||
/// and is automatically updated upon changes to these properties.
|
|
||||||
@Published private(set) var current_item_fill: ImageFill?
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: Initialization and de-initialization
|
|
||||||
|
|
||||||
/// Initializes the `CarouselModel` with the given `DamusState` and `MediaUrl` array
|
init(image_fill: ImageFill?) {
|
||||||
init(damus_state: DamusState, urls: [MediaUrl]) {
|
self.current_url = nil
|
||||||
// Immutable object attributes
|
self.fillHeight = 350
|
||||||
self.damus_state = damus_state
|
self.maxHeight = UIScreen.main.bounds.height * 1.2 // 1.2
|
||||||
self.urls = urls
|
self.firstImageHeight = nil
|
||||||
self.default_fill_height = 350
|
self.open_sheet = false
|
||||||
self.max_height = UIScreen.main.bounds.height * 1.2 // 1.2
|
|
||||||
|
|
||||||
// State management properties
|
|
||||||
self.selectedIndex = 0
|
self.selectedIndex = 0
|
||||||
self.current_item_fill = nil
|
self.video_size = nil
|
||||||
self.geo_size = nil
|
self.image_fill = image_fill
|
||||||
self.media_size_information = [:]
|
|
||||||
|
|
||||||
// Setup the rest of the state management logic
|
|
||||||
self.observe_video_sizes()
|
|
||||||
Task {
|
|
||||||
self.refresh_current_item_fill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This private function observes the video sizes for all videos
|
|
||||||
private func observe_video_sizes() {
|
|
||||||
for media_url in urls {
|
|
||||||
switch media_url {
|
|
||||||
case .video(let url):
|
|
||||||
let video_player = damus_state.video.get_player(for: url)
|
|
||||||
if let video_size = video_player.video_size {
|
|
||||||
self.media_size_information[url] = video_size // Set the initial size if available
|
|
||||||
}
|
|
||||||
let observer_cancellable = video_player.$video_size.sink(receiveValue: { new_size in
|
|
||||||
self.media_size_information[url] = new_size // Update the size when it changes
|
|
||||||
})
|
|
||||||
all_cancellables.append(observer_cancellable) // Store the cancellable to cancel it later
|
|
||||||
case .image(_):
|
|
||||||
break; // Observing an image size needs to be done on the view directly, through the `.observe_image_size` modifier
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
for cancellable_item in all_cancellables {
|
|
||||||
cancellable_item.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: State management and logic
|
|
||||||
|
|
||||||
/// This function refreshes the current item fill based on the current state of the model
|
|
||||||
/// **Usage note:** This is private, do not call this directly from outside the class.
|
|
||||||
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the fill
|
|
||||||
private func refresh_current_item_fill() {
|
|
||||||
if let current_url,
|
|
||||||
let item_size = self.media_size_information[current_url],
|
|
||||||
let geo_size {
|
|
||||||
self.current_item_fill = ImageFill.calculate_image_fill(
|
|
||||||
geo_size: geo_size,
|
|
||||||
img_size: item_size,
|
|
||||||
maxHeight: self.max_height,
|
|
||||||
fillHeight: self.default_fill_height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
self.current_item_fill = nil // Not enough information to compute the proper fill. Default to nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Image Carousel
|
// MARK: - Image Carousel
|
||||||
|
|
||||||
/// A carousel that displays images and videos
|
|
||||||
///
|
|
||||||
/// ## Implementation notes
|
|
||||||
///
|
|
||||||
/// - State management logic is mostly handled by `CarouselModel`, as it is complex, and becomes difficult to manage in a view
|
|
||||||
///
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ImageCarousel<Content: View>: View {
|
struct ImageCarousel<Content: View>: View {
|
||||||
/// The event id of the note that this carousel is displaying
|
var urls: [MediaUrl]
|
||||||
|
|
||||||
let evid: NoteId
|
let evid: NoteId
|
||||||
/// The model that holds information and state of this carousel
|
|
||||||
/// This is observed to update the view when the model changes
|
let state: DamusState
|
||||||
@ObservedObject var model: CarouselModel
|
@ObservedObject var model: CarouselModel
|
||||||
let content: ((_ dismiss: @escaping (() -> Void)) -> Content)?
|
let content: ((_ dismiss: @escaping (() -> Void)) -> Content)?
|
||||||
|
|
||||||
init(state: DamusState, evid: NoteId, urls: [MediaUrl]) {
|
init(state: DamusState, evid: NoteId, urls: [MediaUrl]) {
|
||||||
|
self.urls = urls
|
||||||
self.evid = evid
|
self.evid = evid
|
||||||
self._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls))
|
self.state = state
|
||||||
|
let media_model = state.events.get_cache_data(evid).media_metadata_model
|
||||||
|
self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill))
|
||||||
self.content = nil
|
self.content = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) {
|
init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) {
|
||||||
|
self.urls = urls
|
||||||
self.evid = evid
|
self.evid = evid
|
||||||
self._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls))
|
self.state = state
|
||||||
|
let media_model = state.events.get_cache_data(evid).media_metadata_model
|
||||||
|
self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill))
|
||||||
self.content = content
|
self.content = content
|
||||||
}
|
}
|
||||||
|
|
||||||
var filling: Bool {
|
var filling: Bool {
|
||||||
model.current_item_fill?.filling == true
|
model.image_fill?.filling == true
|
||||||
}
|
}
|
||||||
|
|
||||||
var height: CGFloat {
|
var height: CGFloat {
|
||||||
// Use the calculated fill height if available, otherwise use the default fill height
|
model.firstImageHeight ?? model.image_fill?.height ?? model.fillHeight
|
||||||
model.current_item_fill?.height ?? model.default_fill_height
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
|
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
|
||||||
@@ -300,7 +160,7 @@ struct ImageCarousel<Content: View>: View {
|
|||||||
if num_urls > 1 {
|
if num_urls > 1 {
|
||||||
// jb55: quick hack since carousel with multiple images looks horrible with blurhash background
|
// jb55: quick hack since carousel with multiple images looks horrible with blurhash background
|
||||||
Color.clear
|
Color.clear
|
||||||
} else if let meta = model.damus_state.events.lookup_img_metadata(url: url),
|
} else if let meta = state.events.lookup_img_metadata(url: url),
|
||||||
case .processed(let blurhash) = meta.state {
|
case .processed(let blurhash) = meta.state {
|
||||||
Image(uiImage: blurhash)
|
Image(uiImage: blurhash)
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -309,6 +169,12 @@ struct ImageCarousel<Content: View>: View {
|
|||||||
Color.clear
|
Color.clear
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
if self.model.image_fill == nil, let size = state.video.size_for_url(url) {
|
||||||
|
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: size, maxHeight: model.maxHeight, fillHeight: model.fillHeight)
|
||||||
|
self.model.image_fill = fill
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Media(geo: GeometryProxy, url: MediaUrl, index: Int) -> some View {
|
func Media(geo: GeometryProxy, url: MediaUrl, index: Int) -> some View {
|
||||||
@@ -317,17 +183,24 @@ struct ImageCarousel<Content: View>: View {
|
|||||||
case .image(let url):
|
case .image(let url):
|
||||||
Img(geo: geo, url: url, index: index)
|
Img(geo: geo, url: url, index: index)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex))
|
model.open_sheet = true
|
||||||
}
|
}
|
||||||
case .video(let url):
|
case .video(let url):
|
||||||
let video_model = model.damus_state.video.get_player(for: url)
|
DamusVideoPlayer(url: url, video_size: $model.video_size, controller: state.video, style: .preview(on_tap: { model.open_sheet = true }))
|
||||||
DamusVideoPlayerView(
|
.onChange(of: model.video_size) { size in
|
||||||
model: video_model,
|
guard let size else { return }
|
||||||
coordinator: model.damus_state.video,
|
|
||||||
style: .preview(on_tap: {
|
let fill = ImageFill.calculate_image_fill(geo_size: geo.size, img_size: size, maxHeight: model.maxHeight, fillHeight: model.fillHeight)
|
||||||
present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex))
|
|
||||||
})
|
print("video_size changed \(size)")
|
||||||
)
|
if self.model.image_fill == nil {
|
||||||
|
print("video_size firstImageHeight \(fill.height)")
|
||||||
|
self.model.firstImageHeight = fill.height
|
||||||
|
state.events.get_cache_data(evid).media_metadata_model.fill = fill
|
||||||
|
}
|
||||||
|
|
||||||
|
self.model.image_fill = fill
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,18 +209,31 @@ struct ImageCarousel<Content: View>: View {
|
|||||||
KFAnimatedImage(url)
|
KFAnimatedImage(url)
|
||||||
.callbackQueue(.dispatch(.global(qos:.background)))
|
.callbackQueue(.dispatch(.global(qos:.background)))
|
||||||
.backgroundDecode(true)
|
.backgroundDecode(true)
|
||||||
.imageContext(.note, disable_animation: model.damus_state.settings.disable_animation)
|
.imageContext(.note, disable_animation: state.settings.disable_animation)
|
||||||
.image_fade(duration: 0.25)
|
.image_fade(duration: 0.25)
|
||||||
.cancelOnDisappear(true)
|
.cancelOnDisappear(true)
|
||||||
.configure { view in
|
.configure { view in
|
||||||
view.framePreloadCount = 3
|
view.framePreloadCount = 3
|
||||||
}
|
}
|
||||||
.observe_image_size(size_changed: { size in
|
.imageFill(for: geo.size, max: model.maxHeight, fill: model.fillHeight) { fill in
|
||||||
// Observe the image size to update the model when the size changes, so we can calculate the fill
|
state.events.get_cache_data(evid).media_metadata_model.fill = fill
|
||||||
model.media_size_information[url] = size
|
// blur hash can be discarded when we have the url
|
||||||
})
|
// NOTE: this is the wrong place for this... we need to remove
|
||||||
|
// it when the image is loaded in memory. This may happen
|
||||||
|
// earlier than this (by the preloader, etc)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
|
state.events.lookup_img_metadata(url: url)?.state = .not_needed
|
||||||
|
}
|
||||||
|
self.model.image_fill = fill
|
||||||
|
if index == 0 {
|
||||||
|
self.model.firstImageHeight = fill.height
|
||||||
|
//maxHeight = firstImageHeight ?? maxHeight
|
||||||
|
} else {
|
||||||
|
//maxHeight = firstImageHeight ?? fill.height
|
||||||
|
}
|
||||||
|
}
|
||||||
.background {
|
.background {
|
||||||
Placeholder(url: url, geo_size: geo.size, num_urls: model.urls.count)
|
Placeholder(url: url, geo_size: geo.size, num_urls: urls.count)
|
||||||
}
|
}
|
||||||
.aspectRatio(contentMode: filling ? .fill : .fit)
|
.aspectRatio(contentMode: filling ? .fill : .fit)
|
||||||
.kfClickable()
|
.kfClickable()
|
||||||
@@ -362,19 +248,25 @@ struct ImageCarousel<Content: View>: View {
|
|||||||
|
|
||||||
var Medias: some View {
|
var Medias: some View {
|
||||||
TabView(selection: $model.selectedIndex) {
|
TabView(selection: $model.selectedIndex) {
|
||||||
ForEach(model.urls.indices, id: \.self) { index in
|
ForEach(urls.indices, id: \.self) { index in
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
Media(geo: geo, url: model.urls[index], index: index)
|
Media(geo: geo, url: urls[index], index: index)
|
||||||
.onChange(of: geo.size, perform: { new_size in
|
|
||||||
model.geo_size = new_size
|
|
||||||
})
|
|
||||||
.onAppear {
|
|
||||||
model.geo_size = geo.size
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
|
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
|
||||||
|
.fullScreenCover(isPresented: $model.open_sheet) {
|
||||||
|
if let content {
|
||||||
|
FullScreenCarouselView<Content>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex) {
|
||||||
|
content({ // Dismiss closure
|
||||||
|
model.open_sheet = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
FullScreenCarouselView<AnyView>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
.frame(height: height)
|
.frame(height: height)
|
||||||
.onChange(of: model.selectedIndex) { value in
|
.onChange(of: model.selectedIndex) { value in
|
||||||
model.selectedIndex = value
|
model.selectedIndex = value
|
||||||
@@ -392,8 +284,8 @@ struct ImageCarousel<Content: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if model.urls.count > 1 {
|
if urls.count > 1 {
|
||||||
PageControlView(currentPage: $model.selectedIndex, numberOfPages: model.urls.count)
|
PageControlView(currentPage: $model.selectedIndex, numberOfPages: urls.count)
|
||||||
.frame(maxWidth: 0, maxHeight: 0)
|
.frame(maxWidth: 0, maxHeight: 0)
|
||||||
.padding(.top, 5)
|
.padding(.top, 5)
|
||||||
}
|
}
|
||||||
@@ -401,6 +293,27 @@ struct ImageCarousel<Content: View>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Image Modifier
|
||||||
|
extension KFOptionSetter {
|
||||||
|
/// Sets a block to get image size
|
||||||
|
///
|
||||||
|
/// - Parameter block: The block which is used to read the image object.
|
||||||
|
/// - Returns: `Self` value after read size
|
||||||
|
public func imageFill(for size: CGSize, max: CGFloat, fill: CGFloat, block: @escaping (ImageFill) throws -> Void) -> Self {
|
||||||
|
let modifier = AnyImageModifier { image -> KFCrossPlatformImage in
|
||||||
|
let img_size = image.size
|
||||||
|
let geo_size = size
|
||||||
|
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: img_size, maxHeight: max, fillHeight: fill)
|
||||||
|
DispatchQueue.main.async { [block, fill] in
|
||||||
|
try? block(fill)
|
||||||
|
}
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
options.imageModifier = modifier
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public struct ImageFill {
|
public struct ImageFill {
|
||||||
let filling: Bool?
|
let filling: Bool?
|
||||||
@@ -437,3 +350,4 @@ struct ImageCarousel_Previews: PreviewProvider {
|
|||||||
.environmentObject(OrientationTracker())
|
.environmentObject(OrientationTracker())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
|
|||||||
flusher = .once({ pe in
|
flusher = .once({ pe in
|
||||||
// send donation zap when the pending zap is flushed, this allows user to cancel and not send a donation
|
// send donation zap when the pending zap is flushed, this allows user to cancel and not send a donation
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
await WalletConnect.send_donation_zap(pool: damus_state.pool, postbox: damus_state.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat)
|
await send_donation_zap(pool: damus_state.pool, postbox: damus_state.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -240,7 +240,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
|
|||||||
// we don't have a delay on one-tap nozaps (since this will be from customize zap view)
|
// we don't have a delay on one-tap nozaps (since this will be from customize zap view)
|
||||||
let delay = damus_state.settings.nozaps ? nil : 5.0
|
let delay = damus_state.settings.nozaps ? nil : 5.0
|
||||||
|
|
||||||
let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, delay: delay, on_flush: flusher)
|
let nwc_req = nwc_pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, delay: delay, on_flush: flusher)
|
||||||
|
|
||||||
guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else {
|
guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else {
|
||||||
print("nwc: failed to send nwc request for zapreq \(reqid.reqid)")
|
print("nwc: failed to send nwc request for zapreq \(reqid.reqid)")
|
||||||
|
|||||||
@@ -10,68 +10,22 @@ import SwiftUI
|
|||||||
struct Reposted: View {
|
struct Reposted: View {
|
||||||
let damus: DamusState
|
let damus: DamusState
|
||||||
let pubkey: Pubkey
|
let pubkey: Pubkey
|
||||||
let target: NostrEvent
|
|
||||||
@State var reposts: Int
|
|
||||||
|
|
||||||
init(damus: DamusState, pubkey: Pubkey, target: NostrEvent) {
|
|
||||||
self.damus = damus
|
|
||||||
self.pubkey = pubkey
|
|
||||||
self.target = target
|
|
||||||
self.reposts = damus.boosts.counts[target.id] ?? 1
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
Image("repost")
|
Image("repost")
|
||||||
.foregroundColor(Color.gray)
|
.foregroundColor(Color.gray)
|
||||||
|
ProfileName(pubkey: pubkey, damus: damus, show_nip5_domain: false)
|
||||||
// Show profile picture of the reposter only if the reposter is not the author of the reposted note.
|
.foregroundColor(Color.gray)
|
||||||
if pubkey != target.pubkey {
|
Text("Reposted", comment: "Text indicating that the note was reposted (i.e. re-shared).")
|
||||||
ProfilePicView(pubkey: pubkey, size: eventview_pfp_size(.small), highlight: .none, profiles: damus.profiles, disable_animation: damus.settings.disable_animation)
|
.foregroundColor(Color.gray)
|
||||||
.onTapGesture {
|
|
||||||
show_profile_action_sheet_if_enabled(damus_state: damus, pubkey: pubkey)
|
|
||||||
}
|
|
||||||
.onLongPressGesture(minimumDuration: 0.1) {
|
|
||||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
||||||
damus.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink(value: Route.Reposts(reposts: .reposts(state: damus, target: target.id))) {
|
|
||||||
Text(people_reposted_text(profiles: damus.profiles, pubkey: pubkey, reposts: reposts))
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onReceive(handle_notify(.update_stats), perform: { note_id in
|
|
||||||
guard note_id == target.id else { return }
|
|
||||||
let repost_count = damus.boosts.counts[target.id]
|
|
||||||
if let repost_count, reposts != repost_count {
|
|
||||||
reposts = repost_count
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func people_reposted_text(profiles: Profiles, pubkey: Pubkey, reposts: Int, locale: Locale = Locale.current) -> String {
|
|
||||||
guard reposts > 0 else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
let bundle = bundleForLocale(locale: locale)
|
|
||||||
let other_reposts = reposts - 1
|
|
||||||
let display_name = event_author_name(profiles: profiles, pubkey: pubkey)
|
|
||||||
|
|
||||||
if other_reposts == 0 {
|
|
||||||
return String(format: NSLocalizedString("%@ reposted", bundle: bundle, comment: "Text indicating that the note was reposted (i.e. re-shared)."), locale: locale, display_name)
|
|
||||||
} else {
|
|
||||||
return String(format: localizedStringFormat(key: "people_reposted_count", locale: locale), locale: locale, other_reposts, display_name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Reposted_Previews: PreviewProvider {
|
struct Reposted_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let test_state = test_damus_state
|
let test_state = test_damus_state
|
||||||
Reposted(damus: test_state, pubkey: test_state.pubkey, target: test_note)
|
Reposted(damus: test_state, pubkey: test_state.pubkey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ struct SelectableText: View {
|
|||||||
})) {
|
})) {
|
||||||
if let event, case .show_highlight_post_view(let highlighted_text) = self.selectedTextActionState {
|
if let event, case .show_highlight_post_view(let highlighted_text) = self.selectedTextActionState {
|
||||||
PostView(
|
PostView(
|
||||||
action: .highlighting(.init(selected_text: highlighted_text, source: .event(event.id))),
|
action: .highlighting(.init(selected_text: highlighted_text, source: .event(event))),
|
||||||
damus_state: damus_state
|
damus_state: damus_state
|
||||||
)
|
)
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
|
|||||||
@@ -12,14 +12,6 @@ struct SupporterBadge: View {
|
|||||||
let purple_account: DamusPurple.Account?
|
let purple_account: DamusPurple.Account?
|
||||||
let style: Style
|
let style: Style
|
||||||
let text_color: Color
|
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) {
|
init(percent: Int?, purple_account: DamusPurple.Account? = nil, style: Style, text_color: Color = .secondary) {
|
||||||
self.percent = percent
|
self.percent = percent
|
||||||
@@ -34,18 +26,13 @@ struct SupporterBadge: View {
|
|||||||
HStack {
|
HStack {
|
||||||
if let purple_account, purple_account.active == true {
|
if let purple_account, purple_account.active == true {
|
||||||
HStack(spacing: 1) {
|
HStack(spacing: 1) {
|
||||||
switch self.badge_variant {
|
Image("star.fill")
|
||||||
case .normal:
|
.resizable()
|
||||||
StarShape()
|
.frame(width:size, height:size)
|
||||||
.frame(width:size, height:size)
|
.foregroundStyle(GoldGradient)
|
||||||
.foregroundStyle(GoldGradient)
|
if self.style == .full {
|
||||||
case .oneYearSpecial:
|
let date = format_date(date: purple_account.created_at, time_style: .none)
|
||||||
DoubleStar(size: size)
|
Text(date)
|
||||||
}
|
|
||||||
|
|
||||||
if self.style == .full,
|
|
||||||
let ordinal = self.purple_account?.ordinal() {
|
|
||||||
Text(ordinal)
|
|
||||||
.foregroundStyle(text_color)
|
.foregroundStyle(text_color)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
@@ -69,102 +56,8 @@ struct SupporterBadge: View {
|
|||||||
case full // Shows the entire badge with a purple subscriber number if present
|
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)
|
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 {
|
func support_level_color(_ percent: Int) -> Color {
|
||||||
if percent == 0 {
|
if percent == 0 {
|
||||||
return .gray
|
return .gray
|
||||||
@@ -193,7 +86,7 @@ struct SupporterBadge_Previews: PreviewProvider {
|
|||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
SupporterBadge(
|
SupporterBadge(
|
||||||
percent: nil,
|
percent: nil,
|
||||||
purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: subscriber_number, active: true, attributes: []),
|
purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: subscriber_number, active: true),
|
||||||
style: .full
|
style: .full
|
||||||
)
|
)
|
||||||
.frame(width: 100)
|
.frame(width: 100)
|
||||||
@@ -225,52 +118,4 @@ 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(verbatim: "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(verbatim: "Double star (fallback for iOS 16 and below)")
|
|
||||||
|
|
||||||
HStack(alignment: .center) {
|
|
||||||
DoubleStar.Fallback(size: 17, starOffset: 5)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(verbatim: "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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ enum Sheets: Identifiable {
|
|||||||
case onboardingSuggestions
|
case onboardingSuggestions
|
||||||
case purple(DamusPurpleURL)
|
case purple(DamusPurpleURL)
|
||||||
case purple_onboarding
|
case purple_onboarding
|
||||||
case error(ErrorView.UserPresentableError)
|
|
||||||
|
|
||||||
static func zap(target: ZapTarget, lnurl: String) -> Sheets {
|
static func zap(target: ZapTarget, lnurl: String) -> Sheets {
|
||||||
return .zap(ZapSheet(target: target, lnurl: lnurl))
|
return .zap(ZapSheet(target: target, lnurl: lnurl))
|
||||||
}
|
}
|
||||||
@@ -54,42 +53,6 @@ enum Sheets: Identifiable {
|
|||||||
case .onboardingSuggestions: return "onboarding-suggestions"
|
case .onboardingSuggestions: return "onboarding-suggestions"
|
||||||
case .purple(let purple_url): return "purple" + purple_url.url_string()
|
case .purple(let purple_url): return "purple" + purple_url.url_string()
|
||||||
case .purple_onboarding: return "purple_onboarding"
|
case .purple_onboarding: return "purple_onboarding"
|
||||||
case .error(_): return "error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An item to be presented full screen in a mechanism that is more robust for timeline views.
|
|
||||||
///
|
|
||||||
/// ## Implementation notes
|
|
||||||
///
|
|
||||||
/// This is part of the `present(full_screen_item: FullScreenItem)` interface that allows views in a timeline to show something full-screen without the lazy stack issues
|
|
||||||
/// Full screen cover modifiers are not suitable in those cases because device orientation changes or programmatic scroll commands will cause the view to be unloaded along with the cover,
|
|
||||||
/// causing the user to lose the full screen view randomly.
|
|
||||||
///
|
|
||||||
/// The `ContentView` is responsible for handling these objects
|
|
||||||
///
|
|
||||||
/// New items can be added as needed.
|
|
||||||
///
|
|
||||||
enum FullScreenItem: Identifiable, Equatable {
|
|
||||||
/// A full screen media carousel for images and videos.
|
|
||||||
case full_screen_carousel(urls: [MediaUrl], selectedIndex: Binding<Int>)
|
|
||||||
|
|
||||||
var id: String {
|
|
||||||
switch self {
|
|
||||||
case .full_screen_carousel(let urls, _): return "full_screen_carousel:\(urls.map(\.url))"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func == (lhs: FullScreenItem, rhs: FullScreenItem) -> Bool {
|
|
||||||
return lhs.id == rhs.id
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The view to display the item
|
|
||||||
func view(damus_state: DamusState) -> some View {
|
|
||||||
switch self {
|
|
||||||
case .full_screen_carousel(let urls, let selectedIndex):
|
|
||||||
return FullScreenCarouselView<AnyView>(video_coordinator: damus_state.video, urls: urls, settings: damus_state.settings, selectedIndex: selectedIndex)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,8 +61,6 @@ func present_sheet(_ sheet: Sheets) {
|
|||||||
notify(.present_sheet(sheet))
|
notify(.present_sheet(sheet))
|
||||||
}
|
}
|
||||||
|
|
||||||
var tabHeight: CGFloat = 0.0
|
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
let keypair: Keypair
|
let keypair: Keypair
|
||||||
let appDelegate: AppDelegate?
|
let appDelegate: AppDelegate?
|
||||||
@@ -115,7 +76,6 @@ struct ContentView: View {
|
|||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
|
|
||||||
@State var active_sheet: Sheets? = nil
|
@State var active_sheet: Sheets? = nil
|
||||||
@State var active_full_screen_item: FullScreenItem? = nil
|
|
||||||
@State var damus_state: DamusState!
|
@State var damus_state: DamusState!
|
||||||
@State var menu_subtitle: String? = nil
|
@State var menu_subtitle: String? = nil
|
||||||
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home {
|
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home {
|
||||||
@@ -129,7 +89,6 @@ struct ContentView: View {
|
|||||||
@State var user_muted_confirm: Bool = false
|
@State var user_muted_confirm: Bool = false
|
||||||
@State var confirm_overwrite_mutelist: Bool = false
|
@State var confirm_overwrite_mutelist: Bool = false
|
||||||
@State private var isSideBarOpened = false
|
@State private var isSideBarOpened = false
|
||||||
@State var headerOffset: CGFloat = 0.0
|
|
||||||
var home: HomeModel = HomeModel()
|
var home: HomeModel = HomeModel()
|
||||||
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
|
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
|
||||||
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
|
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
|
||||||
@@ -172,7 +131,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case .home:
|
case .home:
|
||||||
PostingTimelineView(damus_state: damus_state!, home: home, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset)
|
PostingTimelineView(damus_state: damus_state!, home: home, active_sheet: $active_sheet)
|
||||||
|
|
||||||
case .notifications:
|
case .notifications:
|
||||||
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
||||||
@@ -181,16 +140,25 @@ struct ContentView: View {
|
|||||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
|
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(DamusColors.adaptableWhite)
|
|
||||||
.edgesIgnoringSafeArea(selected_timeline != .home ? [] : [.top, .bottom])
|
|
||||||
.navigationBarTitle(timeline_name(selected_timeline), displayMode: .inline)
|
.navigationBarTitle(timeline_name(selected_timeline), displayMode: .inline)
|
||||||
.toolbar(selected_timeline != .home ? .visible : .hidden)
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .principal) {
|
ToolbarItem(placement: .principal) {
|
||||||
VStack {
|
VStack {
|
||||||
timelineNavItem
|
if selected_timeline == .home {
|
||||||
.opacity(isSideBarOpened ? 0 : 1)
|
Image("damus-home")
|
||||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
.resizable()
|
||||||
|
.frame(width:30,height:30)
|
||||||
|
.shadow(color: DamusColors.purple, radius: 2)
|
||||||
|
.opacity(isSideBarOpened ? 0 : 1)
|
||||||
|
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||||
|
.onTapGesture {
|
||||||
|
isSideBarOpened.toggle()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timelineNavItem
|
||||||
|
.opacity(isSideBarOpened ? 0 : 1)
|
||||||
|
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,6 +190,12 @@ struct ContentView: View {
|
|||||||
navigationCoordinator.push(route: Route.Script(script: model))
|
navigationCoordinator.push(route: Route.Script(script: model))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func open_profile(pubkey: Pubkey) {
|
||||||
|
let profile_model = ProfileModel(pubkey: pubkey, damus: damus_state!)
|
||||||
|
let followers = FollowersModel(damus_state: damus_state!, target: pubkey)
|
||||||
|
navigationCoordinator.push(route: Route.Profile(profile: profile_model, followers: followers))
|
||||||
|
}
|
||||||
|
|
||||||
func open_search(filt: NostrFilter) {
|
func open_search(filt: NostrFilter) {
|
||||||
let search = SearchModel(state: damus_state!, search: filt)
|
let search = SearchModel(state: damus_state!, search: filt)
|
||||||
navigationCoordinator.push(route: Route.Search(search: search))
|
navigationCoordinator.push(route: Route.Search(search: search))
|
||||||
@@ -235,7 +209,14 @@ struct ContentView: View {
|
|||||||
MainContent(damus: damus)
|
MainContent(damus: damus)
|
||||||
.toolbar() {
|
.toolbar() {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
TopbarSideMenuButton(damus_state: damus, isSideBarOpened: $isSideBarOpened)
|
Button {
|
||||||
|
isSideBarOpened.toggle()
|
||||||
|
} label: {
|
||||||
|
ProfilePicView(pubkey: damus_state!.pubkey, size: 32, highlight: .none, profiles: damus_state!.profiles, disable_animation: damus_state!.settings.disable_animation)
|
||||||
|
.opacity(isSideBarOpened ? 0 : 1)
|
||||||
|
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||||
|
}
|
||||||
|
.disabled(isSideBarOpened)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
@@ -256,11 +237,9 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(DamusColors.adaptableWhite)
|
|
||||||
.edgesIgnoringSafeArea(selected_timeline != .home ? [] : [.top, .bottom])
|
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
.overlay(
|
.overlay(
|
||||||
SideMenuView(damus_state: damus_state!, isSidebarVisible: $isSideBarOpened.animation(), selected: $selected_timeline)
|
SideMenuView(damus_state: damus_state!, isSidebarVisible: $isSideBarOpened.animation())
|
||||||
)
|
)
|
||||||
.navigationDestination(for: Route.self) { route in
|
.navigationDestination(for: Route.self) { route in
|
||||||
route.view(navigationCoordinator: navigationCoordinator, damusState: damus_state!)
|
route.view(navigationCoordinator: navigationCoordinator, damusState: damus_state!)
|
||||||
@@ -270,28 +249,13 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationViewStyle(.stack)
|
.navigationViewStyle(.stack)
|
||||||
.damus_full_screen_cover($active_full_screen_item, damus_state: damus, content: { item in
|
|
||||||
return item.view(damus_state: damus)
|
if !hide_bar {
|
||||||
})
|
TabBar(nstatus: home.notification_status, selected: $selected_timeline, settings: damus.settings, action: switch_timeline)
|
||||||
.overlay(alignment: .bottom) {
|
.padding([.bottom], 8)
|
||||||
if !hide_bar {
|
.background(Color(uiColor: .systemBackground).ignoresSafeArea())
|
||||||
if !isSideBarOpened {
|
} else {
|
||||||
TabBar(nstatus: home.notification_status, navIsAtRoot: navIsAtRoot(), selected: $selected_timeline, headerOffset: $headerOffset, settings: damus.settings, action: switch_timeline)
|
Text("")
|
||||||
.padding([.bottom], 8)
|
|
||||||
.background(selected_timeline != .home || (selected_timeline == .home && !self.navIsAtRoot()) ? DamusColors.adaptableWhite : DamusColors.adaptableWhite.opacity(abs(1.25 - (abs(headerOffset/100.0)))))
|
|
||||||
.anchorPreference(key: HeaderBoundsKey.self, value: .bounds){$0}
|
|
||||||
.overlayPreferenceValue(HeaderBoundsKey.self) { value in
|
|
||||||
GeometryReader{ proxy in
|
|
||||||
if let anchor = value{
|
|
||||||
Color.clear
|
|
||||||
.onAppear {
|
|
||||||
tabHeight = proxy[anchor].height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,9 +270,6 @@ struct ContentView: View {
|
|||||||
hasSeenOnboardingSuggestions = true
|
hasSeenOnboardingSuggestions = true
|
||||||
}
|
}
|
||||||
self.appDelegate?.state = damus_state
|
self.appDelegate?.state = damus_state
|
||||||
Task { // We probably don't need this to be a detached task. According to https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Defining-and-Calling-Asynchronous-Functions, awaits are only suspension points that do not block the thread.
|
|
||||||
await self.listenAndHandleLocalNotifications()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.sheet(item: $active_sheet) { item in
|
.sheet(item: $active_sheet) { item in
|
||||||
switch item {
|
switch item {
|
||||||
@@ -338,14 +299,36 @@ struct ContentView: View {
|
|||||||
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
|
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
|
||||||
case .purple_onboarding:
|
case .purple_onboarding:
|
||||||
DamusPurpleNewUserOnboardingView(damus_state: damus_state)
|
DamusPurpleNewUserOnboardingView(damus_state: damus_state)
|
||||||
case .error(let error):
|
|
||||||
ErrorView(damus_state: damus_state!, error: error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
Task {
|
on_open_url(state: damus_state!, url: url) { res in
|
||||||
let open_action = await DamusURLHandler.handle_opening_url_and_compute_view_action(damus_state: self.damus_state, url: url)
|
guard let res else {
|
||||||
self.execute_open_action(open_action)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onReceive(handle_notify(.compose)) { action in
|
.onReceive(handle_notify(.compose)) { action in
|
||||||
@@ -367,10 +350,6 @@ struct ContentView: View {
|
|||||||
self.confirm_mute = true
|
self.confirm_mute = true
|
||||||
}
|
}
|
||||||
.onReceive(handle_notify(.attached_wallet)) { nwc in
|
.onReceive(handle_notify(.attached_wallet)) { nwc in
|
||||||
// Ensure to add NWC relay to the pool and connect it.
|
|
||||||
try? damus_state.pool.add_relay(.nwc(url: nwc.relay))
|
|
||||||
damus_state.pool.connect(to: [nwc.relay])
|
|
||||||
|
|
||||||
// update the lightning address on our profile when we attach a
|
// update the lightning address on our profile when we attach a
|
||||||
// wallet with an associated
|
// wallet with an associated
|
||||||
guard let ds = self.damus_state,
|
guard let ds = self.damus_state,
|
||||||
@@ -434,9 +413,6 @@ struct ContentView: View {
|
|||||||
.onReceive(handle_notify(.present_sheet)) { sheet in
|
.onReceive(handle_notify(.present_sheet)) { sheet in
|
||||||
self.active_sheet = sheet
|
self.active_sheet = sheet
|
||||||
}
|
}
|
||||||
.onReceive(handle_notify(.present_full_screen_item)) { item in
|
|
||||||
self.active_full_screen_item = item
|
|
||||||
}
|
|
||||||
.onReceive(handle_notify(.zapping)) { zap_ev in
|
.onReceive(handle_notify(.zapping)) { zap_ev in
|
||||||
guard !zap_ev.is_custom else {
|
guard !zap_ev.is_custom else {
|
||||||
return
|
return
|
||||||
@@ -512,6 +488,27 @@ struct ContentView: View {
|
|||||||
@unknown default:
|
@unknown default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.onReceive(handle_notify(.local_notification)) { local in
|
||||||
|
guard let damus_state else { return }
|
||||||
|
|
||||||
|
switch local.mention {
|
||||||
|
case .pubkey(let pubkey):
|
||||||
|
open_profile(pubkey: pubkey)
|
||||||
|
|
||||||
|
case .note(let noteId):
|
||||||
|
openEvent(noteId: noteId, notificationType: local.type)
|
||||||
|
case .nevent(let nevent):
|
||||||
|
openEvent(noteId: nevent.noteid, notificationType: local.type)
|
||||||
|
case .nprofile(let nprofile):
|
||||||
|
open_profile(pubkey: nprofile.author)
|
||||||
|
case .nrelay(_):
|
||||||
|
break
|
||||||
|
case .naddr(let naddr):
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
.onReceive(handle_notify(.onlyzaps_mode)) { hide in
|
.onReceive(handle_notify(.onlyzaps_mode)) { hide in
|
||||||
home.filter_events()
|
home.filter_events()
|
||||||
@@ -568,7 +565,7 @@ struct ContentView: View {
|
|||||||
}, message: {
|
}, 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.")
|
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/Block User", comment: "Title of alert for muting/blocking a user."), isPresented: $confirm_mute, actions: {
|
.alert(NSLocalizedString("Mute User", comment: "Title of alert for muting a user."), isPresented: $confirm_mute, actions: {
|
||||||
Button(NSLocalizedString("Cancel", comment: "Alert button to cancel out of alert for muting a user."), role: .cancel) {
|
Button(NSLocalizedString("Cancel", comment: "Alert button to cancel out of alert for muting a user."), role: .cancel) {
|
||||||
confirm_mute = false
|
confirm_mute = false
|
||||||
}
|
}
|
||||||
@@ -621,28 +618,6 @@ struct ContentView: View {
|
|||||||
self.selected_timeline = timeline
|
self.selected_timeline = timeline
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Listens to requests to open a push/local user notification
|
|
||||||
///
|
|
||||||
/// This function never returns, it just keeps streaming
|
|
||||||
func listenAndHandleLocalNotifications() async {
|
|
||||||
for await notification in await QueueableNotify<LossyLocalNotification>.shared.stream {
|
|
||||||
self.handleNotification(notification: notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleNotification(notification: LossyLocalNotification) {
|
|
||||||
Log.info("ContentView is handling a notification", for: .push_notifications)
|
|
||||||
guard let damus_state else {
|
|
||||||
// This should never happen because `listenAndHandleLocalNotifications` is called after damus state is initialized in `onAppear`
|
|
||||||
assertionFailure("DamusState not loaded when ContentView (new handler) was handling a notification")
|
|
||||||
Log.error("DamusState not loaded when ContentView (new handler) was handling a notification", for: .push_notifications)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let local = notification
|
|
||||||
let openAction = local.toViewOpenAction()
|
|
||||||
self.execute_open_action(openAction)
|
|
||||||
}
|
|
||||||
|
|
||||||
func connect() {
|
func connect() {
|
||||||
// nostrdb
|
// nostrdb
|
||||||
var mndb = Ndb()
|
var mndb = Ndb()
|
||||||
@@ -703,7 +678,7 @@ struct ContentView: View {
|
|||||||
wallet: WalletModel(settings: settings),
|
wallet: WalletModel(settings: settings),
|
||||||
nav: self.navigationCoordinator,
|
nav: self.navigationCoordinator,
|
||||||
music: MusicController(onChange: music_changed),
|
music: MusicController(onChange: music_changed),
|
||||||
video: DamusVideoCoordinator(),
|
video: VideoController(),
|
||||||
ndb: ndb,
|
ndb: ndb,
|
||||||
quote_reposts: .init(our_pubkey: pubkey),
|
quote_reposts: .init(our_pubkey: pubkey),
|
||||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||||
@@ -748,57 +723,22 @@ struct ContentView: View {
|
|||||||
damus_state.postbox.send(ev)
|
damus_state.postbox.send(ev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An open action within the app
|
private func openEvent(noteId: NoteId, notificationType: LocalNotificationType) {
|
||||||
/// This is used to model, store, and communicate a desired view action to be taken as a result of opening an object,
|
guard let target = damus_state.events.lookup(noteId) else {
|
||||||
/// 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
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TopbarSideMenuButton: View {
|
switch notificationType {
|
||||||
let damus_state: DamusState
|
case .dm:
|
||||||
@Binding var isSideBarOpened: Bool
|
selected_timeline = .dms
|
||||||
|
damus_state.dms.set_active_dm(target.pubkey)
|
||||||
var body: some View {
|
navigationCoordinator.push(route: Route.DMChat(dms: damus_state.dms.active_model))
|
||||||
Button {
|
case .like, .zap, .mention, .repost, .reply, .tagged:
|
||||||
isSideBarOpened.toggle()
|
open_event(ev: target)
|
||||||
} label: {
|
case .profile_zap:
|
||||||
ProfilePicView(pubkey: damus_state.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
break
|
||||||
.opacity(isSideBarOpened ? 0 : 1)
|
|
||||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
|
||||||
.accessibilityHidden(true) // Knowing there is a profile picture here leads to no actionable outcome to VoiceOver users, so it is best not to show it
|
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.main_side_menu_button.rawValue)
|
|
||||||
.accessibilityLabel(NSLocalizedString("Side menu", comment: "Accessibility label for the side menu button at the topbar"))
|
|
||||||
.disabled(isSideBarOpened)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,41 +869,14 @@ enum FindEventType {
|
|||||||
|
|
||||||
enum FoundEvent {
|
enum FoundEvent {
|
||||||
case profile(Pubkey)
|
case profile(Pubkey)
|
||||||
|
case invalid_profile(NostrEvent)
|
||||||
case event(NostrEvent)
|
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?) -> ()) {
|
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)
|
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?) -> ()) {
|
func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: String, callback: @escaping (FoundEvent?) -> ()) {
|
||||||
|
|
||||||
var filter: NostrFilter? = nil
|
var filter: NostrFilter? = nil
|
||||||
@@ -1013,6 +926,10 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
|||||||
switch query {
|
switch query {
|
||||||
case .profile:
|
case .profile:
|
||||||
if ev.known_kind == .metadata {
|
if ev.known_kind == .metadata {
|
||||||
|
guard state.ndb.lookup_profile_key(ev.pubkey) != nil else {
|
||||||
|
callback(.invalid_profile(ev))
|
||||||
|
return
|
||||||
|
}
|
||||||
callback(.profile(ev.pubkey))
|
callback(.profile(ev.pubkey))
|
||||||
}
|
}
|
||||||
case .event:
|
case .event:
|
||||||
@@ -1021,28 +938,20 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
|||||||
case .eose:
|
case .eose:
|
||||||
if !has_event {
|
if !has_event {
|
||||||
attempts += 1
|
attempts += 1
|
||||||
if attempts >= state.pool.our_descriptors.count {
|
if attempts == state.pool.our_descriptors.count / 2 {
|
||||||
callback(nil) // If we could not find any events in any of the relays we are connected to, send back nil
|
callback(nil)
|
||||||
}
|
}
|
||||||
|
state.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||||
}
|
}
|
||||||
state.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose
|
|
||||||
case .notice:
|
case .notice:
|
||||||
break
|
break
|
||||||
case .auth:
|
case .auth:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Finds a replaceable event based on an `naddr` address.
|
|
||||||
///
|
|
||||||
/// This is the callback version of the function. There is another function that makes use of async/await
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - damus_state: The Damus state
|
|
||||||
/// - naddr: the `naddr` address
|
|
||||||
/// - callback: A function to handle the found event
|
|
||||||
func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (NostrEvent?) -> ()) {
|
func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (NostrEvent?) -> ()) {
|
||||||
var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
|
var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
|
||||||
|
|
||||||
@@ -1071,26 +980,6 @@ 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 {
|
func timeline_name(_ timeline: Timeline?) -> String {
|
||||||
guard let timeline else {
|
guard let timeline else {
|
||||||
return ""
|
return ""
|
||||||
@@ -1201,32 +1090,60 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension LossyLocalNotification {
|
|
||||||
/// Computes a view open action from a mention reference.
|
enum OpenResult {
|
||||||
/// Use this when opening a user-presentable interface to a specific mention reference.
|
case profile(Pubkey)
|
||||||
func toViewOpenAction() -> ContentView.ViewOpenAction {
|
case filter(NostrFilter)
|
||||||
switch self.mention {
|
case event(NostrEvent)
|
||||||
case .pubkey(let pubkey):
|
case wallet_connect(WalletConnectURL)
|
||||||
return .route(.ProfileByKey(pubkey: pubkey))
|
case script([UInt8])
|
||||||
case .note(let noteId):
|
case purple(DamusPurpleURL)
|
||||||
return .route(.LoadableNostrEvent(note_reference: .note_id(noteId)))
|
}
|
||||||
case .nevent(let nEvent):
|
|
||||||
// TODO: Improve this by implementing a route that handles nevents with their relay hints.
|
func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) -> Void) {
|
||||||
return .route(.LoadableNostrEvent(note_reference: .note_id(nEvent.noteid)))
|
if let purple_url = DamusPurpleURL(url: url) {
|
||||||
case .nprofile(let nProfile):
|
result(.purple(purple_url))
|
||||||
// TODO: Improve this by implementing a profile route that handles nprofiles with their relay hints.
|
return
|
||||||
return .route(.ProfileByKey(pubkey: nProfile.author))
|
}
|
||||||
case .nrelay(let string):
|
|
||||||
// We do not need to implement `nrelay` support, it has been deprecated.
|
if let nwc = WalletConnectURL(str: url.absoluteString) {
|
||||||
// See https://github.com/nostr-protocol/nips/blob/6e7a618e7f873bb91e743caacc3b09edab7796a0/BREAKING.md?plain=1#L21
|
result(.wallet_connect(nwc))
|
||||||
return .sheet(.error(ErrorView.UserPresentableError(
|
return
|
||||||
user_visible_description: NSLocalizedString("You opened an invalid link. The link you tried to open refers to \"nrelay\", which has been deprecated and is not supported.", comment: "User-visible error description for a user who tries to open a deprecated \"nrelay\" link."),
|
}
|
||||||
tip: NSLocalizedString("Please contact the person who provided the link, and ask for another link.", comment: "User-visible tip on what to do if a link contains a deprecated \"nrelay\" reference."),
|
|
||||||
technical_info: "`MentionRef.toViewOpenAction` detected deprecated `nrelay` contents"
|
guard let link = decode_nostr_uri(url.absoluteString) else {
|
||||||
)))
|
result(nil)
|
||||||
case .naddr(let nAddr):
|
return
|
||||||
return .route(.LoadableNostrEvent(note_reference: .naddr(nAddr)))
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,6 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSUserActivityTypes</key>
|
|
||||||
<array>
|
|
||||||
<string>INSendMessageIntent</string>
|
|
||||||
</array>
|
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -52,8 +48,6 @@
|
|||||||
<key>LSApplicationQueriesSchemes</key>
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>river</string>
|
<string>river</string>
|
||||||
<string>alby</string>
|
|
||||||
<string>albygo</string>
|
|
||||||
<string>bitcoinbeach</string>
|
<string>bitcoinbeach</string>
|
||||||
<string>breez</string>
|
<string>breez</string>
|
||||||
<string>muun</string>
|
<string>muun</string>
|
||||||
@@ -77,6 +71,6 @@
|
|||||||
<key>NSAppleMusicUsageDescription</key>
|
<key>NSAppleMusicUsageDescription</key>
|
||||||
<string>Damus needs access to your media library for playback statuses</string>
|
<string>Damus needs access to your media library for playback statuses</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<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>
|
<string>Damus needs access to your microphone for creating video recording posts</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import Foundation
|
|||||||
|
|
||||||
/// Simple filter to determine whether to show posts or all posts and replies.
|
/// Simple filter to determine whether to show posts or all posts and replies.
|
||||||
enum FilterState : Int {
|
enum FilterState : Int {
|
||||||
case posts = 0
|
|
||||||
case posts_and_replies = 1
|
case posts_and_replies = 1
|
||||||
case conversations = 2
|
case posts = 0
|
||||||
|
|
||||||
func filter(ev: NostrEvent) -> Bool {
|
func filter(ev: NostrEvent) -> Bool {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -20,8 +19,6 @@ enum FilterState : Int {
|
|||||||
return ev.known_kind == .boost || ev.known_kind == .highlight || !ev.is_reply()
|
return ev.known_kind == .boost || ev.known_kind == .highlight || !ev.is_reply()
|
||||||
case .posts_and_replies:
|
case .posts_and_replies:
|
||||||
return true
|
return true
|
||||||
case .conversations:
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ class DamusState: HeadlessDamusState {
|
|||||||
let wallet: WalletModel
|
let wallet: WalletModel
|
||||||
let nav: NavigationCoordinator
|
let nav: NavigationCoordinator
|
||||||
let music: MusicController?
|
let music: MusicController?
|
||||||
let video: DamusVideoCoordinator
|
let video: VideoController
|
||||||
let ndb: Ndb
|
let ndb: Ndb
|
||||||
var purple: DamusPurple
|
var purple: DamusPurple
|
||||||
var push_notification_client: PushNotificationClient
|
var push_notification_client: PushNotificationClient
|
||||||
let emoji_provider: EmojiProvider
|
let emoji_provider: EmojiProvider
|
||||||
|
|
||||||
init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, postbox: PostBox, bootstrap_relays: [RelayURL], replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
|
init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, postbox: PostBox, bootstrap_relays: [RelayURL], replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: VideoController, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
|
||||||
self.pool = pool
|
self.pool = pool
|
||||||
self.keypair = keypair
|
self.keypair = keypair
|
||||||
self.likes = likes
|
self.likes = likes
|
||||||
@@ -141,7 +141,7 @@ class DamusState: HeadlessDamusState {
|
|||||||
wallet: WalletModel(settings: settings),
|
wallet: WalletModel(settings: settings),
|
||||||
nav: navigationCoordinator,
|
nav: navigationCoordinator,
|
||||||
music: MusicController(onChange: { _ in }),
|
music: MusicController(onChange: { _ in }),
|
||||||
video: DamusVideoCoordinator(),
|
video: VideoController(),
|
||||||
ndb: ndb,
|
ndb: ndb,
|
||||||
quote_reposts: .init(our_pubkey: pubkey),
|
quote_reposts: .init(our_pubkey: pubkey),
|
||||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||||
@@ -175,9 +175,6 @@ class DamusState: HeadlessDamusState {
|
|||||||
|
|
||||||
func close() {
|
func close() {
|
||||||
print("txn: damus close")
|
print("txn: damus close")
|
||||||
Task {
|
|
||||||
try await self.push_notification_client.revoke_token()
|
|
||||||
}
|
|
||||||
wallet.disconnect()
|
wallet.disconnect()
|
||||||
pool.close()
|
pool.close()
|
||||||
ndb.close()
|
ndb.close()
|
||||||
@@ -212,7 +209,7 @@ class DamusState: HeadlessDamusState {
|
|||||||
wallet: WalletModel(settings: UserSettingsStore()),
|
wallet: WalletModel(settings: UserSettingsStore()),
|
||||||
nav: NavigationCoordinator(),
|
nav: NavigationCoordinator(),
|
||||||
music: nil,
|
music: nil,
|
||||||
video: DamusVideoCoordinator(),
|
video: VideoController(),
|
||||||
ndb: .empty,
|
ndb: .empty,
|
||||||
quote_reposts: .init(our_pubkey: empty_pub),
|
quote_reposts: .init(our_pubkey: empty_pub),
|
||||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||||
|
|||||||
@@ -6,45 +6,14 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
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 {
|
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
|
var content: NSMutableAttributedString
|
||||||
/// A list of media items that have been attached to the note draft.
|
|
||||||
var media: [UploadedMedia]
|
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> = []
|
|
||||||
|
|
||||||
/// A unique ID for this draft that allows us to address these if we need to.
|
init(content: NSMutableAttributedString = NSMutableAttributedString(string: ""), media: [UploadedMedia] = []) {
|
||||||
///
|
|
||||||
/// 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.content = content
|
||||||
self.media = media
|
self.media = media
|
||||||
self.references = references
|
|
||||||
self.id = id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func == (lhs: DraftArtifacts, rhs: DraftArtifacts) -> Bool {
|
static func == (lhs: DraftArtifacts, rhs: DraftArtifacts) -> Bool {
|
||||||
@@ -53,217 +22,11 @@ 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
|
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 = DisplayName(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 {
|
class Drafts: ObservableObject {
|
||||||
@Published var post: DraftArtifacts? = nil
|
@Published var post: DraftArtifacts? = nil
|
||||||
@Published var replies: [NoteId: DraftArtifacts] = [:]
|
@Published var replies: [NostrEvent: DraftArtifacts] = [:]
|
||||||
@Published var quotes: [NoteId: DraftArtifacts] = [:]
|
@Published var quotes: [NostrEvent: DraftArtifacts] = [:]
|
||||||
/// The drafts we have for highlights
|
@Published var highlights: [HighlightSource: DraftArtifacts] = [:]
|
||||||
///
|
|
||||||
/// ## 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||