Compare commits
287 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
8ed2395865
|
|||
|
274d1035e0
|
|||
|
b41205729e
|
|||
|
2d7b77a7e0
|
|||
|
0ed2b4edec
|
|||
|
9a5fabfee5
|
|||
| 1072c5a384 | |||
| 5ed6e85ad8 | |||
| f948dd81ca | |||
| 391818f230 | |||
| dc74ad37a1 | |||
| e1c94b7ff9 | |||
| ec933452d3 | |||
| 977b268023 | |||
| 0c778af833 | |||
| e75e7950b5 | |||
| 8d68297cce | |||
| e0fd24aff5 | |||
| b5c3ff45e4 | |||
| 786dbb21c4 | |||
| 5a17c330da | |||
| 975be63ce1 | |||
| d9796bd63c | |||
| 25a835624a | |||
| 1d06683bb3 | |||
| 8e15a86c0a | |||
| 51a3008e5a | |||
| f71c1b9848 | |||
| 151e23d524 | |||
| 7619891c86 | |||
| cc98525f59 | |||
| 8cf9549981 | |||
| 9ebf27cd37 | |||
| 16a1a9f37f | |||
| 08d28b0f00 | |||
| d0ae3ca08a | |||
| 6e2f770876 | |||
| 4079bea912 | |||
| 4d01340b90 | |||
| 61b89c2f54 | |||
| cbdff4a5f8 | |||
| 866afe970b | |||
| 87efc91527 | |||
| 4121526588 | |||
| 4adcb738a2 | |||
| 1f17f19a6e | |||
| fb54115286 | |||
| 50dd35d089 | |||
| 1205b2a0e2 | |||
| 0a93e909ed | |||
| 6f3f928ac3 | |||
| 637ceabede | |||
| c25f54f7e7 | |||
| d4ae0b1346 | |||
| 7773618547 | |||
| a4199fa299 | |||
| 5b9ccc4ee5 | |||
| f538e03093 | |||
| e625297a2e | |||
| e603678872 | |||
| 9d77f1b2f7 | |||
| c4f7d25793 | |||
| 100f195a03 | |||
| e599ef1ac9 | |||
| 033c69b92e | |||
| 184e566b1b | |||
| 8c321e479b | |||
| 960c84d02e | |||
| 02f1c2d342 | |||
| c8ca3c93f6 | |||
| 5c6e5ca2de | |||
| e3105a90c5 | |||
| 38dc7b046a | |||
| da76ad9b66 | |||
| 177c55cf3d | |||
| eeb6547d3e | |||
| 50ef6600a8 | |||
| be43819de2 | |||
| 58017952bc | |||
| 409be7fc58 | |||
| 1bc660c9cd | |||
| a56a59f81d | |||
| 1d5af6ca5c | |||
| f81b2b677f | |||
| 290152c859 | |||
| c4ee52fdac | |||
| 6f04455350 | |||
| 3b62945e5b | |||
| b1b032d905 | |||
| 7c805f7f23 | |||
| a2b0620175 | |||
| 8c6bee3d90 | |||
| bba651b37c | |||
| f657af275a | |||
| 03ded7d39f | |||
| 53edc7eb0b | |||
| 8b969021d5 | |||
| 1e52982a5d | |||
| 0038d42f71 | |||
| d94b387fb9 | |||
| 8a2dbc95ca | |||
| 5865b000c0 | |||
| 6efb512a64 | |||
| b7b8c7f175 | |||
| a76e2aa677 | |||
| d9f2317728 | |||
| f1339e835b | |||
| 64f2362be3 | |||
|
5b184a40fd
|
|||
|
17e6191a92
|
|||
|
1ff065d4c7
|
|||
| 94e2c76284 | |||
| 1925af6897 | |||
| 4effaa4324 | |||
| e2a4443a9c | |||
| 7c0e1c5ded | |||
| f6e34ad999 | |||
| ca5da7b5cd | |||
| c08e4a2fdd | |||
| 37a50f6087 | |||
| 2040e79165 | |||
| a6449020b6 | |||
| 75a46f4ab4 | |||
| c948c7e230 | |||
| c83b0fba21 | |||
| b7053e8680 | |||
| 17183632c8 | |||
| 686d6d6e92 | |||
| 847ae7b396 | |||
| ba9780fb17 | |||
| 42f5af0ffd | |||
| 55f1330fc1 | |||
| 4b326340a3 | |||
| 83f7766833 | |||
| 1e3b20f5b3 | |||
| e508f28f7d | |||
| 2c139863b8 | |||
| c699409129 | |||
| 74e6d8781a | |||
| 876f9c742f | |||
| 1e7b57eaf3 | |||
| 5615b1e1ec | |||
| 9d66a5ed4f | |||
| 5555f1afec | |||
| 77b1b895a5 | |||
| 6751bc15cc | |||
| 735fa97089 | |||
| 314774f032 | |||
| 262bbf26ea | |||
| cdf8d043c9 | |||
| 1eb7c94a5a | |||
| 5228d8cf4d | |||
| 782779f0d7 | |||
| 18ec8e6b6c | |||
| 7d82d8b76f | |||
| f957756df7 | |||
| 2cb0553723 | |||
| 8464e151cc | |||
| 2da444e7c2 | |||
| 2a19d5d831 | |||
| f92509fddf | |||
| 51ee4046a0 | |||
| 1e85bb946d | |||
| 6639c002ed | |||
| 2a61440aed | |||
| 823c2565da | |||
| b5a81e2586 | |||
| 6254cea600 | |||
| ce63f6a96b | |||
| 6fa2e8b5c6 | |||
| 2278ab09a4 | |||
| dfa72fceb1 | |||
| 62772615b6 | |||
| 9e0b9debb4 | |||
| 3902fe7b30 | |||
| 471bb4638a | |||
| 8c5b0ed5c4 | |||
| 379de6ff8e | |||
| cb241741e3 | |||
| 1dbf7101b9 | |||
| d9bbca1005 | |||
| d2acf61e5a | |||
| d6898c77d8 | |||
| dd1fdf159b | |||
| 51b1b81c0e | |||
| da7af491d0 | |||
| 90b284fb6e | |||
| c1a89bd617 | |||
| a20f3ab2ab | |||
| 7b9d0edef4 | |||
| c22fc8613d | |||
| f61308e573 | |||
| d93b04a54c | |||
| 4b881e6839 | |||
| 63b0661728 | |||
| 46a66bc69d | |||
| c09018be48 | |||
| d71d448ac8 | |||
| 5834e1ee9b | |||
| d51179189c | |||
| b01243b101 | |||
| d2a80cce4e | |||
| 0cc9fc1670 | |||
| 1279791d65 | |||
| 5d2fc0ed54 | |||
| dcafcd9184 | |||
| 8481ab85de | |||
| 881d3a3aa1 | |||
| 878509090f | |||
| 24657ecc75 | |||
| cf16a9cd10 | |||
| 3a9dda5eb3 | |||
| c69ddd7241 | |||
| bfcb3e4c88 | |||
| 27083669fa | |||
| aaddbd847a | |||
| 1537501127 | |||
| 8b020e2bd6 | |||
| ad614f3e42 | |||
| 01497d0288 | |||
| eaad552273 | |||
|
83ecc3142e
|
|||
| ef4afbc720 | |||
| a5cc3aec92 | |||
| 881ece214d | |||
| 2b140d4279 | |||
| b43dcd2bc7 | |||
| c67a75d740 | |||
| 7f00ef5d9d | |||
| d663155941 | |||
| abfe0f642f | |||
| f0b5162205 | |||
| a9bb2ef98b | |||
| eff4525720 | |||
| 858d9dc6f0 | |||
| 55090bc102 | |||
| 40d3d273f0 | |||
| f9271da11c | |||
| 4f881a5667 | |||
| 9d97886e3f | |||
| e70cfbbe63 | |||
| 8a75537ea3 | |||
| 49c8d63d0b | |||
| 2519b0ee9f | |||
| ba1589e2e2 | |||
| e9a2473bad | |||
| 6480023c96 | |||
| 774da239b9 | |||
| 90c80645ec | |||
| 613ec23f7f | |||
| 1d73ae1d32 | |||
|
63e364ce5b
|
|||
|
ee5f53e4eb
|
|||
|
9de21a730a
|
|||
|
36c09c8657
|
|||
|
e8ac143192
|
|||
|
93f44939e3
|
|||
|
48078b9b6a
|
|||
|
d6d6858e0b
|
|||
|
0187ff1dc0
|
|||
|
4f9fef8515
|
|||
|
1ebadd42f0
|
|||
|
4fb4f3a2de
|
|||
|
f49169c03c
|
|||
| 740c10c9b2 | |||
| 653f9fbcbe | |||
|
1767a677bb
|
|||
| dba1799df0 | |||
| 2db3d7310f | |||
| b2ba1e0e3b | |||
| 10b1cf64ae | |||
| afdd3f1d43 | |||
| 1b8e3fe184 | |||
| 8ab1c6a899 | |||
| e8fae19b97 | |||
| 63e70605fc | |||
| 35df9f7ab7 | |||
| 605d88add1 | |||
| 2b0a7d126d | |||
| 6e2c133faa | |||
| 9885ff1912 | |||
| abb818bbd4 | |||
| f1dc023e18 | |||
| 4a332c7ffa | |||
| 616f730ae5 | |||
| 164cea96f3 | |||
| fa70c376b1 |
@@ -0,0 +1,36 @@
|
||||
## 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
|
||||
- [ ] 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.]_
|
||||
+180
@@ -1,3 +1,183 @@
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
- Completely new threads experience that is easier and more pleasant to use (Daniel D’Aquino)
|
||||
- Add emoji search to emoji picker (Terry Yiu)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Added first aid contact damus support email (alltheseas)
|
||||
- Disable mutiny wallet button (William Casarin)
|
||||
- Make friends show up first when searching for profiles (Terry Yiu)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix crash on profile page when there are profile updates (William Casarin)
|
||||
- Fix crash when adding duplicate mute items (William Casarin)
|
||||
- Fix pretty bad crash when building flatbuffer profiles (William Casarin)
|
||||
- Fix reactions view to not show reactions from replies on parent note (Terry Yiu)
|
||||
- Fix missing Mute button in profile view menu (Terry Yiu)
|
||||
- Fixed wallet not disconnecting when a user logs out (ericholguin)
|
||||
- Fix stale feed issue when follow list is too big (Daniel D’Aquino)
|
||||
|
||||
[1.9 (14)]: https://github.com/damus-io/damus/releases/tag/v1.9-14
|
||||
|
||||
## [1.8] - 2024-05-11
|
||||
|
||||
### Added
|
||||
|
||||
- Added nip10 marker replies (William Casarin)
|
||||
- Add marker nip10 support when reading notes (William Casarin)
|
||||
- Added title image and tags to longform events (ericholguin)
|
||||
- Add First Aid solution for users who do not have a contact list created for their account (Daniel D’Aquino)
|
||||
- Relay fees metadata (ericholguin)
|
||||
- Added callbackuri for a better ux when connecting mutiny wallet nwc (ericholguin)
|
||||
- Add event content preview to the full screen carousel (Daniel D’Aquino)
|
||||
- Show list of quoted reposts in threads (William Casarin)
|
||||
- Proxy Tags are now viewable on Selected Events (ericholguin)
|
||||
- Connect to Mutiny Wallet Button (ericholguin)
|
||||
- Add ability to mute words, add new mutelist interface (Charlie) (William Casarin)
|
||||
- Add ability to mute hashtag from SearchView (Charlie Fish)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Change reactions to use a native looking emoji picker (Terry Yiu)
|
||||
- Relay detail design (ericholguin)
|
||||
- Updated Zeus logo (ericholguin)
|
||||
- Improve UX around video playback (Daniel D’Aquino)
|
||||
- Moved paste nwc button to main wallet view (ericholguin)
|
||||
- Errors with an NWC will show as an alert (ericholguin)
|
||||
- Relay config view user interface (ericholguin)
|
||||
- Always strip GPS data from images (kernelkind)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix thread bug where a quote isn't picked up as a reply (William Casarin)
|
||||
- Fixed threads not loading sometimes (William Casarin)
|
||||
- Fixed issue where some replies were including the q tag (William Casarin)
|
||||
- Fixed issue where timeline was scrolling when it isn't supposed to (William Casarin)
|
||||
- Fix issue where bootstrap relays would inadvertently be added to the user's list on connectivity issues (Daniel D’Aquino)
|
||||
- Fix broken GIF uploads (Daniel D’Aquino)
|
||||
- Fix ghost notifications caused by Purple impending expiration notifications (Daniel D’Aquino)
|
||||
- Improve reliability of contact list creation during onboarding (Daniel D’Aquino)
|
||||
- Fix emoji reactions being cut off (ericholguin)
|
||||
- Fix image indicators to limit number of dots to not spill screen beyond visible margins (ericholguin)
|
||||
- Fix bug that would cause connection issues with relays defined with a trailing slash URL, and an inability to delete them. (Daniel D’Aquino)
|
||||
- Issue where NWC Scanner view would not dismiss after a failed scan/paste (ericholguin)
|
||||
|
||||
|
||||
|
||||
[1.8]: https://github.com/damus-io/damus/releases/tag/v1.8
|
||||
|
||||
## [1.7-rc2] - 2024-02-28
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
@@ -10,5 +12,9 @@
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.jb55.damus2</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -55,6 +55,9 @@ struct NotificationFormatter {
|
||||
var identifier = ""
|
||||
|
||||
switch notify.type {
|
||||
case .tagged:
|
||||
title = String(format: NSLocalizedString("Tagged by %@", comment: "Tagged by heading in local notification"), displayName)
|
||||
identifier = "myMentionNotification"
|
||||
case .mention:
|
||||
title = String(format: NSLocalizedString("Mentioned by %@", comment: "Mentioned by heading in local notification"), displayName)
|
||||
identifier = "myMentionNotification"
|
||||
@@ -70,6 +73,9 @@ struct NotificationFormatter {
|
||||
case .zap, .profile_zap:
|
||||
// not handled here. Try `format_message(displayName: String, notify: LocalNotification, state: HeadlessDamusState) async -> (content: UNMutableNotificationContent, identifier: String)?`
|
||||
return nil
|
||||
case .reply:
|
||||
title = String(format: NSLocalizedString("%@ replied to your note", comment: "Heading for local notification indicating a new reply"), displayName)
|
||||
identifier = "myReplyNotification"
|
||||
}
|
||||
content.title = title
|
||||
content.body = notify.content
|
||||
@@ -87,10 +93,11 @@ struct NotificationFormatter {
|
||||
|
||||
// If it does not work, try async formatting methods
|
||||
let content = UNMutableNotificationContent()
|
||||
|
||||
|
||||
switch notify.type {
|
||||
case .zap, .profile_zap:
|
||||
guard let zap = await get_zap(from: notify.event, state: state) else {
|
||||
Log.debug("format_message: async get_zap failed", for: .push_notifications)
|
||||
return nil
|
||||
}
|
||||
content.title = Self.zap_notification_title(zap)
|
||||
|
||||
@@ -27,9 +27,9 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
// Log that we got a push notification
|
||||
Log.debug("Got nostr event push notification from pubkey %s", for: .push_notifications, nostr_event.pubkey.hex())
|
||||
|
||||
guard let state = NotificationExtensionState(),
|
||||
let display_name = state.ndb.lookup_profile(nostr_event.pubkey)?.unsafeUnownedValue?.profile?.display_name // We are not holding the txn here.
|
||||
else {
|
||||
guard let state = NotificationExtensionState() else {
|
||||
Log.debug("Failed to open nostrdb", for: .push_notifications)
|
||||
|
||||
// Something failed to initialize so let's go for the next best thing
|
||||
guard let improved_content = NotificationFormatter.shared.format_message(event: nostr_event) else {
|
||||
// We cannot format this nostr event. Suppress notification.
|
||||
@@ -39,7 +39,11 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
contentHandler(improved_content)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
|
||||
let profile = txn?.unsafeUnownedValue?.profile
|
||||
let name = Profile.displayName(profile: profile, pubkey: nostr_event.pubkey).displayName
|
||||
|
||||
// Don't show notification details that match mute list.
|
||||
// TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
|
||||
if state.mutelist_manager.is_event_muted(nostr_event) {
|
||||
@@ -54,6 +58,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
|
||||
guard should_display_notification(state: state, event: nostr_event, mode: .push) else {
|
||||
Log.debug("should_display_notification failed", for: .push_notifications)
|
||||
// We should not display notification for this event. Suppress notification.
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
@@ -62,6 +67,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
|
||||
guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else {
|
||||
Log.debug("generate_local_notification_object failed", for: .push_notifications)
|
||||
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
@@ -70,9 +76,13 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
|
||||
Task {
|
||||
if let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: display_name, notify: notification_object, state: state) {
|
||||
contentHandler(improvedContent)
|
||||
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: name, notify: notification_object, state: state) else {
|
||||
|
||||
Log.debug("NotificationFormatter.format_message failed", for: .push_notifications)
|
||||
return
|
||||
}
|
||||
|
||||
contentHandler(improvedContent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2441
-63
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"originHash" : "babaf4d5748afecf49bbb702530d8e9576460692f478b0a50ee43195dd4440e2",
|
||||
"originHash" : "534c8e58993919d5ead25ceb4788c8e492c86bc2cf5833dc651ae60a0f30169c",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "codescanner",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/twostraws/CodeScanner.git",
|
||||
"state" : {
|
||||
"revision" : "9fa582f4b36c69c2a55bff5fb3377eb170ae273c"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "emojikit",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -92,10 +100,9 @@
|
||||
{
|
||||
"identity" : "swipeactions",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/aheze/SwipeActions",
|
||||
"location" : "https://github.com/damus-io/SwipeActions.git",
|
||||
"state" : {
|
||||
"revision" : "41e6f6dce02d8cfa164f8c5461a41340850ca3ab",
|
||||
"version" : "1.1.0"
|
||||
"revision" : "33d99756c3112e1a07c1732e3cddc5ad5bd0c5f4"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1540"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D703D7162C66E47100A400EA"
|
||||
BuildableName = "HighlighterActionExtension.appex"
|
||||
BlueprintName = "HighlighterActionExtension"
|
||||
ReferencedContainer = "container:damus.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
|
||||
BuildableName = "damus.app"
|
||||
BlueprintName = "damus"
|
||||
ReferencedContainer = "container:damus.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
launchStyle = "0"
|
||||
askForAppToLaunch = "Yes"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<RemoteRunnable
|
||||
runnableDebuggingMode = "1"
|
||||
BundleIdentifier = "com.apple.mobilesafari"
|
||||
RemotePath = "/Library/Developer/CoreSimulator/Volumes/iOS_21F79/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.5.simruntime/Contents/Resources/RuntimeRoot/Applications/MobileSafari.app">
|
||||
</RemoteRunnable>
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
|
||||
BuildableName = "damus.app"
|
||||
BlueprintName = "damus"
|
||||
ReferencedContainer = "container:damus.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
|
||||
BuildableName = "damus.app"
|
||||
BlueprintName = "damus"
|
||||
ReferencedContainer = "container:damus.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -40,7 +40,7 @@
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "YES">
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4CE6DEFC27F7A08200C66700"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "damoose.jpeg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tor.svg.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -12,31 +12,25 @@ let RECTANGLE_GRADIENT = LinearGradient(gradient: Gradient(colors: [
|
||||
DamusColors.blue
|
||||
]), startPoint: .leading, endPoint: .trailing)
|
||||
|
||||
struct CustomPicker<SelectionValue: Hashable, Content: View>: View {
|
||||
struct CustomPicker<SelectionValue: Hashable>: View {
|
||||
|
||||
let tabs: [(String, SelectionValue)]
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
|
||||
@Namespace var picker
|
||||
@Binding var selection: SelectionValue
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
|
||||
public var body: some View {
|
||||
let contentMirror = Mirror(reflecting: content)
|
||||
let blocksCount = Mirror(reflecting: contentMirror.descendant("value")!).children.count
|
||||
HStack {
|
||||
ForEach(0..<blocksCount, id: \.self) { index in
|
||||
let tupleBlock = contentMirror.descendant("value", ".\(index)")
|
||||
let text = Mirror(reflecting: tupleBlock!).descendant("content") as! Text
|
||||
let tag = Mirror(reflecting: tupleBlock!).descendant("modifier", "value", "tagged") as! SelectionValue
|
||||
|
||||
ForEach(tabs, id: \.1) { (text, tag) in
|
||||
Button {
|
||||
withAnimation(.spring()) {
|
||||
selection = tag
|
||||
}
|
||||
} label: {
|
||||
text
|
||||
.padding(EdgeInsets(top: 15, leading: 0, bottom: 10, trailing: 0))
|
||||
Text(text).padding(EdgeInsets(top: 15, leading: 0, bottom: 10, trailing: 0))
|
||||
.font(.system(size: 14, weight: .heavy))
|
||||
.tag(tag)
|
||||
}
|
||||
.background(
|
||||
Group {
|
||||
@@ -52,7 +46,6 @@ struct CustomPicker<SelectionValue: Hashable, Content: View>: View {
|
||||
.accentColor(tag == selection ? textColor() : .gray)
|
||||
}
|
||||
}
|
||||
.background(Color(UIColor.systemBackground))
|
||||
}
|
||||
|
||||
func textColor() -> Color {
|
||||
|
||||
@@ -20,6 +20,7 @@ struct DamusBackground: View {
|
||||
.resizable()
|
||||
.frame(maxWidth: .infinity, maxHeight: maxHeight, alignment: .center)
|
||||
.ignoresSafeArea()
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
import Combine
|
||||
|
||||
// TODO: all this ShareSheet complexity can be replaced with ShareLink once we update to iOS 16
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
@@ -95,64 +96,203 @@ 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 {
|
||||
var current_url: URL?
|
||||
var fillHeight: CGFloat
|
||||
var maxHeight: CGFloat
|
||||
var firstImageHeight: CGFloat?
|
||||
// MARK: Immutable object attributes
|
||||
// These are some attributes that are not expected to change throughout the lifecycle of this object
|
||||
// These should not be modified after initialization to avoid state inconsistency
|
||||
|
||||
/// 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.
|
||||
|
||||
@Published var open_sheet: Bool
|
||||
@Published var selectedIndex: Int
|
||||
@Published var video_size: CGSize?
|
||||
@Published var image_fill: ImageFill?
|
||||
/// Stores information about the size of each media item in `urls`.
|
||||
/// **Usage note:** The view is responsible for setting the size of image urls
|
||||
var media_size_information: [URL: CGSize] {
|
||||
didSet {
|
||||
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
|
||||
|
||||
init(image_fill: ImageFill?) {
|
||||
self.current_url = nil
|
||||
self.fillHeight = 350
|
||||
self.maxHeight = UIScreen.main.bounds.height * 1.2 // 1.2
|
||||
self.firstImageHeight = nil
|
||||
self.open_sheet = false
|
||||
/// Initializes the `CarouselModel` with the given `DamusState` and `MediaUrl` array
|
||||
init(damus_state: DamusState, urls: [MediaUrl]) {
|
||||
// Immutable object attributes
|
||||
self.damus_state = damus_state
|
||||
self.urls = urls
|
||||
self.default_fill_height = 350
|
||||
self.max_height = UIScreen.main.bounds.height * 1.2 // 1.2
|
||||
|
||||
// State management properties
|
||||
self.selectedIndex = 0
|
||||
self.video_size = nil
|
||||
self.image_fill = image_fill
|
||||
self.current_item_fill = nil
|
||||
self.geo_size = nil
|
||||
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
|
||||
|
||||
/// 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
|
||||
struct ImageCarousel<Content: View>: View {
|
||||
var urls: [MediaUrl]
|
||||
|
||||
/// The event id of the note that this carousel is displaying
|
||||
let evid: NoteId
|
||||
|
||||
let state: DamusState
|
||||
/// The model that holds information and state of this carousel
|
||||
/// This is observed to update the view when the model changes
|
||||
@ObservedObject var model: CarouselModel
|
||||
let content: ((_ dismiss: @escaping (() -> Void)) -> Content)?
|
||||
|
||||
init(state: DamusState, evid: NoteId, urls: [MediaUrl]) {
|
||||
self.urls = urls
|
||||
self.evid = evid
|
||||
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._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls))
|
||||
self.content = nil
|
||||
}
|
||||
|
||||
init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) {
|
||||
self.urls = urls
|
||||
self.evid = evid
|
||||
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._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls))
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var filling: Bool {
|
||||
model.image_fill?.filling == true
|
||||
model.current_item_fill?.filling == true
|
||||
}
|
||||
|
||||
var height: CGFloat {
|
||||
model.firstImageHeight ?? model.image_fill?.height ?? model.fillHeight
|
||||
// Use the calculated fill height if available, otherwise use the default fill height
|
||||
model.current_item_fill?.height ?? model.default_fill_height
|
||||
}
|
||||
|
||||
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
|
||||
@@ -160,7 +300,7 @@ struct ImageCarousel<Content: View>: View {
|
||||
if num_urls > 1 {
|
||||
// jb55: quick hack since carousel with multiple images looks horrible with blurhash background
|
||||
Color.clear
|
||||
} else if let meta = state.events.lookup_img_metadata(url: url),
|
||||
} else if let meta = model.damus_state.events.lookup_img_metadata(url: url),
|
||||
case .processed(let blurhash) = meta.state {
|
||||
Image(uiImage: blurhash)
|
||||
.resizable()
|
||||
@@ -169,12 +309,6 @@ struct ImageCarousel<Content: View>: View {
|
||||
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 {
|
||||
@@ -183,24 +317,17 @@ struct ImageCarousel<Content: View>: View {
|
||||
case .image(let url):
|
||||
Img(geo: geo, url: url, index: index)
|
||||
.onTapGesture {
|
||||
model.open_sheet = true
|
||||
present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex))
|
||||
}
|
||||
case .video(let url):
|
||||
DamusVideoPlayer(url: url, video_size: $model.video_size, controller: state.video, style: .preview(on_tap: { model.open_sheet = true }))
|
||||
.onChange(of: model.video_size) { size in
|
||||
guard let size else { return }
|
||||
|
||||
let fill = ImageFill.calculate_image_fill(geo_size: geo.size, img_size: size, maxHeight: model.maxHeight, fillHeight: model.fillHeight)
|
||||
|
||||
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
|
||||
}
|
||||
let video_model = model.damus_state.video.get_player(for: url)
|
||||
DamusVideoPlayerView(
|
||||
model: video_model,
|
||||
coordinator: model.damus_state.video,
|
||||
style: .preview(on_tap: {
|
||||
present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex))
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,33 +336,21 @@ struct ImageCarousel<Content: View>: View {
|
||||
KFAnimatedImage(url)
|
||||
.callbackQueue(.dispatch(.global(qos:.background)))
|
||||
.backgroundDecode(true)
|
||||
.imageContext(.note, disable_animation: state.settings.disable_animation)
|
||||
.imageContext(.note, disable_animation: model.damus_state.settings.disable_animation)
|
||||
.image_fade(duration: 0.25)
|
||||
.cancelOnDisappear(true)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.imageFill(for: geo.size, max: model.maxHeight, fill: model.fillHeight) { fill in
|
||||
state.events.get_cache_data(evid).media_metadata_model.fill = fill
|
||||
// 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
|
||||
}
|
||||
}
|
||||
.observe_image_size(size_changed: { size in
|
||||
// Observe the image size to update the model when the size changes, so we can calculate the fill
|
||||
model.media_size_information[url] = size
|
||||
})
|
||||
.background {
|
||||
Placeholder(url: url, geo_size: geo.size, num_urls: urls.count)
|
||||
Placeholder(url: url, geo_size: geo.size, num_urls: model.urls.count)
|
||||
}
|
||||
.aspectRatio(contentMode: filling ? .fill : .fit)
|
||||
.kfClickable()
|
||||
.position(x: geo.size.width / 2, y: geo.size.height / 2)
|
||||
.tabItem {
|
||||
Text(url.absoluteString)
|
||||
@@ -247,25 +362,19 @@ struct ImageCarousel<Content: View>: View {
|
||||
|
||||
var Medias: some View {
|
||||
TabView(selection: $model.selectedIndex) {
|
||||
ForEach(urls.indices, id: \.self) { index in
|
||||
ForEach(model.urls.indices, id: \.self) { index in
|
||||
GeometryReader { geo in
|
||||
Media(geo: geo, url: urls[index], index: index)
|
||||
Media(geo: geo, url: model.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))
|
||||
.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)
|
||||
.onChange(of: model.selectedIndex) { value in
|
||||
model.selectedIndex = value
|
||||
@@ -274,11 +383,17 @@ struct ImageCarousel<Content: View>: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Medias
|
||||
.onTapGesture { }
|
||||
if #available(iOS 18.0, *) {
|
||||
Medias
|
||||
} else {
|
||||
// An empty tap gesture recognizer is needed on iOS 17 and below to suppress other overlapping tap recognizers
|
||||
// Otherwise it will both open the carousel and go to a note at the same time
|
||||
Medias.onTapGesture { }
|
||||
}
|
||||
|
||||
if urls.count > 1 {
|
||||
PageControlView(currentPage: $model.selectedIndex, numberOfPages: urls.count)
|
||||
|
||||
if model.urls.count > 1 {
|
||||
PageControlView(currentPage: $model.selectedIndex, numberOfPages: model.urls.count)
|
||||
.frame(maxWidth: 0, maxHeight: 0)
|
||||
.padding(.top, 5)
|
||||
}
|
||||
@@ -286,27 +401,6 @@ 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 {
|
||||
let filling: Bool?
|
||||
@@ -343,4 +437,3 @@ struct ImageCarousel_Previews: PreviewProvider {
|
||||
.environmentObject(OrientationTracker())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,8 +94,8 @@ enum OpenWalletError: Error {
|
||||
}
|
||||
|
||||
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
|
||||
if let url = URL(string: "\(wallet.link)\(invoice)"), UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url)
|
||||
if let url = URL(string: "\(wallet.link)\(invoice)"), this_app.canOpenURL(url) {
|
||||
this_app.open(url)
|
||||
} else {
|
||||
guard let store_link = wallet.appStoreLink else {
|
||||
throw OpenWalletError.no_wallet_to_open
|
||||
@@ -105,11 +105,11 @@ func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
|
||||
throw OpenWalletError.store_link_invalid
|
||||
}
|
||||
|
||||
guard UIApplication.shared.canOpenURL(url) else {
|
||||
guard this_app.canOpenURL(url) else {
|
||||
throw OpenWalletError.system_cannot_open_store_link
|
||||
}
|
||||
|
||||
UIApplication.shared.open(url)
|
||||
this_app.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,8 +122,3 @@ struct InvoiceView_Previews: PreviewProvider {
|
||||
.frame(width: 300, height: 200)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func present_sheet(_ sheet: Sheets) {
|
||||
notify(.present_sheet(sheet))
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ struct SelectableText: View {
|
||||
let event: NostrEvent?
|
||||
let attributedString: AttributedString
|
||||
let textAlignment: NSTextAlignment
|
||||
@State private var showHighlightPost = false
|
||||
@State private var selectedText = ""
|
||||
@State private var selectedTextActionState: SelectedTextActionState = .hide
|
||||
@State private var selectedTextHeight: CGFloat = .zero
|
||||
@State private var selectedTextWidth: CGFloat = .zero
|
||||
|
||||
@@ -37,8 +36,12 @@ struct SelectableText: View {
|
||||
fixedWidth: selectedTextWidth,
|
||||
textAlignment: self.textAlignment,
|
||||
enableHighlighting: self.enableHighlighting(),
|
||||
showHighlightPost: $showHighlightPost,
|
||||
selectedText: $selectedText,
|
||||
postHighlight: { selectedText in
|
||||
self.selectedTextActionState = .show_highlight_post_view(highlighted_text: selectedText)
|
||||
},
|
||||
muteWord: { selectedText in
|
||||
self.selectedTextActionState = .show_mute_word_view(highlighted_text: selectedText)
|
||||
},
|
||||
height: $selectedTextHeight
|
||||
)
|
||||
.padding([.leading, .trailing], -1.0)
|
||||
@@ -53,11 +56,29 @@ struct SelectableText: View {
|
||||
self.selectedTextWidth = newSize.width
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showHighlightPost) {
|
||||
if let event {
|
||||
HighlightPostView(damus_state: damus_state, event: event, selectedText: $selectedText)
|
||||
.sheet(isPresented: Binding(get: {
|
||||
return self.selectedTextActionState.should_show_highlight_post_view()
|
||||
}, set: { newValue in
|
||||
self.selectedTextActionState = newValue ? .show_highlight_post_view(highlighted_text: self.selectedTextActionState.highlighted_text() ?? "") : .hide
|
||||
})) {
|
||||
if let event, case .show_highlight_post_view(let highlighted_text) = self.selectedTextActionState {
|
||||
PostView(
|
||||
action: .highlighting(.init(selected_text: highlighted_text, source: .event(event))),
|
||||
damus_state: damus_state
|
||||
)
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationDetents([.height(selectedTextHeight + 450), .medium, .large])
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(get: {
|
||||
return self.selectedTextActionState.should_show_mute_word_view()
|
||||
}, set: { newValue in
|
||||
self.selectedTextActionState = newValue ? .show_mute_word_view(highlighted_text: self.selectedTextActionState.highlighted_text() ?? "") : .hide
|
||||
})) {
|
||||
if case .show_mute_word_view(let highlighted_text) = selectedTextActionState {
|
||||
AddMuteItemView(state: damus_state, new_text: .constant(highlighted_text))
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationDetents([.height(selectedTextHeight + 150), .medium, .large])
|
||||
.presentationDetents([.height(300), .medium, .large])
|
||||
}
|
||||
}
|
||||
.frame(height: selectedTextHeight)
|
||||
@@ -66,15 +87,42 @@ struct SelectableText: View {
|
||||
func enableHighlighting() -> Bool {
|
||||
self.event != nil
|
||||
}
|
||||
|
||||
enum SelectedTextActionState {
|
||||
case hide
|
||||
case show_highlight_post_view(highlighted_text: String)
|
||||
case show_mute_word_view(highlighted_text: String)
|
||||
|
||||
func should_show_highlight_post_view() -> Bool {
|
||||
guard case .show_highlight_post_view(let highlighted_text) = self else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
func should_show_mute_word_view() -> Bool {
|
||||
guard case .show_mute_word_view(let highlighted_text) = self else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
func highlighted_text() -> String? {
|
||||
switch self {
|
||||
case .hide:
|
||||
return nil
|
||||
case .show_mute_word_view(highlighted_text: let highlighted_text):
|
||||
return highlighted_text
|
||||
case .show_highlight_post_view(highlighted_text: let highlighted_text):
|
||||
return highlighted_text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate class TextView: UITextView {
|
||||
@Binding var showHighlightPost: Bool
|
||||
@Binding var selectedText: String
|
||||
var postHighlight: (String) -> Void
|
||||
var muteWord: (String) -> Void
|
||||
|
||||
init(frame: CGRect, textContainer: NSTextContainer?, showHighlightPost: Binding<Bool>, selectedText: Binding<String>) {
|
||||
self._showHighlightPost = showHighlightPost
|
||||
self._selectedText = selectedText
|
||||
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void) {
|
||||
self.postHighlight = postHighlight
|
||||
self.muteWord = muteWord
|
||||
super.init(frame: frame, textContainer: textContainer)
|
||||
}
|
||||
|
||||
@@ -86,18 +134,32 @@ fileprivate class TextView: UITextView {
|
||||
if action == #selector(highlightText(_:)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if action == #selector(muteText(_:)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return super.canPerformAction(action, withSender: sender)
|
||||
}
|
||||
|
||||
func getSelectedText() -> String? {
|
||||
guard let selectedRange = self.selectedTextRange else { return nil }
|
||||
return self.text(in: selectedRange)
|
||||
}
|
||||
|
||||
@objc public func highlightText(_ sender: Any?) {
|
||||
guard let selectedRange = self.selectedTextRange else { return }
|
||||
selectedText = self.text(in: selectedRange) ?? ""
|
||||
showHighlightPost.toggle()
|
||||
guard let selectedText = self.getSelectedText() else { return }
|
||||
self.postHighlight(selectedText)
|
||||
}
|
||||
|
||||
@objc public func muteText(_ sender: Any?) {
|
||||
guard let selectedText = self.getSelectedText() else { return }
|
||||
self.muteWord(selectedText)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fileprivate struct TextViewRepresentable: UIViewRepresentable {
|
||||
fileprivate struct TextViewRepresentable: UIViewRepresentable {
|
||||
|
||||
let attributedString: AttributedString
|
||||
let textColor: UIColor
|
||||
@@ -105,12 +167,12 @@ fileprivate class TextView: UITextView {
|
||||
let fixedWidth: CGFloat
|
||||
let textAlignment: NSTextAlignment
|
||||
let enableHighlighting: Bool
|
||||
@Binding var showHighlightPost: Bool
|
||||
@Binding var selectedText: String
|
||||
let postHighlight: (String) -> Void
|
||||
let muteWord: (String) -> Void
|
||||
@Binding var height: CGFloat
|
||||
|
||||
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
|
||||
let view = TextView(frame: .zero, textContainer: nil, showHighlightPost: $showHighlightPost, selectedText: $selectedText)
|
||||
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord)
|
||||
view.isEditable = false
|
||||
view.dataDetectorTypes = .all
|
||||
view.isSelectable = true
|
||||
@@ -123,7 +185,8 @@ fileprivate class TextView: UITextView {
|
||||
|
||||
let menuController = UIMenuController.shared
|
||||
let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:)))
|
||||
menuController.menuItems = self.enableHighlighting ? [highlightItem] : []
|
||||
let muteItem = UIMenuItem(title: "Mute", action: #selector(view.muteText(_:)))
|
||||
menuController.menuItems = self.enableHighlighting ? [highlightItem, muteItem] : []
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ struct SupporterBadge: View {
|
||||
let percent: Int?
|
||||
let purple_account: DamusPurple.Account?
|
||||
let style: Style
|
||||
let text_color: Color
|
||||
|
||||
init(percent: Int?, purple_account: DamusPurple.Account? = nil, style: Style) {
|
||||
init(percent: Int?, purple_account: DamusPurple.Account? = nil, style: Style, text_color: Color = .secondary) {
|
||||
self.percent = percent
|
||||
self.purple_account = purple_account
|
||||
self.style = style
|
||||
self.text_color = text_color
|
||||
}
|
||||
|
||||
let size: CGFloat = 17
|
||||
@@ -31,7 +33,7 @@ struct SupporterBadge: View {
|
||||
if self.style == .full {
|
||||
let date = format_date(date: purple_account.created_at, time_style: .none)
|
||||
Text(date)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(text_color)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,19 +27,26 @@ struct TranslateView: View {
|
||||
let damus_state: DamusState
|
||||
let event: NostrEvent
|
||||
let size: EventViewKind
|
||||
|
||||
|
||||
@Binding var isAppleTranslationPopoverPresented: Bool
|
||||
|
||||
@ObservedObject var translations_model: TranslationModel
|
||||
|
||||
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind) {
|
||||
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind, isAppleTranslationPopoverPresented: Binding<Bool>) {
|
||||
self.damus_state = damus_state
|
||||
self.event = event
|
||||
self.size = size
|
||||
self._isAppleTranslationPopoverPresented = isAppleTranslationPopoverPresented
|
||||
self._translations_model = ObservedObject(wrappedValue: damus_state.events.get_cache_data(event.id).translations_model)
|
||||
}
|
||||
|
||||
var TranslateButton: some View {
|
||||
Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) {
|
||||
translate()
|
||||
if damus_state.settings.translation_service == .none {
|
||||
isAppleTranslationPopoverPresented = true
|
||||
} else {
|
||||
translate()
|
||||
}
|
||||
}
|
||||
.translate_button_style()
|
||||
}
|
||||
@@ -74,17 +81,25 @@ struct TranslateView: View {
|
||||
}
|
||||
|
||||
func should_transl(_ note_lang: String) -> Bool {
|
||||
should_translate(event: event, our_keypair: damus_state.keypair, settings: damus_state.settings, note_lang: note_lang)
|
||||
guard should_translate(event: event, our_keypair: damus_state.keypair, note_lang: note_lang) else {
|
||||
return false
|
||||
}
|
||||
|
||||
if TranslationService.isAppleTranslationPopoverSupported {
|
||||
return damus_state.settings.translation_service == .none || damus_state.settings.can_translate
|
||||
} else {
|
||||
return damus_state.settings.can_translate
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch self.translations_model.state {
|
||||
case .havent_tried:
|
||||
if damus_state.settings.auto_translate {
|
||||
if damus_state.settings.auto_translate && damus_state.settings.translation_service != .none {
|
||||
Text("")
|
||||
} else if let note_lang = translations_model.note_language, should_transl(note_lang) {
|
||||
TranslateButton
|
||||
TranslateButton
|
||||
} else {
|
||||
Text("")
|
||||
}
|
||||
@@ -114,9 +129,11 @@ extension View {
|
||||
}
|
||||
|
||||
struct TranslateView_Previews: PreviewProvider {
|
||||
@State static var isAppleTranslationPopoverPresented: Bool = false
|
||||
|
||||
static var previews: some View {
|
||||
let ds = test_damus_state
|
||||
TranslateView(damus_state: ds, event: test_note, size: .normal)
|
||||
TranslateView(damus_state: ds, event: test_note, size: .normal, isAppleTranslationPopoverPresented: $isAppleTranslationPopoverPresented)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ enum NoteContent {
|
||||
case content(String, TagsSequence?)
|
||||
|
||||
init(note: NostrEvent, keypair: Keypair) {
|
||||
if note.known_kind == .dm {
|
||||
if note.known_kind == .dm || note.known_kind == .highlight {
|
||||
self = .content(note.get_content(keypair), note.tags)
|
||||
} else {
|
||||
self = .note(note)
|
||||
|
||||
+120
-98
@@ -57,6 +57,47 @@ enum Sheets: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func present_sheet(_ sheet: Sheets) {
|
||||
notify(.present_sheet(sheet))
|
||||
}
|
||||
|
||||
var tabHeight: CGFloat = 0.0
|
||||
|
||||
struct ContentView: View {
|
||||
let keypair: Keypair
|
||||
let appDelegate: AppDelegate?
|
||||
@@ -72,78 +113,29 @@ struct ContentView: View {
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
|
||||
@State var active_sheet: Sheets? = nil
|
||||
@State var active_full_screen_item: FullScreenItem? = nil
|
||||
@State var damus_state: DamusState!
|
||||
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home
|
||||
@State var menu_subtitle: String? = nil
|
||||
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home {
|
||||
willSet {
|
||||
self.menu_subtitle = nil
|
||||
}
|
||||
}
|
||||
@State var muting: MuteItem? = nil
|
||||
@State var confirm_mute: Bool = false
|
||||
@State var hide_bar: Bool = false
|
||||
@State var user_muted_confirm: Bool = false
|
||||
@State var confirm_overwrite_mutelist: Bool = false
|
||||
@SceneStorage("ContentView.filter_state") var filter_state : FilterState = .posts_and_replies
|
||||
@State private var isSideBarOpened = false
|
||||
@State var headerOffset: CGFloat = 0.0
|
||||
var home: HomeModel = HomeModel()
|
||||
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
|
||||
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
|
||||
let sub_id = UUID().description
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
// connect retry timer
|
||||
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var mystery: some View {
|
||||
Text("Are you lost?", comment: "Text asking the user if they are lost in the app.")
|
||||
.id("what")
|
||||
}
|
||||
|
||||
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
|
||||
var filters = ContentFilters.defaults(damus_state: damus_state!)
|
||||
filters.append(fstate.filter)
|
||||
return ContentFilters(filters: filters).filter
|
||||
}
|
||||
|
||||
var PostingTimelineView: some View {
|
||||
VStack {
|
||||
ZStack {
|
||||
TabView(selection: $filter_state) {
|
||||
// This is needed or else there is a bug when switching from the 3rd or 2nd tab to first. no idea why.
|
||||
mystery
|
||||
|
||||
contentTimelineView(filter: content_filter(.posts))
|
||||
.tag(FilterState.posts)
|
||||
.id(FilterState.posts)
|
||||
contentTimelineView(filter: content_filter(.posts_and_replies))
|
||||
.tag(FilterState.posts_and_replies)
|
||||
.id(FilterState.posts_and_replies)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
|
||||
if privkey != nil {
|
||||
PostButtonContainer(is_left_handed: damus_state?.settings.left_handed ?? false) {
|
||||
self.active_sheet = .post(.posting(.none))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
CustomPicker(selection: $filter_state, content: {
|
||||
Text("Notes", comment: "Label for filter for seeing only notes (instead of notes and replies).").tag(FilterState.posts)
|
||||
Text("Notes & Replies", comment: "Label for filter for seeing notes and replies (instead of only notes).").tag(FilterState.posts_and_replies)
|
||||
})
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
}
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
}
|
||||
}
|
||||
|
||||
func contentTimelineView(filter: (@escaping (NostrEvent) -> Bool)) -> some View {
|
||||
TimelineView(events: home.events, loading: .constant(false), damus: damus_state, show_friend_icon: false, filter: filter) {
|
||||
PullDownSearchView(state: damus_state, on_cancel: {})
|
||||
}
|
||||
}
|
||||
|
||||
func navIsAtRoot() -> Bool {
|
||||
return navigationCoordinator.isAtRoot()
|
||||
}
|
||||
@@ -153,9 +145,16 @@ struct ContentView: View {
|
||||
isSideBarOpened = false
|
||||
}
|
||||
|
||||
var timelineNavItem: Text {
|
||||
return Text(timeline_name(selected_timeline))
|
||||
.bold()
|
||||
var timelineNavItem: some View {
|
||||
VStack {
|
||||
Text(timeline_name(selected_timeline))
|
||||
.bold()
|
||||
if let menu_subtitle {
|
||||
Text(menu_subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func MainContent(damus: DamusState) -> some View {
|
||||
@@ -171,34 +170,25 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
case .home:
|
||||
PostingTimelineView
|
||||
PostingTimelineView(damus_state: damus_state!, home: home, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset)
|
||||
|
||||
case .notifications:
|
||||
NotificationsView(state: damus, notifications: home.notifications)
|
||||
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
||||
|
||||
case .dms:
|
||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
|
||||
}
|
||||
}
|
||||
.background(DamusColors.adaptableWhite)
|
||||
.edgesIgnoringSafeArea(selected_timeline != .home ? [] : [.top, .bottom])
|
||||
.navigationBarTitle(timeline_name(selected_timeline), displayMode: .inline)
|
||||
.toolbar(selected_timeline != .home ? .visible : .hidden)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
VStack {
|
||||
if selected_timeline == .home {
|
||||
Image("damus-home")
|
||||
.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)
|
||||
}
|
||||
timelineNavItem
|
||||
.opacity(isSideBarOpened ? 0 : 1)
|
||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,14 +239,7 @@ struct ContentView: View {
|
||||
MainContent(damus: damus)
|
||||
.toolbar() {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
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)
|
||||
TopbarSideMenuButton(damus_state: damus, isSideBarOpened: $isSideBarOpened)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
@@ -277,9 +260,11 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(DamusColors.adaptableWhite)
|
||||
.edgesIgnoringSafeArea(selected_timeline != .home ? [] : [.top, .bottom])
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.overlay(
|
||||
SideMenuView(damus_state: damus_state!, isSidebarVisible: $isSideBarOpened.animation())
|
||||
SideMenuView(damus_state: damus_state!, isSidebarVisible: $isSideBarOpened.animation(), selected: $selected_timeline)
|
||||
)
|
||||
.navigationDestination(for: Route.self) { route in
|
||||
route.view(navigationCoordinator: navigationCoordinator, damusState: damus_state!)
|
||||
@@ -289,13 +274,28 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
|
||||
if !hide_bar {
|
||||
TabBar(nstatus: home.notification_status, selected: $selected_timeline, settings: damus.settings, action: switch_timeline)
|
||||
.padding([.bottom], 8)
|
||||
.background(Color(uiColor: .systemBackground).ignoresSafeArea())
|
||||
} else {
|
||||
Text("")
|
||||
.damus_full_screen_cover($active_full_screen_item, damus_state: damus, content: { item in
|
||||
return item.view(damus_state: damus)
|
||||
})
|
||||
.overlay(alignment: .bottom) {
|
||||
if !hide_bar {
|
||||
if !isSideBarOpened {
|
||||
TabBar(nstatus: home.notification_status, navIsAtRoot: navIsAtRoot(), selected: $selected_timeline, headerOffset: $headerOffset, settings: damus.settings, action: switch_timeline)
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,6 +453,9 @@ struct ContentView: View {
|
||||
.onReceive(handle_notify(.present_sheet)) { sheet in
|
||||
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
|
||||
guard !zap_ev.is_custom else {
|
||||
return
|
||||
@@ -718,7 +721,7 @@ struct ContentView: View {
|
||||
wallet: WalletModel(settings: settings),
|
||||
nav: self.navigationCoordinator,
|
||||
music: MusicController(onChange: music_changed),
|
||||
video: VideoController(),
|
||||
video: DamusVideoCoordinator(),
|
||||
ndb: ndb,
|
||||
quote_reposts: .init(our_pubkey: pubkey),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
@@ -774,7 +777,7 @@ struct ContentView: View {
|
||||
selected_timeline = .dms
|
||||
damus_state.dms.set_active_dm(target.pubkey)
|
||||
navigationCoordinator.push(route: Route.DMChat(dms: damus_state.dms.active_model))
|
||||
case .like, .zap, .mention, .repost:
|
||||
case .like, .zap, .mention, .repost, .reply, .tagged:
|
||||
open_event(ev: target)
|
||||
case .profile_zap:
|
||||
break
|
||||
@@ -782,6 +785,25 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct TopbarSideMenuButton: View {
|
||||
let damus_state: DamusState
|
||||
@Binding var isSideBarOpened: Bool
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
isSideBarOpened.toggle()
|
||||
} label: {
|
||||
ProfilePicView(pubkey: damus_state.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContentView(keypair: Keypair(pubkey: test_pubkey, privkey: nil), appDelegate: nil)
|
||||
@@ -875,7 +897,7 @@ func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [Nos
|
||||
|
||||
|
||||
func setup_notifications() {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
this_app.registerForRemoteNotifications()
|
||||
let center = UNUserNotificationCenter.current()
|
||||
|
||||
center.getNotificationSettings { settings in
|
||||
@@ -1107,7 +1129,7 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
|
||||
//let post = tup.0
|
||||
//let to_relays = tup.1
|
||||
print("post \(post.content)")
|
||||
guard let new_ev = post_to_event(post: post, keypair: keypair) else {
|
||||
guard let new_ev = post.to_event(keypair: keypair) else {
|
||||
return false
|
||||
}
|
||||
postbox.send(new_ev)
|
||||
@@ -1168,7 +1190,7 @@ func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) ->
|
||||
}
|
||||
case .hashtag(let ht):
|
||||
result(.filter(.filter_hashtag([ht.hashtag])))
|
||||
case .param, .quote:
|
||||
case .param, .quote, .reference:
|
||||
// doesn't really make sense here
|
||||
break
|
||||
case .naddr(let naddr):
|
||||
|
||||
@@ -151,7 +151,7 @@ public class CameraService: NSObject, Identifiable {
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.alertError = AlertError(title: "Camera Access", message: "Damus needs camera and microphone access. Enable in settings.", primaryButtonTitle: "Go to settings", secondaryButtonTitle: nil, primaryAction: {
|
||||
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!,
|
||||
this_app.open(URL(string: UIApplication.openSettingsURLString)!,
|
||||
options: [:], completionHandler: nil)
|
||||
|
||||
}, secondaryAction: nil)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// CommentItem.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-08-14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct CommentItem: TagConvertible {
|
||||
static let TAG_KEY: String = "comment"
|
||||
let content: String
|
||||
var tag: [String] {
|
||||
return [Self.TAG_KEY, content]
|
||||
}
|
||||
|
||||
static func from_tag(tag: TagSequence) -> CommentItem? {
|
||||
guard tag.count == 2 else { return nil }
|
||||
guard tag[0].string() == Self.TAG_KEY else { return nil }
|
||||
|
||||
return CommentItem(content: tag[1].string())
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func is_already_following(contacts: NostrEvent, follow: FollowRef) -> Bool {
|
||||
case let (.pubkey(pk), .pubkey(follow_pk)):
|
||||
return pk == follow_pk
|
||||
case (.hashtag, .pubkey), (.pubkey, .hashtag),
|
||||
(.event, _), (.quote, _), (.param, _), (.naddr, _):
|
||||
(.event, _), (.quote, _), (.param, _), (.naddr, _), (.reference(_), _):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ enum FilterState : Int {
|
||||
func filter(ev: NostrEvent) -> Bool {
|
||||
switch self {
|
||||
case .posts:
|
||||
return ev.known_kind == .boost || !ev.is_reply()
|
||||
return ev.known_kind == .boost || ev.known_kind == .highlight || !ev.is_reply()
|
||||
case .posts_and_replies:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -34,13 +34,13 @@ class DamusState: HeadlessDamusState {
|
||||
let wallet: WalletModel
|
||||
let nav: NavigationCoordinator
|
||||
let music: MusicController?
|
||||
let video: VideoController
|
||||
let video: DamusVideoCoordinator
|
||||
let ndb: Ndb
|
||||
var purple: DamusPurple
|
||||
var push_notification_client: PushNotificationClient
|
||||
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: VideoController, 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: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
|
||||
self.pool = pool
|
||||
self.keypair = keypair
|
||||
self.likes = likes
|
||||
@@ -74,6 +74,79 @@ class DamusState: HeadlessDamusState {
|
||||
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
|
||||
self.emoji_provider = emoji_provider
|
||||
}
|
||||
|
||||
@MainActor
|
||||
convenience init?(keypair: Keypair) {
|
||||
// nostrdb
|
||||
var mndb = Ndb()
|
||||
if mndb == nil {
|
||||
// try recovery
|
||||
print("DB ISSUE! RECOVERING")
|
||||
mndb = Ndb.safemode()
|
||||
|
||||
// out of space or something?? maybe we need a in-memory fallback
|
||||
if mndb == nil {
|
||||
logout(nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
let navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
|
||||
let home: HomeModel = HomeModel()
|
||||
let sub_id = UUID().uuidString
|
||||
|
||||
guard let ndb = mndb else { return nil }
|
||||
let pubkey = keypair.pubkey
|
||||
|
||||
let pool = RelayPool(ndb: ndb, keypair: keypair)
|
||||
let model_cache = RelayModelCache()
|
||||
let relay_filters = RelayFilters(our_pubkey: pubkey)
|
||||
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
|
||||
|
||||
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
|
||||
|
||||
let new_relay_filters = load_relay_filters(pubkey) == nil
|
||||
for relay in bootstrap_relays {
|
||||
let descriptor = RelayDescriptor(url: relay, info: .rw)
|
||||
add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode)
|
||||
}
|
||||
|
||||
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
|
||||
|
||||
if let nwc_str = settings.nostr_wallet_connect,
|
||||
let nwc = WalletConnectURL(str: nwc_str) {
|
||||
try? pool.add_relay(.nwc(url: nwc.relay))
|
||||
}
|
||||
self.init(
|
||||
pool: pool,
|
||||
keypair: keypair,
|
||||
likes: EventCounter(our_pubkey: pubkey),
|
||||
boosts: EventCounter(our_pubkey: pubkey),
|
||||
contacts: Contacts(our_pubkey: pubkey),
|
||||
mutelist_manager: MutelistManager(user_keypair: keypair),
|
||||
profiles: Profiles(ndb: ndb),
|
||||
dms: home.dms,
|
||||
previews: PreviewCache(),
|
||||
zaps: Zaps(our_pubkey: pubkey),
|
||||
lnurls: LNUrls(),
|
||||
settings: settings,
|
||||
relay_filters: relay_filters,
|
||||
relay_model_cache: model_cache,
|
||||
drafts: Drafts(),
|
||||
events: EventCache(ndb: ndb),
|
||||
bookmarks: BookmarksManager(pubkey: pubkey),
|
||||
postbox: PostBox(pool: pool),
|
||||
bootstrap_relays: bootstrap_relays,
|
||||
replies: ReplyCounter(our_pubkey: pubkey),
|
||||
wallet: WalletModel(settings: settings),
|
||||
nav: navigationCoordinator,
|
||||
music: MusicController(onChange: { _ in }),
|
||||
video: DamusVideoCoordinator(),
|
||||
ndb: ndb,
|
||||
quote_reposts: .init(our_pubkey: pubkey),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func add_zap(zap: Zapping) -> Bool {
|
||||
@@ -136,7 +209,7 @@ class DamusState: HeadlessDamusState {
|
||||
wallet: WalletModel(settings: UserSettingsStore()),
|
||||
nav: NavigationCoordinator(),
|
||||
music: nil,
|
||||
video: VideoController(),
|
||||
video: DamusVideoCoordinator(),
|
||||
ndb: .empty,
|
||||
quote_reposts: .init(our_pubkey: empty_pub),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
|
||||
@@ -28,4 +28,5 @@ class Drafts: ObservableObject {
|
||||
@Published var post: DraftArtifacts? = nil
|
||||
@Published var replies: [NostrEvent: DraftArtifacts] = [:]
|
||||
@Published var quotes: [NostrEvent: DraftArtifacts] = [:]
|
||||
@Published var highlights: [HighlightSource: DraftArtifacts] = [:]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import Foundation
|
||||
|
||||
enum FriendFilter: String, StringCodable {
|
||||
case all
|
||||
case friends
|
||||
case friends_of_friends
|
||||
|
||||
init?(from string: String) {
|
||||
guard let ff = FriendFilter(rawValue: string) else {
|
||||
@@ -27,8 +27,17 @@ enum FriendFilter: String, StringCodable {
|
||||
switch self {
|
||||
case .all:
|
||||
return true
|
||||
case .friends:
|
||||
case .friends_of_friends:
|
||||
return contacts.is_in_friendosphere(pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
func description() -> String {
|
||||
switch self {
|
||||
case .all:
|
||||
return NSLocalizedString("All", comment: "Human-readable short description of the 'friends filter' when it is set to 'all'")
|
||||
case .friends_of_friends:
|
||||
return NSLocalizedString("Friends of friends", comment: "Human-readable short description of the 'friends filter' when it is set to 'friends-of-friends'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,22 +13,227 @@ struct HighlightEvent {
|
||||
var event_ref: String? = nil
|
||||
var url_ref: URL? = nil
|
||||
var context: String? = nil
|
||||
|
||||
// MARK: - Initializers and parsers
|
||||
|
||||
static func parse(from ev: NostrEvent) -> HighlightEvent {
|
||||
var highlight = HighlightEvent(event: ev)
|
||||
|
||||
var best_url_source: (url: URL, tagged_as_source: Bool)? = nil
|
||||
|
||||
for tag in ev.tags {
|
||||
guard tag.count >= 2 else { continue }
|
||||
switch tag[0].string() {
|
||||
case "e": highlight.event_ref = tag[1].string()
|
||||
case "a": highlight.event_ref = tag[1].string()
|
||||
case "r": highlight.url_ref = URL(string: tag[1].string())
|
||||
case "r":
|
||||
if tag.count >= 3,
|
||||
tag[2].string() == HighlightSource.TAG_SOURCE_ELEMENT,
|
||||
let url = URL(string: tag[1].string()) {
|
||||
// URL marked as source. Very good candidate
|
||||
best_url_source = (url: url, tagged_as_source: true)
|
||||
}
|
||||
else if tag.count >= 3 && tag[2].string() != HighlightSource.TAG_SOURCE_ELEMENT {
|
||||
// URL marked as something else (not source). Not the source we are after
|
||||
}
|
||||
else if let url = URL(string: tag[1].string()), tag.count == 2 {
|
||||
// Unmarked URL. This might be what we are after (For NIP-84 backwards compatibility)
|
||||
if (best_url_source?.tagged_as_source ?? false) == false {
|
||||
// No URL candidates marked as the source. Mark this as the best option we have
|
||||
best_url_source = (url: url, tagged_as_source: false)
|
||||
}
|
||||
}
|
||||
case "context": highlight.context = tag[1].string()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if let best_url_source {
|
||||
highlight.url_ref = best_url_source.url
|
||||
}
|
||||
|
||||
return highlight
|
||||
}
|
||||
|
||||
// MARK: - Getting information about source
|
||||
|
||||
func source_description_info(highlighted_event: NostrEvent?) -> ReplyDesc {
|
||||
var others_count = 0
|
||||
var highlighted_authors: [Pubkey] = []
|
||||
var i = event.tags.count
|
||||
|
||||
if let highlighted_event {
|
||||
highlighted_authors.append(highlighted_event.pubkey)
|
||||
}
|
||||
|
||||
for tag in event.tags {
|
||||
if let pubkey_with_role = PubkeyWithRole.from_tag(tag: tag) {
|
||||
others_count += 1
|
||||
if highlighted_authors.count < 2 {
|
||||
if let highlighted_event, pubkey_with_role.pubkey == highlighted_event.pubkey {
|
||||
continue
|
||||
} else {
|
||||
switch pubkey_with_role.role {
|
||||
case .author:
|
||||
highlighted_authors.append(pubkey_with_role.pubkey)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
i -= 1
|
||||
}
|
||||
|
||||
return ReplyDesc(pubkeys: highlighted_authors, others: others_count)
|
||||
}
|
||||
|
||||
func source_description_text(ndb: Ndb, highlighted_event: NostrEvent?, locale: Locale = Locale.current) -> String {
|
||||
let description_info = self.source_description_info(highlighted_event: highlighted_event)
|
||||
let pubkeys = description_info.pubkeys
|
||||
|
||||
let bundle = bundleForLocale(locale: locale)
|
||||
|
||||
if pubkeys.count == 0 {
|
||||
return NSLocalizedString("Highlighted", bundle: bundle, comment: "Label to indicate that the user is highlighting their own post.")
|
||||
}
|
||||
|
||||
guard let profile_txn = NdbTxn(ndb: ndb) else {
|
||||
return ""
|
||||
}
|
||||
|
||||
let names: [String] = pubkeys.map { pk in
|
||||
let prof = ndb.lookup_profile_with_txn(pk, txn: profile_txn)
|
||||
|
||||
return Profile.displayName(profile: prof?.profile, pubkey: pk).username.truncate(maxLength: 50)
|
||||
}
|
||||
|
||||
let uniqueNames: [String] = Array(Set(names))
|
||||
return String(format: NSLocalizedString("Highlighted %@", bundle: bundle, comment: "Label to indicate that the user is highlighting 1 user."), locale: locale, uniqueNames.first ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper structures
|
||||
|
||||
extension HighlightEvent {
|
||||
struct PubkeyWithRole: TagKey, TagConvertible {
|
||||
let pubkey: Pubkey
|
||||
let role: Role
|
||||
|
||||
var tag: [String] {
|
||||
if let role_text = self.role.rawValue {
|
||||
return [keychar.description, self.pubkey.hex(), role_text]
|
||||
}
|
||||
else {
|
||||
return [keychar.description, self.pubkey.hex()]
|
||||
}
|
||||
}
|
||||
|
||||
var keychar: AsciiCharacter { "p" }
|
||||
|
||||
static func from_tag(tag: TagSequence) -> HighlightEvent.PubkeyWithRole? {
|
||||
var i = tag.makeIterator()
|
||||
|
||||
guard tag.count >= 2,
|
||||
let t0 = i.next(),
|
||||
let key = t0.single_char,
|
||||
key == "p",
|
||||
let t1 = i.next(),
|
||||
let pubkey = t1.id().map(Pubkey.init)
|
||||
else { return nil }
|
||||
|
||||
let t3: String? = i.next()?.string()
|
||||
let role = Role(rawValue: t3)
|
||||
return PubkeyWithRole(pubkey: pubkey, role: role)
|
||||
}
|
||||
|
||||
enum Role: RawRepresentable {
|
||||
case author
|
||||
case editor
|
||||
case mention
|
||||
case other(String)
|
||||
case no_role
|
||||
|
||||
typealias RawValue = String?
|
||||
var rawValue: String? {
|
||||
switch self {
|
||||
case .author: "author"
|
||||
case .editor: "editor"
|
||||
case .mention: "mention"
|
||||
case .other(let role): role
|
||||
case .no_role: nil
|
||||
}
|
||||
}
|
||||
|
||||
init(rawValue: String?) {
|
||||
switch rawValue {
|
||||
case "author": self = .author
|
||||
case "editor": self = .editor
|
||||
case "mention": self = .mention
|
||||
default:
|
||||
if let rawValue {
|
||||
self = .other(rawValue)
|
||||
}
|
||||
else {
|
||||
self = .no_role
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HighlightContentDraft: Hashable {
|
||||
let selected_text: String
|
||||
let source: HighlightSource
|
||||
}
|
||||
|
||||
enum HighlightSource: Hashable {
|
||||
static let TAG_SOURCE_ELEMENT = "source"
|
||||
case event(NostrEvent)
|
||||
case external_url(URL)
|
||||
|
||||
func tags() -> [[String]] {
|
||||
switch self {
|
||||
case .event(let event):
|
||||
return [ ["e", "\(event.id)", HighlightSource.TAG_SOURCE_ELEMENT] ]
|
||||
case .external_url(let url):
|
||||
return [ ["r", "\(url)", HighlightSource.TAG_SOURCE_ELEMENT] ]
|
||||
}
|
||||
}
|
||||
|
||||
func ref() -> RefId {
|
||||
switch self {
|
||||
case .event(let event):
|
||||
return .event(event.id)
|
||||
case .external_url(let url):
|
||||
return .reference(url.absoluteString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ShareContent {
|
||||
let title: String
|
||||
let content: ContentType
|
||||
|
||||
enum ContentType {
|
||||
case link(URL)
|
||||
case media([PreUploadedMedia])
|
||||
}
|
||||
|
||||
func getLinkURL() -> URL? {
|
||||
if case let .link(url) = content {
|
||||
return url
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getMediaArray() -> [PreUploadedMedia] {
|
||||
if case let .media(mediaArray) = content {
|
||||
return mediaArray
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,11 +127,11 @@ class HomeModel: ContactsDelegate {
|
||||
/// This loads the latest contact event we have on file from NostrDB. This should be called as soon as we get the new DamusState
|
||||
/// Loading the latest contact list event into our `Contacts` instance from storage is important to avoid getting into weird states when the network is unreliable or when relays delete such information
|
||||
func load_latest_contact_event_from_damus_state() {
|
||||
damus_state.contacts.delegate = self
|
||||
guard let latest_contact_event_id_hex = damus_state.settings.latest_contact_event_id_hex else { return }
|
||||
guard let latest_contact_event_id = NoteId(hex: latest_contact_event_id_hex) else { return }
|
||||
guard let latest_contact_event: NdbNote = damus_state.ndb.lookup_note( latest_contact_event_id)?.unsafeUnownedValue?.to_owned() else { return }
|
||||
process_contact_event(state: damus_state, ev: latest_contact_event)
|
||||
damus_state.contacts.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - ContactsDelegate functions
|
||||
|
||||
@@ -10,7 +10,7 @@ import Foundation
|
||||
enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
|
||||
var id: String { self.rawValue }
|
||||
case nostrBuild
|
||||
case nostrImg
|
||||
case nostrcheck
|
||||
|
||||
init?(from string: String) {
|
||||
guard let mu = MediaUploader(rawValue: string) else {
|
||||
@@ -23,95 +23,73 @@ enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
|
||||
func to_string() -> String {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
|
||||
var nameParam: String {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
return "\"fileToUpload\""
|
||||
case .nostrImg:
|
||||
return "\"image\""
|
||||
default:
|
||||
return "\"file\""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var supportsVideo: Bool {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
return true
|
||||
case .nostrImg:
|
||||
return false
|
||||
case .nostrcheck:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct Model: Identifiable, Hashable {
|
||||
var id: String { self.tag }
|
||||
var index: Int
|
||||
var tag: String
|
||||
var displayName : String
|
||||
}
|
||||
|
||||
|
||||
var model: Model {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
return .init(index: -1, tag: "nostrBuild", displayName: "nostr.build")
|
||||
case .nostrImg:
|
||||
return .init(index: 0, tag: "nostrImg", displayName: "nostrimg.com")
|
||||
case .nostrcheck:
|
||||
return .init(index: 0, tag: "nostrcheck", displayName: "nostrcheck.me")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
var postAPI: String {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
return "https://nostr.build/api/v2/upload/files"
|
||||
case .nostrImg:
|
||||
return "https://nostrimg.com/api/upload"
|
||||
return "https://nostr.build/api/v2/nip96/upload"
|
||||
case .nostrcheck:
|
||||
return "https://nostrcheck.me/api/v2/media"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func getMediaURL(from data: Data) -> String? {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
do {
|
||||
if let jsonObject = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any],
|
||||
let status = jsonObject["status"] as? String {
|
||||
|
||||
if status == "success", let dataArray = jsonObject["data"] as? [[String: Any]] {
|
||||
|
||||
var urls: [String] = []
|
||||
|
||||
for dataDict in dataArray {
|
||||
if let mainUrl = dataDict["url"] as? String {
|
||||
urls.append(mainUrl)
|
||||
do {
|
||||
if let jsonObject = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any],
|
||||
let status = jsonObject["status"] as? String {
|
||||
|
||||
if status == "success", let nip94Event = jsonObject["nip94_event"] as? [String: Any] {
|
||||
|
||||
if let tags = nip94Event["tags"] as? [[String]] {
|
||||
for tagArray in tags {
|
||||
if tagArray.count > 1, tagArray[0] == "url" {
|
||||
return tagArray[1]
|
||||
}
|
||||
}
|
||||
|
||||
return urls.joined(separator: "\n")
|
||||
} else if status == "error", let message = jsonObject["message"] as? String {
|
||||
print("Upload Error: \(message)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Failed JSONSerialization")
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
case .nostrImg:
|
||||
guard let responseString = String(data: data, encoding: String.Encoding(rawValue: String.Encoding.utf8.rawValue)) else {
|
||||
print("Upload failed getting response string")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let startIndex = responseString.range(of: "https://i.nostrimg.com/")?.lowerBound else {
|
||||
} else if status == "error", let message = jsonObject["message"] as? String {
|
||||
print("Upload Error: \(message)")
|
||||
return nil
|
||||
}
|
||||
let stringContainingName = responseString[startIndex..<responseString.endIndex]
|
||||
guard let endIndex = stringContainingName.range(of: "\"")?.lowerBound else {
|
||||
return nil
|
||||
}
|
||||
let nostrBuildImageName = responseString[startIndex..<endIndex]
|
||||
let nostrBuildURL = "\(nostrBuildImageName)"
|
||||
return nostrBuildURL
|
||||
} catch {
|
||||
print("Failed JSONSerialization")
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,46 +256,3 @@ func find_tag_ref(type: String, id: String, tags: [[String]]) -> Int? {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
struct PostTags {
|
||||
let blocks: [Block]
|
||||
let tags: [[String]]
|
||||
}
|
||||
|
||||
/// Convert
|
||||
func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
|
||||
var new_tags = tags
|
||||
|
||||
for post_block in post_blocks {
|
||||
switch post_block {
|
||||
case .mention(let mention):
|
||||
switch(mention.ref) {
|
||||
case .note, .nevent:
|
||||
continue
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
new_tags.append(mention.ref.tag)
|
||||
case .hashtag(let hashtag):
|
||||
new_tags.append(["t", hashtag.lowercased()])
|
||||
case .text: break
|
||||
case .invoice: break
|
||||
case .relay: break
|
||||
case .url(let url):
|
||||
new_tags.append(["r", url.absoluteString])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return PostTags(blocks: post_blocks, tags: new_tags)
|
||||
}
|
||||
|
||||
func post_to_event(post: NostrPost, keypair: FullKeypair) -> NostrEvent? {
|
||||
let post_blocks = parse_post_blocks(content: post.content)
|
||||
let post_tags = make_post_tags(post_blocks: post_blocks, tags: post.tags)
|
||||
let content = post_tags.blocks
|
||||
.map(\.asString)
|
||||
.joined(separator: "")
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: post.kind.rawValue, tags: post_tags.tags)
|
||||
}
|
||||
|
||||
@@ -111,12 +111,16 @@ class MutelistManager {
|
||||
private func add_mute_item(_ item: MuteItem) {
|
||||
switch item {
|
||||
case .user(_, _):
|
||||
guard !users.contains(item) else { return }
|
||||
users.insert(item)
|
||||
case .hashtag(_, _):
|
||||
guard !hashtags.contains(item) else { return }
|
||||
hashtags.insert(item)
|
||||
case .word(_, _):
|
||||
guard !words.contains(item) else { return }
|
||||
words.insert(item)
|
||||
case .thread(_, _):
|
||||
guard !threads.contains(item) else { return }
|
||||
threads.insert(item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func process_local_notification(state: HeadlessDamusState, event ev: NostrEvent)
|
||||
|
||||
func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent, mode: UserSettingsStore.NotificationsMode) -> Bool {
|
||||
// Do not show notification if it's coming from a mode different from the one selected by our user
|
||||
guard state.settings.notifications_mode == mode else {
|
||||
guard state.settings.notification_mode == mode else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -61,36 +61,55 @@ func generate_local_notification_object(from ev: NostrEvent, state: HeadlessDamu
|
||||
|
||||
if type == .text, state.settings.mention_notification {
|
||||
let blocks = ev.blocks(state.keypair).blocks
|
||||
|
||||
for case .mention(let mention) in blocks {
|
||||
guard case .pubkey(let pk) = mention.ref, pk == state.keypair.pubkey else {
|
||||
continue
|
||||
}
|
||||
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .mention, event: ev, target: ev, content: content_preview)
|
||||
return LocalNotification(type: .mention, event: ev, target: .note(ev), content: content_preview)
|
||||
}
|
||||
|
||||
if ev.referenced_ids.contains(where: { note_id in
|
||||
guard let note_author: Pubkey = state.ndb.lookup_note(note_id)?.unsafeUnownedValue?.pubkey else { return false }
|
||||
guard note_author == state.keypair.pubkey else { return false }
|
||||
return true
|
||||
}) {
|
||||
// This is a reply to one of our posts
|
||||
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .reply, event: ev, target: .note(ev), content: content_preview)
|
||||
}
|
||||
|
||||
if ev.referenced_pubkeys.contains(state.keypair.pubkey) {
|
||||
// not mentioned or replied to, just tagged
|
||||
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .tagged, event: ev, target: .note(ev), content: content_preview)
|
||||
}
|
||||
|
||||
} else if type == .boost,
|
||||
state.settings.repost_notification,
|
||||
let inner_ev = ev.get_inner_event()
|
||||
{
|
||||
let content_preview = render_notification_content_preview(ev: inner_ev, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .repost, event: ev, target: inner_ev, content: content_preview)
|
||||
} else if type == .like,
|
||||
state.settings.like_notification,
|
||||
let evid = ev.referenced_ids.last,
|
||||
let txn = state.ndb.lookup_note(evid, txn_name: "local_notification_like"),
|
||||
let liked_event = txn.unsafeUnownedValue?.to_owned()
|
||||
{
|
||||
let content_preview = render_notification_content_preview(ev: liked_event, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .like, event: ev, target: liked_event, content: content_preview)
|
||||
return LocalNotification(type: .repost, event: ev, target: .note(inner_ev), content: content_preview)
|
||||
} else if type == .like, state.settings.like_notification, let evid = ev.referenced_ids.last {
|
||||
if let txn = state.ndb.lookup_note(evid, txn_name: "local_notification_like"),
|
||||
let liked_event = txn.unsafeUnownedValue
|
||||
{
|
||||
let content_preview = render_notification_content_preview(ev: liked_event, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .like, event: ev, target: .note(liked_event), content: content_preview)
|
||||
} else {
|
||||
return LocalNotification(type: .like, event: ev, target: .note_id(evid), content: "")
|
||||
}
|
||||
}
|
||||
else if type == .dm,
|
||||
state.settings.dm_notification {
|
||||
let convo = ev.decrypted(keypair: state.keypair) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message")
|
||||
return LocalNotification(type: .dm, event: ev, target: ev, content: convo)
|
||||
return LocalNotification(type: .dm, event: ev, target: .note(ev), content: convo)
|
||||
}
|
||||
else if type == .zap,
|
||||
state.settings.zap_notification {
|
||||
return LocalNotification(type: .zap, event: ev, target: ev, content: ev.content)
|
||||
return LocalNotification(type: .zap, event: ev, target: .note(ev), content: ev.content)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+77
-1
@@ -17,10 +17,86 @@ struct NostrPost {
|
||||
self.kind = kind
|
||||
self.tags = tags
|
||||
}
|
||||
|
||||
func to_event(keypair: FullKeypair) -> NostrEvent? {
|
||||
let post_blocks = self.parse_blocks()
|
||||
let post_tags = self.make_post_tags(post_blocks: post_blocks, tags: self.tags)
|
||||
let content = post_tags.blocks
|
||||
.map(\.asString)
|
||||
.joined(separator: "")
|
||||
|
||||
if self.kind == .highlight {
|
||||
var new_tags = post_tags.tags.filter({ $0[safe: 0] != "comment" })
|
||||
if content.count > 0 {
|
||||
new_tags.append(["comment", content])
|
||||
}
|
||||
return NostrEvent(content: self.content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: new_tags)
|
||||
}
|
||||
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: post_tags.tags)
|
||||
}
|
||||
|
||||
func parse_blocks() -> [Block] {
|
||||
guard let content_for_parsing = self.default_content_for_block_parsing() else { return [] }
|
||||
return parse_post_blocks(content: content_for_parsing)
|
||||
}
|
||||
|
||||
private func default_content_for_block_parsing() -> String? {
|
||||
switch kind {
|
||||
case .highlight:
|
||||
return tags.filter({ $0[safe: 0] == "comment" }).first?[safe: 1]
|
||||
default:
|
||||
return self.content
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the post's contents to find more tags to apply to the final nostr event
|
||||
func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
|
||||
var new_tags = tags
|
||||
|
||||
for post_block in post_blocks {
|
||||
switch post_block {
|
||||
case .mention(let mention):
|
||||
switch(mention.ref) {
|
||||
case .note, .nevent:
|
||||
continue
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if self.kind == .highlight, case .pubkey(_) = mention.ref {
|
||||
var new_tag = mention.ref.tag
|
||||
new_tag.append("mention")
|
||||
new_tags.append(new_tag)
|
||||
}
|
||||
else {
|
||||
new_tags.append(mention.ref.tag)
|
||||
}
|
||||
case .hashtag(let hashtag):
|
||||
new_tags.append(["t", hashtag.lowercased()])
|
||||
case .text: break
|
||||
case .invoice: break
|
||||
case .relay: break
|
||||
case .url(let url):
|
||||
new_tags.append(self.kind == .highlight ? ["r", url.absoluteString, "mention"] : ["r", url.absoluteString])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return PostTags(blocks: post_blocks, tags: new_tags)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper structures and functions
|
||||
|
||||
extension NostrPost {
|
||||
/// A struct used for temporarily holding tag information that was parsed from a post contents to aid in building a nostr event
|
||||
struct PostTags {
|
||||
let blocks: [Block]
|
||||
let tags: [[String]]
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a list of tags
|
||||
func parse_post_blocks(content: String) -> [Block] {
|
||||
return parse_note_content(content: .content(content, nil)).blocks
|
||||
}
|
||||
|
||||
@@ -11,35 +11,34 @@ struct PushNotificationClient {
|
||||
let keypair: Keypair
|
||||
let settings: UserSettingsStore
|
||||
private(set) var device_token: Data? = nil
|
||||
var device_token_hex: String? {
|
||||
guard let device_token else { return nil }
|
||||
return device_token.map { String(format: "%02.2hhx", $0) }.joined()
|
||||
}
|
||||
|
||||
mutating func set_device_token(new_device_token: Data) async throws {
|
||||
self.device_token = new_device_token
|
||||
if settings.enable_experimental_push_notifications && settings.notifications_mode == .push {
|
||||
if settings.enable_push_notifications && settings.notification_mode == .push {
|
||||
try await self.send_token()
|
||||
}
|
||||
}
|
||||
|
||||
func send_token() async throws {
|
||||
guard let device_token else { return }
|
||||
// Send the device token and pubkey to the server
|
||||
let token = device_token.map { String(format: "%02.2hhx", $0) }.joined()
|
||||
guard let token = device_token_hex else { return }
|
||||
|
||||
Log.info("Sending device token to server: %s", for: .push_notifications, token)
|
||||
|
||||
let pubkey = self.keypair.pubkey
|
||||
|
||||
// Send those as JSON to the server
|
||||
let json: [String: Any] = ["deviceToken": token, "pubkey": pubkey.hex()]
|
||||
|
||||
// create post request
|
||||
let url = self.settings.send_device_token_to_localhost ? Constants.DEVICE_TOKEN_RECEIVER_TEST_URL : Constants.DEVICE_TOKEN_RECEIVER_PRODUCTION_URL
|
||||
let json_data = try JSONSerialization.data(withJSONObject: json)
|
||||
|
||||
let url = self.current_push_notification_environment().api_base_url()
|
||||
.appendingPathComponent("user-info")
|
||||
.appendingPathComponent(self.keypair.pubkey.hex())
|
||||
.appendingPathComponent(token)
|
||||
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .post,
|
||||
method: .put,
|
||||
url: url,
|
||||
payload: json_data,
|
||||
payload: nil,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
@@ -58,26 +57,23 @@ struct PushNotificationClient {
|
||||
}
|
||||
|
||||
func revoke_token() async throws {
|
||||
guard let device_token else { return }
|
||||
// Send the device token and pubkey to the server
|
||||
let token = device_token.map { String(format: "%02.2hhx", $0) }.joined()
|
||||
guard let token = device_token_hex else { return }
|
||||
|
||||
Log.info("Revoking device token from server: %s", for: .push_notifications, token)
|
||||
|
||||
let pubkey = self.keypair.pubkey
|
||||
|
||||
// Send those as JSON to the server
|
||||
let json: [String: Any] = ["deviceToken": token, "pubkey": pubkey.hex()]
|
||||
|
||||
// create post request
|
||||
let url = self.settings.send_device_token_to_localhost ? Constants.DEVICE_TOKEN_REVOKER_TEST_URL : Constants.DEVICE_TOKEN_REVOKER_PRODUCTION_URL
|
||||
let json_data = try JSONSerialization.data(withJSONObject: json)
|
||||
let url = self.current_push_notification_environment().api_base_url()
|
||||
.appendingPathComponent("user-info")
|
||||
.appendingPathComponent(pubkey.hex())
|
||||
.appendingPathComponent(token)
|
||||
|
||||
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .post,
|
||||
method: .delete,
|
||||
url: url,
|
||||
payload: json_data,
|
||||
payload: nil,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
@@ -94,6 +90,78 @@ struct PushNotificationClient {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func set_settings(_ new_settings: NotificationSettings? = nil) async throws {
|
||||
// Send the device token and pubkey to the server
|
||||
guard let token = device_token_hex else { return }
|
||||
|
||||
Log.info("Sending notification preferences to the server", for: .push_notifications)
|
||||
|
||||
let url = self.current_push_notification_environment().api_base_url()
|
||||
.appendingPathComponent("user-info")
|
||||
.appendingPathComponent(self.keypair.pubkey.hex())
|
||||
.appendingPathComponent(token)
|
||||
.appendingPathComponent("preferences")
|
||||
|
||||
let json_payload = try JSONEncoder().encode(new_settings ?? NotificationSettings.from(settings: settings))
|
||||
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .put,
|
||||
url: url,
|
||||
payload: json_payload,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
Log.info("Sent notification settings to Damus push notification server successfully", for: .push_notifications)
|
||||
default:
|
||||
Log.error("Error in sending notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
|
||||
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func get_settings() async throws -> NotificationSettings {
|
||||
// Send the device token and pubkey to the server
|
||||
guard let token = device_token_hex else {
|
||||
throw ClientError.no_device_token
|
||||
}
|
||||
|
||||
let url = self.current_push_notification_environment().api_base_url()
|
||||
.appendingPathComponent("user-info")
|
||||
.appendingPathComponent(self.keypair.pubkey.hex())
|
||||
.appendingPathComponent(token)
|
||||
.appendingPathComponent("preferences")
|
||||
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .get,
|
||||
url: url,
|
||||
payload: nil,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
guard let notification_settings = NotificationSettings.from(json_data: data) else { throw ClientError.json_decoding_error }
|
||||
return notification_settings
|
||||
default:
|
||||
Log.error("Error in getting notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
|
||||
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
throw ClientError.could_not_process_response
|
||||
}
|
||||
|
||||
func current_push_notification_environment() -> Environment {
|
||||
return self.settings.push_notification_environment
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Helper structures
|
||||
@@ -101,5 +169,121 @@ struct PushNotificationClient {
|
||||
extension PushNotificationClient {
|
||||
enum ClientError: Error {
|
||||
case http_response_error(status_code: Int, response: Data)
|
||||
case could_not_process_response
|
||||
case no_device_token
|
||||
case json_decoding_error
|
||||
}
|
||||
|
||||
struct NotificationSettings: Codable, Equatable {
|
||||
let zap_notifications_enabled: Bool
|
||||
let mention_notifications_enabled: Bool
|
||||
let repost_notifications_enabled: Bool
|
||||
let reaction_notifications_enabled: Bool
|
||||
let dm_notifications_enabled: Bool
|
||||
let only_notifications_from_following_enabled: Bool
|
||||
|
||||
static func from(json_data: Data) -> Self? {
|
||||
guard let decoded = try? JSONDecoder().decode(Self.self, from: json_data) else { return nil }
|
||||
return decoded
|
||||
}
|
||||
|
||||
static func from(settings: UserSettingsStore) -> Self {
|
||||
return NotificationSettings(
|
||||
zap_notifications_enabled: settings.zap_notification,
|
||||
mention_notifications_enabled: settings.mention_notification,
|
||||
repost_notifications_enabled: settings.repost_notification,
|
||||
reaction_notifications_enabled: settings.like_notification,
|
||||
dm_notifications_enabled: settings.dm_notification,
|
||||
only_notifications_from_following_enabled: settings.notification_only_from_following
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum Environment: CaseIterable, Codable, Identifiable, StringCodable, Equatable, Hashable {
|
||||
static var allCases: [Environment] = [.local_test(host: nil), .staging, .production]
|
||||
|
||||
case local_test(host: String?)
|
||||
case staging
|
||||
case production
|
||||
|
||||
func text_description() -> String {
|
||||
switch self {
|
||||
case .local_test:
|
||||
return NSLocalizedString("Test (local)", comment: "Label indicating a local test environment for Push notification functionality (Developer feature)")
|
||||
case .production:
|
||||
return NSLocalizedString("Production", comment: "Label indicating the production environment for Push notification functionality")
|
||||
case .staging:
|
||||
return NSLocalizedString("Staging (for dev builds)", comment: "Label indicating the staging environment for Push notification functionality")
|
||||
}
|
||||
}
|
||||
|
||||
func api_base_url() -> URL {
|
||||
switch self {
|
||||
case .local_test(let host):
|
||||
URL(string: "http://\(host ?? "localhost:8000")") ?? Constants.PUSH_NOTIFICATION_SERVER_TEST_BASE_URL
|
||||
case .production:
|
||||
Constants.PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL
|
||||
case .staging:
|
||||
Constants.PUSH_NOTIFICATION_SERVER_STAGING_BASE_URL
|
||||
}
|
||||
}
|
||||
|
||||
func custom_host() -> String? {
|
||||
switch self {
|
||||
case .local_test(let host):
|
||||
return host
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
init?(from string: String) {
|
||||
switch string {
|
||||
case "local_test":
|
||||
self = .local_test(host: nil)
|
||||
case "production":
|
||||
self = .production
|
||||
case "staging":
|
||||
self = .staging
|
||||
default:
|
||||
let components = string.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
if components.count == 2 && components[0] == "local_test" {
|
||||
self = .local_test(host: String(components[1]))
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func to_string() -> String {
|
||||
switch self {
|
||||
case .local_test(let host):
|
||||
if let host {
|
||||
return "local_test:\(host)"
|
||||
}
|
||||
return "local_test"
|
||||
case .staging:
|
||||
return "staging"
|
||||
case .production:
|
||||
return "production"
|
||||
}
|
||||
}
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .local_test(let host):
|
||||
if let host {
|
||||
return "local_test:\(host)"
|
||||
}
|
||||
else {
|
||||
return "local_test"
|
||||
}
|
||||
case .production:
|
||||
return "production"
|
||||
case .staging:
|
||||
return "staging"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,15 @@ import Foundation
|
||||
class ThreadModel: ObservableObject {
|
||||
@Published var event: NostrEvent
|
||||
let original_event: NostrEvent
|
||||
let highlight: String?
|
||||
var event_map: Set<NostrEvent>
|
||||
|
||||
init(event: NostrEvent, damus_state: DamusState) {
|
||||
init(event: NostrEvent, damus_state: DamusState, highlight: String? = nil) {
|
||||
self.damus_state = damus_state
|
||||
self.event_map = Set()
|
||||
self.event = event
|
||||
self.original_event = event
|
||||
self.highlight = highlight
|
||||
add_event(event, keypair: damus_state.keypair)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,13 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable {
|
||||
var model: Model {
|
||||
switch self {
|
||||
case .none:
|
||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("none_translation_service", value: "None", comment: "Dropdown option for selecting no translation service."))
|
||||
let displayName: String
|
||||
if TranslationService.isAppleTranslationPopoverSupported {
|
||||
displayName = NSLocalizedString("apple_translation_service", value: "Apple", comment: "Dropdown option for selecting Apple as a translation service.")
|
||||
} else {
|
||||
displayName = NSLocalizedString("none_translation_service", value: "None", comment: "Dropdown option for selecting no translation service.")
|
||||
}
|
||||
return .init(tag: self.rawValue, displayName: displayName)
|
||||
case .purple:
|
||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("Damus Purple", comment: "Dropdown option for selecting Damus Purple as a translation service."))
|
||||
case .libretranslate:
|
||||
@@ -51,4 +57,16 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable {
|
||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("translate.nostr.wine (DeepL, Pay with BTC)", comment: "Dropdown option for selecting translate.nostr.wine as the translation service."))
|
||||
}
|
||||
}
|
||||
|
||||
static var isAppleTranslationPopoverSupported: Bool {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
return false
|
||||
#else
|
||||
if #available(iOS 17.4, macOS 14.4, *) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,8 +155,8 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "like_notification", default_value: true)
|
||||
var like_notification: Bool
|
||||
|
||||
@StringSetting(key: "notifications_mode", default_value: .local)
|
||||
var notifications_mode: NotificationsMode
|
||||
@StringSetting(key: "notification_mode", default_value: .push)
|
||||
var notification_mode: NotificationsMode
|
||||
|
||||
@Setting(key: "notification_only_from_following", default_value: false)
|
||||
var notification_only_from_following: Bool
|
||||
@@ -207,11 +207,12 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "always_show_onboarding_suggestions", default_value: false)
|
||||
var always_show_onboarding_suggestions: Bool
|
||||
|
||||
@Setting(key: "enable_experimental_push_notifications", default_value: false)
|
||||
var enable_experimental_push_notifications: Bool
|
||||
// @Setting(key: "enable_experimental_push_notifications", default_value: false)
|
||||
// This was a feature flag setting during early development, but now this is enabled for everyone.
|
||||
var enable_push_notifications: Bool = true
|
||||
|
||||
@Setting(key: "send_device_token_to_localhost", default_value: false)
|
||||
var send_device_token_to_localhost: Bool
|
||||
@StringSetting(key: "push_notification_environment", default_value: .production)
|
||||
var push_notification_environment: PushNotificationClient.Environment
|
||||
|
||||
@Setting(key: "enable_experimental_purple_api", default_value: false)
|
||||
var enable_experimental_purple_api: Bool
|
||||
|
||||
@@ -12,11 +12,15 @@ struct SwipeToDismissModifier: ViewModifier {
|
||||
var onDismiss: () -> Void
|
||||
@State private var offset: CGSize = .zero
|
||||
@GestureState private var viewOffset: CGSize = .zero
|
||||
|
||||
let threshold_offset: CGFloat = 100.0
|
||||
let minimum_opacity: CGFloat = 0.1
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.offset(y: viewOffset.height)
|
||||
.animation(.interactiveSpring(), value: viewOffset)
|
||||
.opacity(max(min(1.0 - (abs(offset.height) / threshold_offset), 1.0), minimum_opacity))
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: minDistance ?? 10)
|
||||
.updating($viewOffset, body: { value, gestureState, transaction in
|
||||
@@ -28,7 +32,7 @@ struct SwipeToDismissModifier: ViewModifier {
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
if abs(offset.height) > 100 {
|
||||
if abs(offset.height) > threshold_offset {
|
||||
onDismiss()
|
||||
} else {
|
||||
offset = .zero
|
||||
|
||||
@@ -122,20 +122,22 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
case hashtag(Hashtag)
|
||||
case param(TagElem)
|
||||
case naddr(NAddr)
|
||||
case reference(String)
|
||||
|
||||
var key: RefKey {
|
||||
switch self {
|
||||
case .event: return .e
|
||||
case .pubkey: return .p
|
||||
case .quote: return .q
|
||||
case .hashtag: return .t
|
||||
case .param: return .d
|
||||
case .naddr: return .a
|
||||
case .event: return .e
|
||||
case .pubkey: return .p
|
||||
case .quote: return .q
|
||||
case .hashtag: return .t
|
||||
case .param: return .d
|
||||
case .naddr: return .a
|
||||
case .reference: return .r
|
||||
}
|
||||
}
|
||||
|
||||
enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible {
|
||||
case e, p, t, d, q, a
|
||||
case e, p, t, d, q, a, r
|
||||
|
||||
var keychar: AsciiCharacter {
|
||||
self.rawValue
|
||||
@@ -159,6 +161,8 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
case .param(let string): return string.string()
|
||||
case .naddr(let naddr):
|
||||
return naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier
|
||||
case .reference(let string):
|
||||
return string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +183,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
case .t: return .hashtag(Hashtag(hashtag: t1.string()))
|
||||
case .d: return .param(t1)
|
||||
case .a: return .naddr(NAddr(identifier: "", author: Pubkey(Data()), relays: [], kind: 0))
|
||||
case .r: return .reference(t1.string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,10 @@ final class RelayConnection: ObservableObject {
|
||||
|
||||
if err == nil {
|
||||
self.last_pong = .now
|
||||
Log.info("Got pong from '%s'", for: .networking, self.relay_url.absoluteString)
|
||||
self.log?.add("Successful ping")
|
||||
} else {
|
||||
print("pong failed, reconnecting \(self.relay_url.id)")
|
||||
Log.info("Ping failed, reconnecting to '%s'", for: .networking, self.relay_url.absoluteString)
|
||||
self.isConnected = false
|
||||
self.isConnecting = false
|
||||
self.reconnect_with_backoff()
|
||||
@@ -126,7 +127,7 @@ final class RelayConnection: ObservableObject {
|
||||
self.receive(message: message)
|
||||
case .disconnected(let closeCode, let reason):
|
||||
if closeCode != .normalClosure {
|
||||
print("⚠️ Warning: RelayConnection (\(self.relay_url)) closed with code \(closeCode), reason: \(String(describing: reason))")
|
||||
Log.error("⚠️ Warning: RelayConnection (%d) closed with code: %s", for: .networking, String(describing: closeCode), String(describing: reason))
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.isConnected = false
|
||||
@@ -134,12 +135,16 @@ final class RelayConnection: ObservableObject {
|
||||
self.reconnect()
|
||||
}
|
||||
case .error(let error):
|
||||
print("⚠️ Warning: RelayConnection (\(self.relay_url)) error: \(error)")
|
||||
Log.error("⚠️ Warning: RelayConnection (%s) error: %s", for: .networking, self.relay_url.absoluteString, error.localizedDescription)
|
||||
let nserr = error as NSError
|
||||
if nserr.domain == NSPOSIXErrorDomain && nserr.code == 57 {
|
||||
// ignore socket not connected?
|
||||
return
|
||||
}
|
||||
if nserr.domain == NSURLErrorDomain && nserr.code == -999 {
|
||||
// these aren't real error, it just means task was cancelled
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.isConnected = false
|
||||
self.isConnecting = false
|
||||
@@ -156,14 +161,21 @@ final class RelayConnection: ObservableObject {
|
||||
}
|
||||
|
||||
func reconnect_with_backoff() {
|
||||
self.backoff *= 1.5
|
||||
self.backoff *= 2.0
|
||||
self.reconnect_in(after: self.backoff)
|
||||
}
|
||||
|
||||
func reconnect() {
|
||||
guard !isConnecting && !isDisabled else {
|
||||
self.log?.add("Cancelling reconnect, already connecting")
|
||||
return // we're already trying to connect or we're disabled
|
||||
}
|
||||
|
||||
guard !self.isConnected else {
|
||||
self.log?.add("Cancelling reconnect, already connected")
|
||||
return
|
||||
}
|
||||
|
||||
disconnect()
|
||||
connect()
|
||||
log?.add("Reconnecting...")
|
||||
|
||||
@@ -89,6 +89,7 @@ class RelayPool {
|
||||
}
|
||||
|
||||
func ping() {
|
||||
Log.info("Pinging %d relays", for: .networking, relays.count)
|
||||
for relay in relays {
|
||||
relay.connection.ping()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// PresentFullScreenItemNotify.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-11-01.
|
||||
//
|
||||
|
||||
struct PresentFullScreenItemNotify: Notify {
|
||||
typealias Payload = FullScreenItem
|
||||
var payload: Payload
|
||||
}
|
||||
|
||||
extension NotifyHandler {
|
||||
static var present_full_screen_item: NotifyHandler<PresentFullScreenItemNotify> {
|
||||
.init()
|
||||
}
|
||||
}
|
||||
|
||||
extension Notifications {
|
||||
static func present_full_screen_item(_ item: FullScreenItem) -> Notifications<PresentFullScreenItemNotify> {
|
||||
.init(.init(payload: item))
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the app to present an item in full screen. Use this when presenting items coming from a timeline or any lazy stack.
|
||||
///
|
||||
/// ## Usage notes
|
||||
///
|
||||
/// Use this instead of `.damus_full_screen_cover` when the source view is on a lazy stack or timeline.
|
||||
///
|
||||
/// The reason is that when using a full screen modifier in those scenarios, the full screen view may abruptly disappear.
|
||||
/// One example is when showing videos from the timeline in full screen, where changing the orientation of the device (landscape/portrait)
|
||||
/// can cause the source view to be unloaded by the lazy stack, making your full screen overlay to simply disappear, causing a feeling of flakiness to the app
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// The requests from this function will be received and handled at the top level app view (`ContentView`), which contains a `.damus_full_screen_cover`.
|
||||
///
|
||||
func present(full_screen_item: FullScreenItem) {
|
||||
notify(.present_full_screen_item(full_screen_item))
|
||||
}
|
||||
|
||||
@@ -36,6 +36,13 @@ let test_short_note =
|
||||
createdAt: UInt32(Date().timeIntervalSince1970 - 100)
|
||||
)!
|
||||
|
||||
let test_super_short_note =
|
||||
NostrEvent(
|
||||
content: "A",
|
||||
keypair: jack_keypair,
|
||||
createdAt: UInt32(Date().timeIntervalSince1970 - 100)
|
||||
)!
|
||||
|
||||
let test_note_json_with_escaped_slash = "{\"tags\":[],\"pubkey\":\"f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9\",\"content\":\"https:\\/\\/cdn.nostr.build\\/i\\/5c1d3296f66c2630131bf123106486aeaf051ed8466031c0e0532d70b33cddb2.jpg\",\"created_at\":1691864981,\"kind\":1,\"sig\":\"fc0033aa3d4df50b692a5b346fa816fdded698de2045e36e0642a021391468c44ca69c2471adc7e92088131872d4aaa1e90ea6e1ad97f3cc748f4aed96dfae18\",\"id\":\"e8f6eca3b161abba034dac9a02bb6930ecde9fd2fb5d6c5f22a05526e11382cb\"}"
|
||||
let test_encoded_note_with_image = NostrEvent.owned_from_json(json: test_note_json_with_escaped_slash)
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ import Foundation
|
||||
class Constants {
|
||||
//static let EXAMPLE_DEMOS: DamusState = .empty
|
||||
static let DAMUS_APP_GROUP_IDENTIFIER: String = "group.com.damus"
|
||||
static let DEVICE_TOKEN_RECEIVER_PRODUCTION_URL: URL = URL(string: "http://45.33.32.5:8000/user-info")!
|
||||
static let DEVICE_TOKEN_RECEIVER_TEST_URL: URL = URL(string: "http://localhost:8000/user-info")!
|
||||
static let DEVICE_TOKEN_REVOKER_PRODUCTION_URL: URL = URL(string: "http://45.33.32.5:8000/user-info/remove")!
|
||||
static let DEVICE_TOKEN_REVOKER_TEST_URL: URL = URL(string: "http://localhost:8000/user-info/remove")!
|
||||
static let MAIN_APP_BUNDLE_IDENTIFIER: String = "com.jb55.damus2"
|
||||
static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService"
|
||||
|
||||
// MARK: Push notification server
|
||||
static let PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL: URL = URL(string: "https://notify.damus.io")!
|
||||
static let PUSH_NOTIFICATION_SERVER_STAGING_BASE_URL: URL = URL(string: "https://notify-staging.damus.io")!
|
||||
static let PUSH_NOTIFICATION_SERVER_TEST_BASE_URL: URL = URL(string: "http://localhost:8000")!
|
||||
|
||||
// MARK: Purple
|
||||
// API
|
||||
static let PURPLE_API_LOCAL_TEST_BASE_URL: URL = URL(string: "http://localhost:8989")!
|
||||
@@ -30,4 +31,7 @@ class Constants {
|
||||
static let DAMUS_WEBSITE_LOCAL_TEST_URL: URL = URL(string: "http://localhost:3000")!
|
||||
static let DAMUS_WEBSITE_STAGING_URL: URL = URL(string: "https://staging.damus.io")!
|
||||
static let DAMUS_WEBSITE_PRODUCTION_URL: URL = URL(string: "https://damus.io")!
|
||||
|
||||
// MARK: General constants
|
||||
static let GIF_IMAGE_TYPE: String = "com.compuserve.gif"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//
|
||||
// DamusAliases.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-08-12.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
let this_app: UIApplication = UIApplication.shared
|
||||
+15
-11
@@ -244,16 +244,12 @@ class EventCache {
|
||||
}
|
||||
}
|
||||
|
||||
func should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore, note_lang: String?) -> Bool {
|
||||
guard settings.can_translate else {
|
||||
return false
|
||||
}
|
||||
|
||||
func should_translate(event: NostrEvent, our_keypair: Keypair, note_lang: String?) -> Bool {
|
||||
// don't translate reposts, longform, etc
|
||||
if event.kind != 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Do not translate self-authored notes if logged in with a private key
|
||||
// as we can assume the user can understand their own notes.
|
||||
// The detected language prediction could be incorrect and not in the list of preferred languages.
|
||||
@@ -261,25 +257,33 @@ func should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSet
|
||||
if our_keypair.privkey != nil && our_keypair.pubkey == event.pubkey {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
if let note_lang {
|
||||
let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
|
||||
|
||||
|
||||
// Don't translate if its in our preferred languages
|
||||
guard !preferredLanguages.contains(note_lang) else {
|
||||
// if its the same, give up and don't retry
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// we should start translating if we have auto_translate on
|
||||
return true
|
||||
}
|
||||
|
||||
func can_and_should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore, note_lang: String?) -> Bool {
|
||||
guard settings.can_translate else {
|
||||
return false
|
||||
}
|
||||
|
||||
return should_translate(event: event, our_keypair: our_keypair, note_lang: note_lang)
|
||||
}
|
||||
|
||||
func should_preload_translation(event: NostrEvent, our_keypair: Keypair, current_status: TranslateStatus, settings: UserSettingsStore, note_lang: String?) -> Bool {
|
||||
switch current_status {
|
||||
case .havent_tried:
|
||||
return should_translate(event: event, our_keypair: our_keypair, settings: settings, note_lang: note_lang) && settings.auto_translate
|
||||
return can_and_should_translate(event: event, our_keypair: our_keypair, settings: settings, note_lang: note_lang) && settings.auto_translate
|
||||
case .translating: return false
|
||||
case .translated: return false
|
||||
case .not_needed: return false
|
||||
@@ -413,7 +417,7 @@ func preload_event(plan: PreloadPlan, state: DamusState) async {
|
||||
|
||||
var translations: TranslateStatus? = nil
|
||||
// We have to recheck should_translate here now that we have note_language
|
||||
if plan.load_translations && should_translate(event: plan.event, our_keypair: our_keypair, settings: settings, note_lang: note_language) && settings.auto_translate
|
||||
if plan.load_translations && can_and_should_translate(event: plan.event, our_keypair: our_keypair, settings: settings, note_lang: note_language) && settings.auto_translate
|
||||
{
|
||||
translations = await translate_note(profiles: profiles, keypair: our_keypair, event: plan.event, settings: settings, note_lang: note_language, purple: state.purple)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,22 @@ extension KFOptionSetter {
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
/// This allows you to observe the size of the image, and get a callback when the size changes
|
||||
/// This is useful for when you need to layout views based on the size of the image
|
||||
/// - Parameter size_changed: A callback that will be called when the size of the image changes
|
||||
/// - Returns: The same KFOptionSetter instance
|
||||
func observe_image_size(size_changed: @escaping (CGSize) -> Void) -> Self {
|
||||
let modifier = AnyImageModifier { image -> KFCrossPlatformImage in
|
||||
let image_size = image.size
|
||||
DispatchQueue.main.async { [size_changed, image_size] in
|
||||
size_changed(image_size)
|
||||
}
|
||||
return image
|
||||
}
|
||||
options.imageModifier = modifier
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
let MAX_FILE_SIZE = 20_971_520 // 20MiB
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// OffsetExtension.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 9/6/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum SwipeDirection {
|
||||
case up
|
||||
case down
|
||||
case none
|
||||
}
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func offsetY(completion: @escaping (CGFloat, CGFloat)->())->some View {
|
||||
self
|
||||
.modifier(OffsetHelper(onChange: completion))
|
||||
}
|
||||
|
||||
func safeArea() -> UIEdgeInsets {
|
||||
guard let scene = this_app.connectedScenes.first as? UIWindowScene else{return .zero}
|
||||
guard let safeArea = scene.windows.first?.safeAreaInsets else{return .zero}
|
||||
return safeArea
|
||||
}
|
||||
}
|
||||
|
||||
struct OffsetHelper: ViewModifier{
|
||||
var onChange: (CGFloat,CGFloat)->()
|
||||
@State var currentOffset: CGFloat = 0
|
||||
@State var previousOffset: CGFloat = 0
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
GeometryReader{proxy in
|
||||
let minY = proxy.frame(in: .named("scroll")).minY
|
||||
Color.clear
|
||||
.preference(key: OffsetKey.self, value: minY)
|
||||
.onPreferenceChange(OffsetKey.self) { value in
|
||||
previousOffset = currentOffset
|
||||
currentOffset = value
|
||||
onChange(previousOffset,currentOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OffsetKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
struct HeaderBoundsKey: PreferenceKey{
|
||||
static var defaultValue: Anchor<CGRect>?
|
||||
|
||||
static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
func getSafeAreaTop()->CGFloat{
|
||||
guard let scene = this_app.connectedScenes.first as? UIWindowScene else{return .zero}
|
||||
guard let topSafeArea = scene.windows.first?.safeAreaInsets.top else{return .zero}
|
||||
return topSafeArea
|
||||
}
|
||||
|
||||
func getSafeAreaBottom()->CGFloat{
|
||||
guard let scene = this_app.connectedScenes.first as? UIWindowScene else{return .zero}
|
||||
guard let bottomSafeArea = scene.windows.first?.safeAreaInsets.bottom else{return .zero}
|
||||
return bottomSafeArea
|
||||
}
|
||||
@@ -60,7 +60,8 @@ struct ImageMetadata: Equatable {
|
||||
|
||||
func process_blurhash(blurhash: String, size: CGSize?) async -> UIImage? {
|
||||
let res = Task.detached(priority: .low) {
|
||||
let size = get_blurhash_size(img_size: size ?? CGSize(width: 100.0, height: 100.0))
|
||||
let default_size = CGSize(width: 100.0, height: 100.0)
|
||||
let size = get_blurhash_size(img_size: size ?? default_size) ?? default_size
|
||||
guard let img = UIImage.init(blurHash: blurhash, size: size) else {
|
||||
let noimg: UIImage? = nil
|
||||
return noimg
|
||||
@@ -135,7 +136,8 @@ extension UIImage {
|
||||
}
|
||||
}
|
||||
|
||||
func get_blurhash_size(img_size: CGSize) -> CGSize {
|
||||
func get_blurhash_size(img_size: CGSize) -> CGSize? {
|
||||
guard img_size.width > 0 && img_size.height > 0 else { return nil }
|
||||
return CGSize(width: 100.0, height: (100.0/img_size.width) * img_size.height)
|
||||
}
|
||||
|
||||
@@ -145,7 +147,7 @@ func calculate_blurhash(img: UIImage) async -> String? {
|
||||
}
|
||||
|
||||
let res = Task.detached(priority: .low) {
|
||||
let bhs = get_blurhash_size(img_size: img.size)
|
||||
let bhs = get_blurhash_size(img_size: img.size) ?? CGSize(width: 100.0, height: 100.0)
|
||||
let smaller = img.resized(to: bhs)
|
||||
|
||||
guard let blurhash = smaller.blurHash(numberOfComponents: (5,5)) else {
|
||||
|
||||
@@ -30,7 +30,7 @@ public struct DismissKeyboardOnTap: ViewModifier {
|
||||
}
|
||||
|
||||
public func end_editing() {
|
||||
UIApplication.shared.connectedScenes
|
||||
this_app.connectedScenes
|
||||
.filter {$0.activationState == .foregroundActive}
|
||||
.map {$0 as? UIWindowScene}
|
||||
.compactMap({$0})
|
||||
|
||||
@@ -48,10 +48,24 @@ struct LossyLocalNotification {
|
||||
}
|
||||
}
|
||||
|
||||
enum NotificationTarget {
|
||||
case note(NostrEvent)
|
||||
case note_id(NoteId)
|
||||
|
||||
var id: NoteId {
|
||||
switch self {
|
||||
case .note(let note):
|
||||
return note.id
|
||||
case .note_id(let id):
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalNotification {
|
||||
let type: LocalNotificationType
|
||||
let event: NostrEvent
|
||||
let target: NostrEvent
|
||||
let target: NotificationTarget
|
||||
let content: String
|
||||
|
||||
func to_lossy() -> LossyLocalNotification {
|
||||
@@ -63,6 +77,8 @@ enum LocalNotificationType: String {
|
||||
case dm
|
||||
case like
|
||||
case mention
|
||||
case reply
|
||||
case tagged
|
||||
case repost
|
||||
case zap
|
||||
case profile_zap
|
||||
|
||||
@@ -7,16 +7,12 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
func bundleForLocale(locale: Locale?) -> Bundle {
|
||||
if locale == nil {
|
||||
return Bundle.main
|
||||
}
|
||||
|
||||
let path = Bundle.main.path(forResource: locale!.identifier, ofType: "lproj")
|
||||
func bundleForLocale(locale: Locale) -> Bundle {
|
||||
let path = Bundle.main.path(forResource: locale.identifier, ofType: "lproj")
|
||||
return path != nil ? (Bundle(path: path!) ?? Bundle.main) : Bundle.main
|
||||
}
|
||||
|
||||
func localizedStringFormat(key: String, locale: Locale?) -> String {
|
||||
func localizedStringFormat(key: String, locale: Locale) -> String {
|
||||
let bundle = bundleForLocale(locale: locale)
|
||||
let fallback = bundleForLocale(locale: Locale(identifier: "en-US")).localizedString(forKey: key, value: nil, table: nil)
|
||||
return bundle.localizedString(forKey: key, value: fallback, table: nil)
|
||||
|
||||
@@ -13,9 +13,11 @@ enum LogCategory: String {
|
||||
case nav
|
||||
case render
|
||||
case storage
|
||||
case networking
|
||||
case push_notifications
|
||||
case damus_purple
|
||||
case image_uploading
|
||||
case video_coordination
|
||||
}
|
||||
|
||||
/// Damus structured logger
|
||||
|
||||
@@ -37,6 +37,7 @@ enum Route: Hashable {
|
||||
case Reactions(reactions: EventsModel)
|
||||
case Zaps(target: ZapTarget)
|
||||
case Search(search: SearchModel)
|
||||
case NDBSearch(results: Binding<[NostrEvent]>)
|
||||
case EULA
|
||||
case Login
|
||||
case CreateAccount
|
||||
@@ -105,6 +106,8 @@ enum Route: Hashable {
|
||||
ZapsView(state: damusState, target: target)
|
||||
case .Search(let search):
|
||||
SearchView(appstate: damusState, search: search)
|
||||
case .NDBSearch(let results):
|
||||
NDBSearchView(damus_state: damusState, results: results)
|
||||
case .EULA:
|
||||
EULAView(nav: navigationCoordinator)
|
||||
case .Login:
|
||||
@@ -200,6 +203,8 @@ enum Route: Hashable {
|
||||
case .Search(let search):
|
||||
hasher.combine("search")
|
||||
hasher.combine(search.search)
|
||||
case .NDBSearch(let results):
|
||||
hasher.combine("results")
|
||||
case .EULA:
|
||||
hasher.combine("eula")
|
||||
case .Login:
|
||||
|
||||
@@ -11,8 +11,7 @@ import UIKit
|
||||
class Theme {
|
||||
|
||||
static var safeAreaInsets: UIEdgeInsets? {
|
||||
return UIApplication
|
||||
.shared
|
||||
return this_app
|
||||
.connectedScenes
|
||||
.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }
|
||||
.first { $0.isKeyWindow }?.safeAreaInsets
|
||||
|
||||
+6
-19
@@ -309,14 +309,10 @@ struct Zap {
|
||||
return nil
|
||||
}
|
||||
*/
|
||||
guard let desc = get_zap_description(zap_ev, inv_desc: zap_invoice.description) else {
|
||||
guard let zap_req = get_zap_request(zap_ev) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let zap_req = decode_nostr_event_json(desc) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard validate_event(ev: zap_req) == .ok else {
|
||||
return nil
|
||||
}
|
||||
@@ -399,21 +395,12 @@ func event_has_tag(ev: NostrEvent, tag: String) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
/// Fetches the description from either the invoice, or tags, depending on the type of invoice
|
||||
func get_zap_description(_ ev: NostrEvent, inv_desc: InvoiceDescription) -> String? {
|
||||
switch inv_desc {
|
||||
case .description(let string):
|
||||
return string
|
||||
case .description_hash(let deschash):
|
||||
guard let desc = event_tag(ev, name: "description") else {
|
||||
return nil
|
||||
}
|
||||
guard let data = desc.data(using: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return desc
|
||||
func get_zap_request(_ ev: NostrEvent) -> NostrEvent? {
|
||||
guard let desc = event_tag(ev, name: "description") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return decode_nostr_event_json(desc)
|
||||
}
|
||||
|
||||
func invoice_to_zap_invoice(_ invoice: Invoice) -> ZapInvoice? {
|
||||
|
||||
@@ -116,7 +116,7 @@ struct AddRelayView: View {
|
||||
}
|
||||
new_relay = ""
|
||||
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
|
||||
dismiss()
|
||||
}) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// AppAccessibilityIdentifiers.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-11-18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A collection of app-wide identifier constants used to facilitate UI tests to find the element they are looking for.
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - This is not an exhaustive list. Add more identifiers as needed.
|
||||
/// - Organize this by separating each category with `MARK` comment markers and a unique prefix, each category separated by 2 empty lines
|
||||
enum AppAccessibilityIdentifiers: String {
|
||||
// MARK: Login
|
||||
// Prefix: `sign_in`
|
||||
|
||||
/// Sign in button at the very start of the app
|
||||
case sign_in_option_button
|
||||
/// A secure text entry field where the user can put their private key when logging in
|
||||
case sign_in_nsec_key_entry_field
|
||||
/// Button to sign in after entering private key
|
||||
case sign_in_confirm_button
|
||||
|
||||
|
||||
// MARK: Onboarding
|
||||
// Prefix: `onboarding`
|
||||
|
||||
/// The skip button on the onboarding sheet
|
||||
case onboarding_sheet_skip_button
|
||||
|
||||
|
||||
// MARK: Post composer
|
||||
// Prefix: `post_composer`
|
||||
|
||||
/// The cancel post button
|
||||
case post_composer_cancel_button
|
||||
|
||||
// MARK: Main interface layout
|
||||
// Prefix: `main`
|
||||
|
||||
/// Profile picture item on the top toolbar, used to open the side menu
|
||||
case main_side_menu_button
|
||||
|
||||
|
||||
// MARK: Side menu
|
||||
// Prefix: `side_menu`
|
||||
|
||||
/// The profile option in the side menu
|
||||
case side_menu_profile_button
|
||||
|
||||
|
||||
// MARK: Items specific to the user's own profile
|
||||
// Prefix: `own_profile`
|
||||
|
||||
/// The edit profile button
|
||||
case own_profile_edit_button
|
||||
|
||||
/// The button to edit the banner image on the profile
|
||||
case own_profile_banner_image_edit_button
|
||||
|
||||
/// The button to pick the new banner image from URL
|
||||
case own_profile_banner_image_edit_from_url
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func create_upload_request(mediaToUpload: MediaUpload, mediaUploader: MediaUploa
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
// If uploading to a media host that support NIP-98 authorization, add the header
|
||||
if mediaUploader == .nostrBuild,
|
||||
if mediaUploader == .nostrBuild || mediaUploader == .nostrcheck,
|
||||
let keypair,
|
||||
let method = request.httpMethod,
|
||||
let signature = create_nip98_signature(keypair: keypair, method: method, url: url) {
|
||||
|
||||
@@ -13,7 +13,8 @@ struct EditBannerImageView: View {
|
||||
var damus_state: DamusState
|
||||
@ObservedObject var viewModel: ImageUploadingObserver
|
||||
let callback: (URL?) -> Void
|
||||
let defaultImage = UIImage(named: "profile-banner") ?? UIImage()
|
||||
let defaultImage = UIImage(named: "damoose") ?? UIImage()
|
||||
let safeAreaInsets: EdgeInsets
|
||||
|
||||
@State var banner_image: URL? = nil
|
||||
|
||||
@@ -29,8 +30,23 @@ struct EditBannerImageView: View {
|
||||
Color(uiColor: .secondarySystemBackground)
|
||||
}
|
||||
.onFailureImage(defaultImage)
|
||||
.kfClickable()
|
||||
|
||||
EditPictureControl(uploader: damus_state.settings.default_media_uploader, pubkey: damus_state.pubkey, image_url: $banner_image, uploadObserver: viewModel, callback: callback)
|
||||
EditPictureControl(uploader: damus_state.settings.default_media_uploader, keypair: damus_state.keypair, pubkey: damus_state.pubkey, image_url: $banner_image, uploadObserver: viewModel, callback: callback)
|
||||
.padding(10)
|
||||
.backwardsCompatibleSafeAreaPadding(self.safeAreaInsets)
|
||||
.accessibilityLabel(NSLocalizedString("Edit banner image", comment: "Accessibility label for edit banner image button"))
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.own_profile_banner_image_edit_button.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
fileprivate func backwardsCompatibleSafeAreaPadding(_ insets: EdgeInsets) -> some View {
|
||||
if #available(iOS 17.0, *) {
|
||||
return self.safeAreaPadding(insets)
|
||||
} else {
|
||||
return self.padding(.top, insets.top)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +54,7 @@ struct EditBannerImageView: View {
|
||||
struct InnerBannerImageView: View {
|
||||
let disable_animation: Bool
|
||||
let url: URL?
|
||||
let defaultImage = UIImage(named: "profile-banner") ?? UIImage()
|
||||
let defaultImage = UIImage(named: "damoose") ?? UIImage()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -54,6 +70,7 @@ struct InnerBannerImageView: View {
|
||||
Color(uiColor: .secondarySystemBackground)
|
||||
}
|
||||
.onFailureImage(defaultImage)
|
||||
.kfClickable()
|
||||
} else {
|
||||
Image(uiImage: defaultImage).resizable()
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ struct BookmarksView: View {
|
||||
ScrollView {
|
||||
InnerTimelineView(events: EventHolder(events: bookmarks, incoming: []), damus: state, filter: noneFilter)
|
||||
}
|
||||
.padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.switched_timeline)) { _ in
|
||||
|
||||
@@ -14,12 +14,12 @@ struct FriendsButton: View {
|
||||
Button(action: {
|
||||
switch self.filter {
|
||||
case .all:
|
||||
self.filter = .friends
|
||||
case .friends:
|
||||
self.filter = .friends_of_friends
|
||||
case .friends_of_friends:
|
||||
self.filter = .all
|
||||
}
|
||||
}) {
|
||||
if filter == .friends {
|
||||
if filter == .friends_of_friends {
|
||||
LINEAR_GRADIENT
|
||||
.mask(Image("user-added")
|
||||
.resizable()
|
||||
@@ -28,7 +28,7 @@ struct FriendsButton: View {
|
||||
Image("user-added")
|
||||
.resizable()
|
||||
.frame(width: 28, height: 28)
|
||||
.foregroundColor(DamusColors.adaptableGrey)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -29,13 +29,18 @@ struct GradientFollowButton: View {
|
||||
.fontWeight(.medium)
|
||||
.padding([.top, .bottom], 10)
|
||||
.padding([.leading, .trailing], 12)
|
||||
.background(follow_state == .unfollows ? PinkGradient : GrayGradient)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(grayBorder, lineWidth: follow_state == .unfollows ? 0 : 1)
|
||||
.frame(width: 100)
|
||||
)
|
||||
.frame(width: 100)
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.background(follow_state == .unfollows ? PinkGradient : GrayGradient)
|
||||
.cornerRadius(12)
|
||||
.frame(width: 100)
|
||||
.onReceive(handle_notify(.followed)) { ref in
|
||||
guard target.follow_ref == ref else { return }
|
||||
self.follow_state = .follows
|
||||
|
||||
@@ -165,7 +165,7 @@ struct ChatBubble<T: View, U: ShapeStyle, V: View>: View {
|
||||
stroke_style: .init(lineWidth: 4),
|
||||
background_style: Color.accentColor
|
||||
) {
|
||||
Text("Hello there")
|
||||
Text(verbatim: "Hello there")
|
||||
.padding()
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
@@ -176,7 +176,7 @@ struct ChatBubble<T: View, U: ShapeStyle, V: View>: View {
|
||||
stroke_style: .init(lineWidth: 4),
|
||||
background_style: Color.accentColor
|
||||
) {
|
||||
Text("Hello there")
|
||||
Text(verbatim: "Hello there")
|
||||
.padding()
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
|
||||
@@ -27,11 +27,9 @@ struct ChatEventView: View {
|
||||
// MARK: long-press reaction control objects
|
||||
/// Whether the user is actively pressing the view
|
||||
@State var is_pressing = false
|
||||
/// The dispatched work item scheduled by a timer to bounce the event bubble and show the emoji selector
|
||||
@State var long_press_bounce_work_item: DispatchWorkItem?
|
||||
@State var popover_state: PopoverState = .closed {
|
||||
didSet {
|
||||
let generator = UIImpactFeedbackGenerator(style: popover_state == .open_emoji_selector ? .heavy : .light)
|
||||
let generator = UIImpactFeedbackGenerator(style: popover_state.some_sheet_open() ? .heavy : .light)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
}
|
||||
@@ -39,10 +37,16 @@ struct ChatEventView: View {
|
||||
|
||||
@State private var isOnTopHalfOfScreen: Bool = false
|
||||
@ObservedObject var bar: ActionBarModel
|
||||
@Environment(\.swipeViewGroupSelection) var swipeViewGroupSelection
|
||||
|
||||
enum PopoverState: String {
|
||||
case closed
|
||||
case open_emoji_selector
|
||||
case open_zap_sheet
|
||||
|
||||
func some_sheet_open() -> Bool {
|
||||
return self == .open_zap_sheet || self == .open_emoji_selector
|
||||
}
|
||||
}
|
||||
|
||||
var just_started: Bool {
|
||||
@@ -90,9 +94,22 @@ struct ChatEventView: View {
|
||||
var by_other_user: Bool {
|
||||
return event.pubkey != damus_state.pubkey
|
||||
}
|
||||
|
||||
|
||||
var is_ours: Bool { return !by_other_user }
|
||||
|
||||
|
||||
// MARK: Zapping properties
|
||||
|
||||
var lnurl: String? {
|
||||
damus_state.profiles.lookup_with_timestamp(event.pubkey)?.map({ pr in
|
||||
pr?.lnurl
|
||||
}).value
|
||||
}
|
||||
var zap_target: ZapTarget {
|
||||
ZapTarget.note(id: event.id, author: event.pubkey)
|
||||
}
|
||||
|
||||
// MARK: Views
|
||||
|
||||
var event_bubble: some View {
|
||||
ChatBubble(
|
||||
direction: is_ours ? .right : .left,
|
||||
@@ -107,6 +124,7 @@ struct ChatEventView: View {
|
||||
.onTapGesture {
|
||||
show_profile_action_sheet_if_enabled(damus_state: damus_state, pubkey: event.pubkey)
|
||||
}
|
||||
.lineLimit(1)
|
||||
Text(verbatim: "\(format_relative_time(event.created_at))")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
@@ -124,13 +142,18 @@ struct ChatEventView: View {
|
||||
}
|
||||
|
||||
let blur_images = should_blur_images(settings: damus_state.settings, contacts: damus_state.contacts, ev: event, our_pubkey: damus_state.pubkey)
|
||||
NoteContentView(damus_state: damus_state, event: event, blur_images: blur_images, size: .normal, options: [])
|
||||
NoteContentView(damus_state: damus_state, event: event, blur_images: blur_images, size: .normal, options: [.truncate_content])
|
||||
.padding(2)
|
||||
if let mention = first_eref_mention(ev: event, keypair: damus_state.keypair) {
|
||||
MentionView(damus_state: damus_state, mention: mention)
|
||||
.background(DamusColors.adaptableWhite)
|
||||
.clipShape(RoundedRectangle(cornerSize: CGSize(width: 10, height: 10)))
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 150, alignment: is_ours ? .trailing : .leading)
|
||||
.frame(minWidth: 5, alignment: is_ours ? .trailing : .leading)
|
||||
.padding(10)
|
||||
}
|
||||
.tint(is_ours ? Color.white : Color.accentColor)
|
||||
.tint(Color.accentColor)
|
||||
.overlay(
|
||||
ZStack(alignment: is_ours ? .bottomLeading : .bottomTrailing) {
|
||||
VStack {
|
||||
@@ -164,6 +187,14 @@ struct ChatEventView: View {
|
||||
EmojiPickerView(selectedEmoji: $selected_emoji, emojiProvider: damus_state.emoji_provider)
|
||||
}.presentationDetents([.medium, .large])
|
||||
}
|
||||
.sheet(isPresented: Binding(get: { popover_state == .open_zap_sheet }, set: { new_state in
|
||||
withAnimation(new_state == true ? .easeIn(duration: 0.5) : .easeOut(duration: 0.1)) {
|
||||
popover_state = new_state == true ? .open_zap_sheet : .closed
|
||||
}
|
||||
})) {
|
||||
ZapSheetViewIfPossible(damus_state: damus_state, target: zap_target, lnurl: lnurl)
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
.onChange(of: selected_emoji) { newSelectedEmoji in
|
||||
if let newSelectedEmoji {
|
||||
send_like(emoji: newSelectedEmoji.value)
|
||||
@@ -171,30 +202,21 @@ struct ChatEventView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.scaleEffect(self.popover_state == .open_emoji_selector ? 1.08 : is_pressing ? 1.02 : 1)
|
||||
.shadow(color: (is_pressing || self.popover_state == .open_emoji_selector) ? .black.opacity(0.1) : .black.opacity(0.3), radius: (is_pressing || self.popover_state == .open_emoji_selector) ? 8 : 0, y: (is_pressing || self.popover_state == .open_emoji_selector) ? 15 : 0)
|
||||
.scaleEffect(self.popover_state.some_sheet_open() ? 1.08 : is_pressing ? 1.02 : 1)
|
||||
.shadow(color: (is_pressing || self.popover_state.some_sheet_open()) ? .black.opacity(0.1) : .black.opacity(0.3), radius: (is_pressing || self.popover_state.some_sheet_open()) ? 8 : 0, y: (is_pressing || self.popover_state.some_sheet_open()) ? 15 : 0)
|
||||
.onLongPressGesture(minimumDuration: 0.5, maximumDistance: 10, perform: {
|
||||
long_press_bounce_work_item?.cancel()
|
||||
withAnimation(.bouncy(duration: 0.2, extraBounce: 0.35)) {
|
||||
let should_show_zap_sheet = !damus_state.settings.nozaps && damus_state.settings.onlyzaps_mode
|
||||
popover_state = should_show_zap_sheet ? .open_zap_sheet : .open_emoji_selector
|
||||
}
|
||||
}, onPressingChanged: { is_pressing in
|
||||
withAnimation(is_pressing ? .easeIn(duration: 0.5) : .easeOut(duration: 0.1)) {
|
||||
self.is_pressing = is_pressing
|
||||
if popover_state != .closed {
|
||||
return
|
||||
}
|
||||
if self.is_pressing {
|
||||
let item = DispatchWorkItem {
|
||||
// Ensure the action is performed only if the condition is still valid
|
||||
if self.is_pressing {
|
||||
withAnimation(.bouncy(duration: 0.2, extraBounce: 0.35)) {
|
||||
popover_state = .open_emoji_selector
|
||||
}
|
||||
}
|
||||
}
|
||||
long_press_bounce_work_item = item
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: item)
|
||||
}
|
||||
}
|
||||
})
|
||||
.onChange(of: swipeViewGroupSelection.wrappedValue) { newValue in
|
||||
self.is_pressing = false
|
||||
}
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
EmptyView()
|
||||
@@ -254,17 +276,30 @@ struct ChatEventView: View {
|
||||
SwipeView {
|
||||
self.event_bubble_with_long_press_interaction
|
||||
} leadingActions: { context in
|
||||
EventActionBar(
|
||||
damus_state: damus_state,
|
||||
event: event,
|
||||
bar: bar,
|
||||
options: is_ours ? [.swipe_action_menu_reverse] : [.swipe_action_menu],
|
||||
swipe_context: context
|
||||
)
|
||||
if !is_ours {
|
||||
EventActionBar(
|
||||
damus_state: damus_state,
|
||||
event: event,
|
||||
bar: bar,
|
||||
options: is_ours ? [.swipe_action_menu_reverse] : [.swipe_action_menu],
|
||||
swipe_context: context
|
||||
)
|
||||
}
|
||||
} trailingActions: { context in
|
||||
if is_ours {
|
||||
EventActionBar(
|
||||
damus_state: damus_state,
|
||||
event: event,
|
||||
bar: bar,
|
||||
options: is_ours ? [.swipe_action_menu_reverse] : [.swipe_action_menu],
|
||||
swipe_context: context
|
||||
)
|
||||
}
|
||||
}
|
||||
.swipeSpacing(-20)
|
||||
.swipeActionsStyle(.mask)
|
||||
.swipeMinimumDistance(20)
|
||||
.swipeDragGesturePriority(.normal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,3 +357,8 @@ extension Notification.Name {
|
||||
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||
return ChatEventView(event: test_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: true, bar: bar)
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||
return ChatEventView(event: test_super_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,9 @@ struct ChatroomThreadView: View {
|
||||
}
|
||||
.padding(.top)
|
||||
EndBlock()
|
||||
|
||||
HStack {}
|
||||
.frame(height: tabHeight + getSafeAreaBottom())
|
||||
}
|
||||
.onReceive(handle_notify(.post), perform: { notify in
|
||||
switch notify {
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
//
|
||||
// CodeScanner.swift
|
||||
// https://github.com/twostraws/CodeScanner
|
||||
//
|
||||
// Created by Paul Hudson on 14/12/2021.
|
||||
// Copyright © 2021 Paul Hudson. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
/// An enum describing the ways CodeScannerView can hit scanning problems.
|
||||
public enum ScanError: Error {
|
||||
/// The camera could not be accessed.
|
||||
case badInput
|
||||
|
||||
/// The camera was not capable of scanning the requested codes.
|
||||
case badOutput
|
||||
|
||||
/// Initialization failed.
|
||||
case initError(_ error: Error)
|
||||
}
|
||||
|
||||
/// The result from a successful scan: the string that was scanned, and also the type of data that was found.
|
||||
/// The type is useful for times when you've asked to scan several different code types at the same time, because
|
||||
/// it will report the exact code type that was found.
|
||||
public struct ScanResult {
|
||||
/// The contents of the code.
|
||||
public let string: String
|
||||
|
||||
/// The type of code that was matched.
|
||||
public let type: AVMetadataObject.ObjectType
|
||||
}
|
||||
|
||||
/// The operating mode for CodeScannerView.
|
||||
public enum ScanMode {
|
||||
/// Scan exactly one code, then stop.
|
||||
case once
|
||||
|
||||
/// Scan each code no more than once.
|
||||
case oncePerCode
|
||||
|
||||
/// Keep scanning all codes until dismissed.
|
||||
case continuous
|
||||
}
|
||||
|
||||
/// A SwiftUI view that is able to scan barcodes, QR codes, and more, and send back what was found.
|
||||
/// To use, set `codeTypes` to be an array of things to scan for, e.g. `[.qr]`, and set `completion` to
|
||||
/// a closure that will be called when scanning has finished. This will be sent the string that was detected or a `ScanError`.
|
||||
/// For testing inside the simulator, set the `simulatedData` property to some test data you want to send back.
|
||||
public struct CodeScannerView: UIViewControllerRepresentable {
|
||||
|
||||
public let codeTypes: [AVMetadataObject.ObjectType]
|
||||
public let scanMode: ScanMode
|
||||
public let scanInterval: Double
|
||||
public let showViewfinder: Bool
|
||||
public var simulatedData = ""
|
||||
public var shouldVibrateOnSuccess: Bool
|
||||
public var isTorchOn: Bool
|
||||
public var isGalleryPresented: Binding<Bool>
|
||||
public var videoCaptureDevice: AVCaptureDevice?
|
||||
public var completion: (Result<ScanResult, ScanError>) -> Void
|
||||
|
||||
public init(
|
||||
codeTypes: [AVMetadataObject.ObjectType],
|
||||
scanMode: ScanMode = .once,
|
||||
scanInterval: Double = 2.0,
|
||||
showViewfinder: Bool = false,
|
||||
simulatedData: String = "",
|
||||
shouldVibrateOnSuccess: Bool = true,
|
||||
isTorchOn: Bool = false,
|
||||
isGalleryPresented: Binding<Bool> = .constant(false),
|
||||
videoCaptureDevice: AVCaptureDevice? = AVCaptureDevice.default(for: .video),
|
||||
completion: @escaping (Result<ScanResult, ScanError>) -> Void
|
||||
) {
|
||||
self.codeTypes = codeTypes
|
||||
self.scanMode = scanMode
|
||||
self.showViewfinder = showViewfinder
|
||||
self.scanInterval = scanInterval
|
||||
self.simulatedData = simulatedData
|
||||
self.shouldVibrateOnSuccess = shouldVibrateOnSuccess
|
||||
self.isTorchOn = isTorchOn
|
||||
self.isGalleryPresented = isGalleryPresented
|
||||
self.videoCaptureDevice = videoCaptureDevice
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
public func makeCoordinator() -> ScannerCoordinator {
|
||||
ScannerCoordinator(parent: self)
|
||||
}
|
||||
|
||||
public func makeUIViewController(context: Context) -> ScannerViewController {
|
||||
let viewController = ScannerViewController(showViewfinder: showViewfinder)
|
||||
viewController.delegate = context.coordinator
|
||||
return viewController
|
||||
}
|
||||
|
||||
public func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {
|
||||
uiViewController.updateViewController(
|
||||
isTorchOn: isTorchOn,
|
||||
isGalleryPresented: isGalleryPresented.wrappedValue
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@available(macCatalyst 14.0, *)
|
||||
struct CodeScannerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CodeScannerView(codeTypes: [.qr]) { result in
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//
|
||||
// CodeScanner.swift
|
||||
// https://github.com/twostraws/CodeScanner
|
||||
//
|
||||
// Created by Paul Hudson on 14/12/2021.
|
||||
// Copyright © 2021 Paul Hudson. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
extension CodeScannerView {
|
||||
@available(macCatalyst 14.0, *)
|
||||
public class ScannerCoordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||
var parent: CodeScannerView
|
||||
var codesFound = Set<String>()
|
||||
var didFinishScanning = false
|
||||
var lastTime = Date(timeIntervalSince1970: 0)
|
||||
|
||||
init(parent: CodeScannerView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
public func reset() {
|
||||
codesFound.removeAll()
|
||||
didFinishScanning = false
|
||||
lastTime = Date(timeIntervalSince1970: 0)
|
||||
}
|
||||
|
||||
public func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||
if let metadataObject = metadataObjects.first {
|
||||
guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return }
|
||||
guard let stringValue = readableObject.stringValue else { return }
|
||||
guard didFinishScanning == false else { return }
|
||||
let result = ScanResult(string: stringValue, type: readableObject.type)
|
||||
|
||||
switch parent.scanMode {
|
||||
case .once:
|
||||
found(result)
|
||||
// make sure we only trigger scan once per use
|
||||
didFinishScanning = true
|
||||
|
||||
case .oncePerCode:
|
||||
if !codesFound.contains(stringValue) {
|
||||
codesFound.insert(stringValue)
|
||||
found(result)
|
||||
}
|
||||
|
||||
case .continuous:
|
||||
if isPastScanInterval() {
|
||||
found(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isPastScanInterval() -> Bool {
|
||||
Date().timeIntervalSince(lastTime) >= parent.scanInterval
|
||||
}
|
||||
|
||||
func found(_ result: ScanResult) {
|
||||
lastTime = Date()
|
||||
|
||||
if parent.shouldVibrateOnSuccess {
|
||||
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
|
||||
}
|
||||
|
||||
parent.completion(.success(result))
|
||||
}
|
||||
|
||||
func didFail(reason: ScanError) {
|
||||
parent.completion(.failure(reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
//
|
||||
// CodeScanner.swift
|
||||
// https://github.com/twostraws/CodeScanner
|
||||
//
|
||||
// Created by Paul Hudson on 14/12/2021.
|
||||
// Copyright © 2021 Paul Hudson. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
extension CodeScannerView {
|
||||
|
||||
@available(macCatalyst 14.0, *)
|
||||
public class ScannerViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
|
||||
var delegate: ScannerCoordinator?
|
||||
private let showViewfinder: Bool
|
||||
|
||||
private var isGalleryShowing: Bool = false {
|
||||
didSet {
|
||||
// Update binding
|
||||
if delegate?.parent.isGalleryPresented.wrappedValue != isGalleryShowing {
|
||||
delegate?.parent.isGalleryPresented.wrappedValue = isGalleryShowing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public init(showViewfinder: Bool = false) {
|
||||
self.showViewfinder = showViewfinder
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
self.showViewfinder = false
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
func openGallery() {
|
||||
isGalleryShowing = true
|
||||
let imagePicker = UIImagePickerController()
|
||||
imagePicker.delegate = self
|
||||
present(imagePicker, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc func openGalleryFromButton(_ sender: UIButton) {
|
||||
openGallery()
|
||||
}
|
||||
|
||||
public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
isGalleryShowing = false
|
||||
|
||||
if let qrcodeImg = info[.originalImage] as? UIImage {
|
||||
let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])!
|
||||
let ciImage = CIImage(image:qrcodeImg)!
|
||||
var qrCodeLink = ""
|
||||
|
||||
let features = detector.features(in: ciImage)
|
||||
|
||||
for feature in features as! [CIQRCodeFeature] {
|
||||
qrCodeLink += feature.messageString!
|
||||
}
|
||||
|
||||
if qrCodeLink == "" {
|
||||
delegate?.didFail(reason: .badOutput)
|
||||
} else {
|
||||
let result = ScanResult(string: qrCodeLink, type: .qr)
|
||||
delegate?.found(result)
|
||||
}
|
||||
} else {
|
||||
print("Something went wrong")
|
||||
}
|
||||
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
isGalleryShowing = false
|
||||
}
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
override public func loadView() {
|
||||
view = UIView()
|
||||
view.isUserInteractionEnabled = true
|
||||
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.numberOfLines = 0
|
||||
label.text = "You're running in the simulator, which means the camera isn't available. Tap anywhere to send back some simulated data."
|
||||
label.textAlignment = .center
|
||||
|
||||
let button = UIButton()
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.setTitle("Select a custom image", for: .normal)
|
||||
button.setTitleColor(UIColor.systemBlue, for: .normal)
|
||||
button.setTitleColor(UIColor.gray, for: .highlighted)
|
||||
button.addTarget(self, action: #selector(openGalleryFromButton), for: .touchUpInside)
|
||||
|
||||
let stackView = UIStackView()
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = 50
|
||||
stackView.addArrangedSubview(label)
|
||||
stackView.addArrangedSubview(button)
|
||||
|
||||
view.addSubview(stackView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
button.heightAnchor.constraint(equalToConstant: 50),
|
||||
stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
|
||||
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let simulatedData = delegate?.parent.simulatedData else {
|
||||
print("Simulated Data Not Provided!")
|
||||
return
|
||||
}
|
||||
|
||||
// Send back their simulated data, as if it was one of the types they were scanning for
|
||||
let result = ScanResult(string: simulatedData, type: delegate?.parent.codeTypes.first ?? .qr)
|
||||
delegate?.found(result)
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
var captureSession: AVCaptureSession!
|
||||
var previewLayer: AVCaptureVideoPreviewLayer!
|
||||
let fallbackVideoCaptureDevice = AVCaptureDevice.default(for: .video)
|
||||
|
||||
private lazy var viewFinder: UIImageView? = {
|
||||
guard let image = UIImage(named: "viewfinder", in: .main, with: nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let imageView = UIImageView(image: image)
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
return imageView
|
||||
}()
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(updateOrientation),
|
||||
name: Notification.Name("UIDeviceOrientationDidChangeNotification"),
|
||||
object: nil)
|
||||
|
||||
view.backgroundColor = UIColor.black
|
||||
captureSession = AVCaptureSession()
|
||||
|
||||
guard let videoCaptureDevice = delegate?.parent.videoCaptureDevice ?? fallbackVideoCaptureDevice else {
|
||||
return
|
||||
}
|
||||
|
||||
let videoInput: AVCaptureDeviceInput
|
||||
|
||||
do {
|
||||
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
|
||||
} catch {
|
||||
delegate?.didFail(reason: .initError(error))
|
||||
return
|
||||
}
|
||||
|
||||
if (captureSession.canAddInput(videoInput)) {
|
||||
captureSession.addInput(videoInput)
|
||||
} else {
|
||||
delegate?.didFail(reason: .badInput)
|
||||
return
|
||||
}
|
||||
|
||||
let metadataOutput = AVCaptureMetadataOutput()
|
||||
|
||||
if (captureSession.canAddOutput(metadataOutput)) {
|
||||
captureSession.addOutput(metadataOutput)
|
||||
|
||||
metadataOutput.setMetadataObjectsDelegate(delegate, queue: DispatchQueue.main)
|
||||
metadataOutput.metadataObjectTypes = delegate?.parent.codeTypes
|
||||
} else {
|
||||
delegate?.didFail(reason: .badOutput)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
override public func viewWillLayoutSubviews() {
|
||||
previewLayer?.frame = view.layer.bounds
|
||||
}
|
||||
|
||||
@objc func updateOrientation() {
|
||||
guard let orientation = view.window?.windowScene?.interfaceOrientation else { return }
|
||||
guard let connection = captureSession.connections.last, connection.isVideoOrientationSupported else { return }
|
||||
connection.videoOrientation = AVCaptureVideoOrientation(rawValue: orientation.rawValue) ?? .portrait
|
||||
}
|
||||
|
||||
override public func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
updateOrientation()
|
||||
}
|
||||
|
||||
override public func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if previewLayer == nil {
|
||||
previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
|
||||
}
|
||||
|
||||
previewLayer.frame = view.layer.bounds
|
||||
previewLayer.videoGravity = .resizeAspectFill
|
||||
view.layer.addSublayer(previewLayer)
|
||||
addviewfinder()
|
||||
|
||||
delegate?.reset()
|
||||
|
||||
if (captureSession?.isRunning == false) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
self.captureSession.startRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addviewfinder() {
|
||||
guard showViewfinder, let imageView = viewFinder else { return }
|
||||
|
||||
view.addSubview(imageView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
imageView.widthAnchor.constraint(equalToConstant: 200),
|
||||
imageView.heightAnchor.constraint(equalToConstant: 200),
|
||||
])
|
||||
}
|
||||
|
||||
override public func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
if (captureSession?.isRunning == true) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
self.captureSession.stopRunning()
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
override public var prefersStatusBarHidden: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
.all
|
||||
}
|
||||
|
||||
/** Touch the screen for autofocus */
|
||||
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard touches.first?.view == view,
|
||||
let touchPoint = touches.first,
|
||||
let device = delegate?.parent.videoCaptureDevice ?? fallbackVideoCaptureDevice
|
||||
else { return }
|
||||
|
||||
let videoView = view
|
||||
let screenSize = videoView!.bounds.size
|
||||
let xPoint = touchPoint.location(in: videoView).y / screenSize.height
|
||||
let yPoint = 1.0 - touchPoint.location(in: videoView).x / screenSize.width
|
||||
let focusPoint = CGPoint(x: xPoint, y: yPoint)
|
||||
|
||||
do {
|
||||
try device.lockForConfiguration()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// Focus to the correct point, make continiuous focus and exposure so the point stays sharp when moving the device closer
|
||||
device.focusPointOfInterest = focusPoint
|
||||
device.focusMode = .continuousAutoFocus
|
||||
device.exposurePointOfInterest = focusPoint
|
||||
device.exposureMode = AVCaptureDevice.ExposureMode.continuousAutoExposure
|
||||
device.unlockForConfiguration()
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
func updateViewController(isTorchOn: Bool, isGalleryPresented: Bool) {
|
||||
if let backCamera = AVCaptureDevice.default(for: AVMediaType.video),
|
||||
backCamera.hasTorch
|
||||
{
|
||||
try? backCamera.lockForConfiguration()
|
||||
backCamera.torchMode = isTorchOn ? .on : .off
|
||||
backCamera.unlockForConfiguration()
|
||||
}
|
||||
|
||||
if isGalleryPresented && !isGalleryShowing {
|
||||
openGallery()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,10 @@ struct ConfigView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section(NSLocalizedString("Version", comment: "Section title for displaying the version number of the Damus app.")) {
|
||||
Section(
|
||||
header: Text(NSLocalizedString("Version", comment: "Section title for displaying the version number of the Damus app.")),
|
||||
footer: Text("").padding(.bottom, tabHeight + getSafeAreaBottom())
|
||||
) {
|
||||
Text(verbatim: VersionInfo.version)
|
||||
.contextMenu {
|
||||
Button {
|
||||
|
||||
@@ -28,7 +28,7 @@ struct CreateAccountView: View {
|
||||
Spacer()
|
||||
VStack(alignment: .center) {
|
||||
|
||||
EditPictureControl(uploader: .nostrBuild, pubkey: account.pubkey, size: 75, setup: true, image_url: $account.profile_image , uploadObserver: profileUploadObserver, callback: uploadedProfilePicture)
|
||||
EditPictureControl(uploader: .nostrBuild, keypair: account.keypair, pubkey: account.pubkey, size: 75, setup: true, image_url: $account.profile_image , uploadObserver: profileUploadObserver, callback: uploadedProfilePicture)
|
||||
.shadow(radius: 2)
|
||||
.padding(.top, 100)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import Combine
|
||||
|
||||
struct DMChatView: View, KeyboardReadable {
|
||||
let damus_state: DamusState
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@ObservedObject var dms: DirectMessageModel
|
||||
|
||||
var pubkey: Pubkey {
|
||||
@@ -46,6 +47,7 @@ struct DMChatView: View, KeyboardReadable {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, isTextFieldFocused ? 0 : tabHeight)
|
||||
}
|
||||
|
||||
func scroll_to_end(_ scroller: ScrollViewProxy, animated: Bool = false) {
|
||||
@@ -74,6 +76,7 @@ struct DMChatView: View, KeyboardReadable {
|
||||
.textEditorBackground {
|
||||
InputBackground()
|
||||
}
|
||||
.focused($isTextFieldFocused)
|
||||
.cornerRadius(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
|
||||
@@ -35,6 +35,7 @@ struct DirectMessagesView: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.bottom, tabHeight)
|
||||
}
|
||||
|
||||
func filter_dms(dms: [DirectMessageModel]) -> [DirectMessageModel] {
|
||||
@@ -72,13 +73,11 @@ struct DirectMessagesView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
CustomPicker(selection: $dm_type, content: {
|
||||
Text("DMs", comment: "Picker option for DM selector for seeing only DMs that have been responded to. DM is the English abbreviation for Direct Message.")
|
||||
.tag(DMType.friend)
|
||||
Text("Requests", comment: "Picker option for DM selector for seeing only message requests (DMs that someone else sent the user which has not been responded to yet). DM is the English abbreviation for Direct Message.")
|
||||
.tag(DMType.rando)
|
||||
})
|
||||
|
||||
CustomPicker(tabs: [
|
||||
(NSLocalizedString("DMs", comment: "Picker option for DM selector for seeing only DMs that have been responded to. DM is the English abbreviation for Direct Message."), DMType.friend),
|
||||
(NSLocalizedString("Requests", comment: "Picker option for DM selector for seeing only message requests (DMs that someone else sent the user which has not been responded to yet"), DMType.rando),
|
||||
], selection: $dm_type)
|
||||
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
|
||||
@@ -105,7 +104,7 @@ struct DirectMessagesView: View {
|
||||
|
||||
func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageModel]) -> Bool {
|
||||
for dm in dms {
|
||||
if !FriendFilter.friends.filter(contacts: contacts, pubkey: dm.pubkey) {
|
||||
if !FriendFilter.friends_of_friends.filter(contacts: contacts, pubkey: dm.pubkey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,16 @@ func should_blur_images(settings: UserSettingsStore, contacts: Contacts, ev: Nos
|
||||
return true
|
||||
}
|
||||
|
||||
// blame the porn bots for this code too
|
||||
func should_blur_images(damus_state: DamusState, ev: NostrEvent) -> Bool {
|
||||
return should_blur_images(
|
||||
settings: damus_state.settings,
|
||||
contacts: damus_state.contacts,
|
||||
ev: ev,
|
||||
our_pubkey: damus_state.pubkey
|
||||
)
|
||||
}
|
||||
|
||||
func format_relative_time(_ created_at: UInt32) -> String
|
||||
{
|
||||
return time_ago_since(Date(timeIntervalSince1970: Double(created_at)))
|
||||
|
||||
@@ -15,18 +15,13 @@ struct ReplyPart: View {
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let reply_ref = event.thread_reply()?.reply {
|
||||
if event.known_kind == .highlight {
|
||||
let highlighted_note = event.highlighted_note_id().flatMap { events.lookup($0) }
|
||||
let highlight_note = HighlightEvent.parse(from: event)
|
||||
HighlightDescription(highlight_event: highlight_note, highlighted_event: highlighted_note, ndb: ndb)
|
||||
} else if let reply_ref = event.thread_reply()?.reply {
|
||||
let replying_to = events.lookup(reply_ref.note_id)
|
||||
if event.known_kind != .highlight {
|
||||
ReplyDescription(event: event, replying_to: replying_to, ndb: ndb)
|
||||
} else if event.known_kind == .highlight {
|
||||
HighlightDescription(event: event, highlighted_event: replying_to, ndb: ndb)
|
||||
}
|
||||
else {
|
||||
EmptyView()
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
ReplyDescription(event: event, replying_to: replying_to, ndb: ndb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ struct EventBody: View {
|
||||
}
|
||||
} else if event.known_kind == .highlight {
|
||||
HighlightBodyView(state: damus_state, ev: event, options: options)
|
||||
.onTapGesture {
|
||||
if let highlighted_note = event.highlighted_note_id().flatMap({ damus_state.events.lookup($0) }) {
|
||||
let thread = ThreadModel(event: highlighted_note, damus_state: damus_state, highlight: event.content)
|
||||
damus_state.nav.push(route: Route.Thread(thread: thread))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
note_content
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ import SwiftUI
|
||||
|
||||
// Modified from Reply Description
|
||||
struct HighlightDescription: View {
|
||||
let event: NostrEvent
|
||||
let highlight_event: HighlightEvent
|
||||
let highlighted_event: NostrEvent?
|
||||
let ndb: Ndb
|
||||
|
||||
var body: some View {
|
||||
(Text(Image(systemName: "highlighter")) + Text(verbatim: " \(highlight_desc(ndb: ndb, event: event, highlighted_event: highlighted_event))"))
|
||||
(Text(Image(systemName: "highlighter")) + Text(verbatim: " \(highlight_event.source_description_text(ndb: ndb, highlighted_event: highlighted_event))"))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.gray)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -24,30 +24,6 @@ struct HighlightDescription: View {
|
||||
|
||||
struct HighlightDescription_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
HighlightDescription(event: test_note, highlighted_event: test_note, ndb: test_damus_state.ndb)
|
||||
HighlightDescription(highlight_event: HighlightEvent.parse(from: test_note), highlighted_event: nil, ndb: test_damus_state.ndb)
|
||||
}
|
||||
}
|
||||
|
||||
func highlight_desc(ndb: Ndb, event: NostrEvent, highlighted_event: NostrEvent?, locale: Locale = Locale.current) -> String {
|
||||
let desc = make_reply_description(event, replying_to: highlighted_event)
|
||||
let pubkeys = desc.pubkeys
|
||||
|
||||
let bundle = bundleForLocale(locale: locale)
|
||||
|
||||
if pubkeys.count == 0 {
|
||||
return NSLocalizedString("Highlighted", bundle: bundle, comment: "Label to indicate that the user is highlighting their own post.")
|
||||
}
|
||||
|
||||
guard let profile_txn = NdbTxn(ndb: ndb) else {
|
||||
return ""
|
||||
}
|
||||
|
||||
let names: [String] = pubkeys.map { pk in
|
||||
let prof = ndb.lookup_profile_with_txn(pk, txn: profile_txn)
|
||||
|
||||
return Profile.displayName(profile: prof?.profile, pubkey: pk).username.truncate(maxLength: 50)
|
||||
}
|
||||
|
||||
let uniqueNames: [String] = Array(Set(names))
|
||||
return String(format: NSLocalizedString("Highlighted %@", bundle: bundle, comment: "Label to indicate that the user is highlighting 1 user."), locale: locale, uniqueNames.first ?? "")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// HighlightDraftContentView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 5/26/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HighlightDraftContentView: View {
|
||||
let draft: HighlightContentDraft
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
var attributedString: AttributedString {
|
||||
var attributedString = AttributedString(draft.selected_text)
|
||||
|
||||
if let range = attributedString.range(of: draft.selected_text) {
|
||||
attributedString[range].backgroundColor = DamusColors.highlight
|
||||
}
|
||||
|
||||
return attributedString
|
||||
}
|
||||
|
||||
Text(attributedString)
|
||||
.lineSpacing(5)
|
||||
.padding(10)
|
||||
}
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 25).fill(DamusColors.highlight).frame(width: 4),
|
||||
alignment: .leading
|
||||
)
|
||||
|
||||
if case .external_url(let url) = draft.source {
|
||||
LinkViewRepresentable(meta: .url(url))
|
||||
.frame(height: 50)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ struct HighlightEventRef: View {
|
||||
FailedImage()
|
||||
}
|
||||
.frame(width: 35, height: 35)
|
||||
.kfClickable()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.gray.opacity(0.5), lineWidth: 0.5))
|
||||
.scaledToFit()
|
||||
@@ -58,7 +59,7 @@ struct HighlightEventRef: View {
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(longform_event.title ?? "Untitled")
|
||||
Text(longform_event.title ?? NSLocalizedString("Untitled", comment: "Title of longform event if it is untitled."))
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.lineLimit(1)
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ struct HighlightLink: View {
|
||||
.background(DamusColors.adaptableWhite)
|
||||
}
|
||||
.frame(width: 35, height: 35)
|
||||
.kfClickable()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.scaledToFit()
|
||||
} else {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
//
|
||||
// HighlightPostView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 5/26/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HighlightPostView: View {
|
||||
let damus_state: DamusState
|
||||
let event: NostrEvent
|
||||
@Binding var selectedText: String
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack {
|
||||
HStack(spacing: 5.0) {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("Cancel", comment: "Button to cancel out of highlighting a note.")
|
||||
.padding(10)
|
||||
})
|
||||
.buttonStyle(NeutralButtonStyle())
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(NSLocalizedString("Post", comment: "Button to post a highlight.")) {
|
||||
var tags: [[String]] = [ ["e", "\(self.event.id)"] ]
|
||||
tags.append(["context", self.event.content])
|
||||
|
||||
let kind = NostrKind.highlight.rawValue
|
||||
guard let ev = NostrEvent(content: selectedText, keypair: damus_state.keypair, kind: kind, tags: tags) else {
|
||||
return
|
||||
}
|
||||
damus_state.postbox.send(ev)
|
||||
dismiss()
|
||||
}
|
||||
.bold()
|
||||
.buttonStyle(GradientButtonStyle(padding: 10))
|
||||
}
|
||||
|
||||
Divider()
|
||||
.foregroundColor(DamusColors.neutral3)
|
||||
.padding(.top, 5)
|
||||
}
|
||||
.frame(height: 30)
|
||||
.padding()
|
||||
.padding(.top, 15)
|
||||
|
||||
HStack {
|
||||
var attributedString: AttributedString {
|
||||
var attributedString = AttributedString(self.event.content)
|
||||
|
||||
if let range = attributedString.range(of: selectedText) {
|
||||
attributedString[range].backgroundColor = DamusColors.highlight
|
||||
}
|
||||
|
||||
return attributedString
|
||||
}
|
||||
|
||||
Text(attributedString)
|
||||
.lineSpacing(5)
|
||||
.padding(10)
|
||||
}
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 25).fill(DamusColors.highlight).frame(width: 4),
|
||||
alignment: .leading
|
||||
)
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,9 +59,9 @@ struct HighlightBodyView: View {
|
||||
var body: some View {
|
||||
Group {
|
||||
if options.contains(.wide) {
|
||||
Main.padding(.horizontal)
|
||||
} else {
|
||||
Main
|
||||
} else {
|
||||
Main.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,18 @@ struct HighlightBodyView: View {
|
||||
|
||||
var Main: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
|
||||
if self.event.event.referenced_comment_items.first?.content != nil {
|
||||
let all_options = options.union(.no_action_bar)
|
||||
NoteContentView(
|
||||
damus_state: self.state,
|
||||
event: self.event.event,
|
||||
blur_images: should_blur_images(damus_state: self.state, ev: self.event.event),
|
||||
size: .normal,
|
||||
options: all_options
|
||||
).padding(.vertical, 10)
|
||||
}
|
||||
|
||||
HStack {
|
||||
var attributedString: AttributedString {
|
||||
var attributedString: AttributedString = ""
|
||||
@@ -119,14 +131,17 @@ struct HighlightBodyView: View {
|
||||
RoundedRectangle(cornerRadius: 25).fill(DamusColors.highlight).frame(width: 4),
|
||||
alignment: .leading
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
if let url = event.url_ref {
|
||||
HighlightLink(state: state, url: url, content: event.event.content)
|
||||
.padding(.horizontal)
|
||||
} else {
|
||||
if let evRef = event.event_ref {
|
||||
if let eventHex = hex_decode_id(evRef) {
|
||||
HighlightEventRef(damus_state: state, event_ref: NoteId(eventHex))
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ struct LongformPreviewBody: View {
|
||||
}
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(maxWidth: .infinity, maxHeight: header ? .infinity : 150)
|
||||
.kfClickable()
|
||||
.cornerRadius(1)
|
||||
}
|
||||
|
||||
@@ -129,7 +130,7 @@ struct LongformPreviewBody: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text(event.title ?? "Untitled")
|
||||
Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of longform event if it is untitled."))
|
||||
.font(header ? .title : .headline)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 5)
|
||||
|
||||
@@ -24,7 +24,7 @@ struct LongformView: View {
|
||||
|
||||
var body: some View {
|
||||
EventShell(state: state, event: event.event, options: options) {
|
||||
SelectableText(damus_state: state, event: event.event, attributedString: AttributedString(stringLiteral: event.title ?? "Untitled"), size: .title)
|
||||
SelectableText(damus_state: state, event: event.event, attributedString: AttributedString(stringLiteral: event.title ?? NSLocalizedString("Untitled", comment: "Title of longform event if it is untitled.")), size: .title)
|
||||
|
||||
NoteContentView(damus_state: state, event: event.event, blur_images: false, size: .selected, options: options)
|
||||
}
|
||||
|
||||
@@ -39,21 +39,10 @@ struct SelectedEventView: View {
|
||||
.padding(.horizontal)
|
||||
.minimumScaleFactor(0.75)
|
||||
.lineLimit(1)
|
||||
|
||||
if let reply_ref = event.thread_reply()?.reply {
|
||||
let replying_to = damus.events.lookup(reply_ref.note_id)
|
||||
if event.known_kind == .highlight {
|
||||
HighlightDescription(event: event, highlighted_event: replying_to, ndb: damus.ndb)
|
||||
.padding(.horizontal)
|
||||
} else {
|
||||
ReplyDescription(event: event, replying_to: replying_to, ndb: damus.ndb)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
} else if event.known_kind == .highlight {
|
||||
HighlightDescription(event: event, highlighted_event: nil, ndb: damus.ndb)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
|
||||
ReplyPart(events: damus.events, event: event, keypair: damus.keypair, ndb: damus.ndb)
|
||||
.padding(.horizontal)
|
||||
|
||||
ProxyView(event: event)
|
||||
.padding(.top, 5)
|
||||
.padding(.horizontal)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//
|
||||
// DamusFullScreenCover.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-10-25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
// MARK: - Private view modifier implementations of DamusFullScreenCover
|
||||
|
||||
/// This implements a full screen cover made for use in Damus.
|
||||
/// This was created as a way to facilitate video coordination throughout the app, by handling the necessary logic — without requiring any special handling in the usages of video player views.
|
||||
///
|
||||
/// In the future this could be used to faciliate other full screen logic as well.
|
||||
///
|
||||
/// # Features
|
||||
///
|
||||
/// This has the following features:
|
||||
/// 1. It automatically tells the video coordinator about full screen mode changes, so that the video coordinator always knows if the app is in normal mode or in full screen mode for video coordination
|
||||
/// 2. It automatically sets the `view_layer_context`, which is consumed by video player views, allowing those views to communicate about their layer position to the video coordinator
|
||||
fileprivate struct DamusFullScreenCover<FullScreenContent: View, T: Identifiable & Equatable>: ViewModifier {
|
||||
/// The `damus_state`, where we can access the video coordinator
|
||||
let damus_state: DamusState
|
||||
/// The item to be presented full screen
|
||||
@Binding var item: T?
|
||||
/// The view to be presented full screen
|
||||
let full_screen_content: (T) -> FullScreenContent
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.environment(\.view_layer_context, .normal_layer) // Let the views under content know they are NOT in a full screen environment
|
||||
.onChange(of: item, perform: { newValue in
|
||||
// Inform the video coordinator whether we are in full screen mode or not.
|
||||
damus_state.video.set_full_screen_mode(newValue != nil)
|
||||
})
|
||||
.fullScreenCover(item: $item, content: { item in
|
||||
full_screen_content(item)
|
||||
.environment(\.view_layer_context, .full_screen_layer) // Let the views under full screen content know they are in a full screen environment
|
||||
// Another observer for full screen presentation is needed here because in some cases the underlying view (`body::content`) may have been deinitialized and no longer listen to changes
|
||||
// One such example is when the underlying navigation stack navigates away from a source view at the same time it opens the full screen view
|
||||
// Therefore, when the full screen view is dismissed, this content will disappear, and we should notify the video coordinator.
|
||||
.onDisappear {
|
||||
damus_state.video.set_full_screen_mode(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A convenience view modifier that provides a different interface than `DamusFullScreenCover`, but is otherwise identical to it.
|
||||
fileprivate struct DamusFullScreenCoverWithoutItem<FullScreenContent: View>: ViewModifier {
|
||||
let damus_state: DamusState
|
||||
@Binding var is_presented: Bool
|
||||
let full_screen_content: () -> FullScreenContent
|
||||
private let fake_item: FakeItem = FakeItem()
|
||||
private var binding_item: Binding<FakeItem?> {
|
||||
return Binding(
|
||||
get: { is_presented ? self.fake_item : nil },
|
||||
set: { is_presented = $0 != nil ? true : false }
|
||||
)
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.damus_full_screen_cover(self.binding_item, damus_state: damus_state, content: { _ in full_screen_content() })
|
||||
}
|
||||
|
||||
private struct FakeItem: Identifiable, Equatable {
|
||||
let id: Int = 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Environment variable definitions
|
||||
|
||||
extension EnvironmentValues {
|
||||
@Entry var view_layer_context: ViewLayerContext? = nil
|
||||
}
|
||||
|
||||
|
||||
/// Context about the layer a view finds itself in
|
||||
/// This communicates to a view (e.g. a video player) context about whether it is being displayed inside a full screen layer, or a normal layer
|
||||
enum ViewLayerContext {
|
||||
/// This is used for items placed in a scroll view, such as on a timeline or a thread view.
|
||||
case normal_layer
|
||||
/// This is used for video players being displayed full screen
|
||||
case full_screen_layer
|
||||
}
|
||||
|
||||
|
||||
// MARK: - View extension interfaces to access Damus' full screen cover
|
||||
|
||||
extension View {
|
||||
|
||||
/// A full screen cover to be used throughout Damus, containing extra functionality that helps with app coordination, and is meant to replace `.fullScreenCover`
|
||||
///
|
||||
/// ## Usage notes
|
||||
///
|
||||
/// This is the preferred method of doing a full screen cover. This is preferred over `.fullScreenCover` because it helps with certain coordination elements:
|
||||
///
|
||||
/// 1. It automatically informs the video coordinator if the app is in full screen or not
|
||||
/// 2. It provides contextual information that any child view can pickup to introspect whether or not they are in a full screen layer. This can be picked up via the `\.view_layer_context` environment variable
|
||||
///
|
||||
/// **CAUTION:**
|
||||
/// If you are planning to use this from a view that is presented on a timeline or lazy stack, please use `present(full_screen_item: FullScreenItem)` instead to avoid your full screen view to abruptly disappear.
|
||||
/// Please read the documentation for `present(full_screen_item: FullScreenItem)` for more details.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - is_presented: whether to show the full screen cover
|
||||
/// - damus_state: The state of the app
|
||||
/// - content: The view to show full screen
|
||||
/// - Returns: the modified view
|
||||
func damus_full_screen_cover<Content: View>(_ is_presented: Binding<Bool>, damus_state: DamusState, @ViewBuilder content: @escaping () -> Content) -> some View {
|
||||
return self.modifier(DamusFullScreenCoverWithoutItem(damus_state: damus_state, is_presented: is_presented, full_screen_content: content))
|
||||
}
|
||||
|
||||
/// A full screen cover to be used throughout Damus, containing extra functionality that helps with app coordination, and is meant to replace `.fullScreenCover`
|
||||
///
|
||||
/// ## Usage notes
|
||||
///
|
||||
/// This is the preferred method of doing a full screen cover. This is preferred over `.fullScreenCover` because it helps with certain coordination elements:
|
||||
///
|
||||
/// 1. It automatically informs the video coordinator if the app is in full screen or not
|
||||
/// 2. It provides contextual information that any child view can pickup to introspect whether or not they are in a full screen layer. This can be picked up via the `\.view_layer_context` environment variable
|
||||
///
|
||||
/// **CAUTION:**
|
||||
/// If you are planning to use this from a view that is presented on a timeline or lazy stack, please use `present(full_screen_item: FullScreenItem)` instead to avoid your full screen view to abruptly disappear.
|
||||
/// Please read the documentation for `present(full_screen_item: FullScreenItem)` for more details.
|
||||
///
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - item: The item to be displayed full screen, or `nil` if full screen should be dismissed.
|
||||
/// - damus_state: The state of the app
|
||||
/// - content: The view to render `item`
|
||||
/// - Returns: the modified view
|
||||
func damus_full_screen_cover<Content: View, T: Identifiable & Equatable>(_ item: Binding<T?>, damus_state: DamusState, @ViewBuilder content: @escaping (T) -> Content) -> some View {
|
||||
return self.modifier(DamusFullScreenCover(damus_state: damus_state, item: item, full_screen_content: content))
|
||||
}
|
||||
}
|
||||
@@ -10,27 +10,119 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func on_visibility_change(perform visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment = .center) -> some View {
|
||||
self.modifier(VisibilityTracker(visibility_change_notifier: visibility_change_notifier, edge: edge))
|
||||
/// Watches for visibility changes. Does not detect occlusion
|
||||
///
|
||||
/// ## Usage notes
|
||||
///
|
||||
/// 1. Detection mechanisms are not perfect, parameters may need fine tuning. Please refer to `VisibilityTracker` documentation for more details.
|
||||
/// 2. This does **not** detect if the view has been occluded. There are currently no known mechanisms to do that.
|
||||
/// If occlusion tracking is needed for your usage, consider using layout knowledge/introspection of the different layers that make up the view, and using that information for your logic.
|
||||
/// For example, when dealing with items on a normal view, and a full screen cover, write your logic based on explicit information about which views are in the full screen layer.
|
||||
/// Read about `present(full_screen_item: FullScreenItem)`, `damus_full_screen_cover`, and the `.view_layer_context` environment variable.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - visibility_change_notifier: Function to call once visibility changes
|
||||
/// - edge: Edge for the visibility overlay sensor
|
||||
/// - method: The method to use for visibility tracking. Refer to `VisibilityTracker` documentation for more details.
|
||||
/// - Returns: A modified view.
|
||||
func on_visibility_change(perform visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment = .center, method: VisibilityTracker.Method = .standard) -> some View {
|
||||
self.modifier(VisibilityTracker(visibility_change_notifier: visibility_change_notifier, edge: edge, method: method))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Tracks visibility of a SwiftUI view.
|
||||
/// Built mostly to track visibility states of video players around the app and help the video coordinator pick a video to focus on, but can be used for basically any other view
|
||||
/// **Caution:** This is not a perfect tracker, please read and fine-tune parameters for your use case, especially `method`
|
||||
struct VisibilityTracker: ViewModifier {
|
||||
let visibility_window: CGFloat = 0.8
|
||||
let visibility_change_notifier: (Bool) -> Void
|
||||
let edge: Alignment
|
||||
let method: Method
|
||||
|
||||
init(visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment, method: Method) {
|
||||
self.visibility_change_notifier = visibility_change_notifier
|
||||
self.edge = edge
|
||||
self.method = method
|
||||
}
|
||||
|
||||
@EnvironmentObject private var orientationTracker: OrientationTracker
|
||||
/// Holds information about whether the view is "generically" visible, meaning whether it would have been loaded on a lazy stack.
|
||||
@State private var generic_visible: Bool = false {
|
||||
didSet {
|
||||
if oldValue == generic_visible { return } // Save up computing resources if there were no changes
|
||||
self.visibility_change_notifier(self.is_visible)
|
||||
}
|
||||
}
|
||||
/// Whether the view is visible by checking if its Y position is within a range of the user's screen
|
||||
@State private var y_scroll_visible: Bool = false {
|
||||
didSet {
|
||||
switch self.method {
|
||||
case .standard:
|
||||
if oldValue == y_scroll_visible { return } // Save up computing resources if there were no changes
|
||||
self.visibility_change_notifier(self.is_visible)
|
||||
case .no_y_scroll_detection:
|
||||
return // Don't cause re-renders if the visibility method does not use this
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether view is "visible"
|
||||
var is_visible: Bool {
|
||||
switch method {
|
||||
case .standard:
|
||||
return generic_visible && y_scroll_visible
|
||||
case .no_y_scroll_detection:
|
||||
return generic_visible
|
||||
}
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay(
|
||||
LazyVStack {
|
||||
Color.clear
|
||||
.onAppear {
|
||||
visibility_change_notifier(true)
|
||||
}
|
||||
.onDisappear {
|
||||
visibility_change_notifier(false)
|
||||
}
|
||||
},
|
||||
alignment: edge)
|
||||
content
|
||||
.overlay(
|
||||
GeometryReader { geo in
|
||||
let localFrame = geo.frame(in: .local)
|
||||
let centerY = globalCoordinate(localX: 0, localY: localFrame.midY, localGeometry: geo).y
|
||||
LazyVStack {
|
||||
Color.clear
|
||||
// MARK: Detection triggers
|
||||
.onAppear {
|
||||
self.generic_visible = true
|
||||
self.y_scroll_visible = self.compute_y_scroll_visible(centerY: centerY)
|
||||
}
|
||||
.onDisappear {
|
||||
self.generic_visible = false
|
||||
}
|
||||
.onChange(of: centerY) { new_center_y in
|
||||
if generic_visible == false { return } // Don't bother calculating anything if this is not visible generically, to save up computing resources
|
||||
self.y_scroll_visible = self.compute_y_scroll_visible(
|
||||
centerY: new_center_y // Compute the new Y scroll visibility using the newest value to avoid transient issues on device orientation changes
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
alignment: edge)
|
||||
}
|
||||
|
||||
/// Computes whether the view is "visible" in a range of the screen given its Y position
|
||||
private func compute_y_scroll_visible(centerY: CGFloat) -> Bool {
|
||||
let screen_center_y = orientationTracker.deviceMajorAxis / 2
|
||||
let screen_visibility_window_margin = orientationTracker.deviceMajorAxis * visibility_window / 2
|
||||
let isBelowTop = centerY > screen_center_y - screen_visibility_window_margin,
|
||||
isAboveBottom = centerY < screen_center_y + screen_visibility_window_margin
|
||||
return (isBelowTop && isAboveBottom)
|
||||
}
|
||||
|
||||
/// The methods available for visibility detection.
|
||||
/// Unfortunately, there is currently no perfect visibility detection mechanism, so callers of `VisibilityTracker` should select a method that best suits the context of the view.
|
||||
enum Method: Equatable {
|
||||
/// Includes both a generic and Y coordinate based visibility detection.
|
||||
/// When this option is selected, the view is only deemed visible if both lazy view evaluators load it (when close enough to viewport), and the center Y coordinate is sufficiently in the center
|
||||
/// This is best for most view presentations, specially for scroll views.
|
||||
case standard
|
||||
/// Includes only a generic visibility detection based on a lazy view loader
|
||||
/// When this option is selected, the view is only deemed visible if the lazy view evaluators load it (which SwiftUI does when it is close enough to viewport), regardless of Y coordinate
|
||||
/// This is not suitable for scroll views or most presentations because it may trigger too early, leading to false positives. This is more suitable when the standard detection mechanism is triggering too many false negatives, and this is a more "static" view
|
||||
/// For example: when displaying an item in full screen mode where it is visible in a more stable, static form, and device orientation changes may cause transient visibility triggers
|
||||
case no_y_scroll_detection
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +160,10 @@ struct FollowingView: View {
|
||||
.navigationBarTitle(NSLocalizedString("Following", comment: "Navigation bar title for view that shows who a user is following."))
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
CustomPicker(selection: $tab_selection, content: {
|
||||
Text("People", comment: "Label for filter for seeing only people follows.").tag(FollowingViewTabSelection.people)
|
||||
Text("Hashtags", comment: "Label for filter for seeing only hashtag follows.").tag(FollowingViewTabSelection.hashtags)
|
||||
})
|
||||
CustomPicker(tabs: [
|
||||
(NSLocalizedString("People", comment: "Label for filter for seeing only people follows."), FollowingViewTabSelection.people),
|
||||
(NSLocalizedString("Hashtags", comment: "Label for filter for seeing only hashtag follows."), FollowingViewTabSelection.hashtags)
|
||||
], selection: $tab_selection)
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
//
|
||||
// ImageView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by user232838 on 1/5/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ImageView: View {
|
||||
var body: some View {
|
||||
Text(verbatim: /*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ImageView()
|
||||
}
|
||||
}
|
||||
@@ -8,37 +8,37 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FullScreenCarouselView<Content: View>: View {
|
||||
let video_controller: VideoController
|
||||
@ObservedObject var video_coordinator: DamusVideoCoordinator
|
||||
let urls: [MediaUrl]
|
||||
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
@State var showMenu = true
|
||||
|
||||
@State private var imageDict: [URL: UIImage] = [:]
|
||||
let settings: UserSettingsStore
|
||||
@Binding var selectedIndex: Int
|
||||
@ObservedObject var carouselSelection: CarouselSelection
|
||||
let content: (() -> Content)?
|
||||
|
||||
init(video_controller: VideoController, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.video_controller = video_controller
|
||||
init(video_coordinator: DamusVideoCoordinator, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.video_coordinator = video_coordinator
|
||||
self.urls = urls
|
||||
self._showMenu = State(initialValue: showMenu)
|
||||
self.settings = settings
|
||||
_selectedIndex = selectedIndex
|
||||
self._carouselSelection = ObservedObject(initialValue: CarouselSelection(index: selectedIndex.wrappedValue))
|
||||
self.content = content
|
||||
}
|
||||
|
||||
init(video_controller: VideoController, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>) {
|
||||
self.video_controller = video_controller
|
||||
init(video_coordinator: DamusVideoCoordinator, urls: [MediaUrl], showMenu: Bool = true, settings: UserSettingsStore, selectedIndex: Binding<Int>) {
|
||||
self.video_coordinator = video_coordinator
|
||||
self.urls = urls
|
||||
self._showMenu = State(initialValue: showMenu)
|
||||
self.settings = settings
|
||||
_selectedIndex = selectedIndex
|
||||
self._carouselSelection = ObservedObject(initialValue: CarouselSelection(index: selectedIndex.wrappedValue))
|
||||
self.content = nil
|
||||
}
|
||||
|
||||
var background: some ShapeStyle {
|
||||
if case .video = urls[safe: selectedIndex] {
|
||||
if case .video = urls[safe: carouselSelection.index] {
|
||||
return AnyShapeStyle(Color.black)
|
||||
}
|
||||
else {
|
||||
@@ -55,23 +55,24 @@ struct FullScreenCarouselView<Content: View>: View {
|
||||
Color(self.background_color)
|
||||
.ignoresSafeArea()
|
||||
|
||||
TabView(selection: $selectedIndex) {
|
||||
TabView(selection: $carouselSelection.index) {
|
||||
ForEach(urls.indices, id: \.self) { index in
|
||||
VStack {
|
||||
if case .video = urls[safe: index] {
|
||||
ImageContainerView(video_controller: video_controller, url: urls[index], settings: settings)
|
||||
.clipped() // SwiftUI hack from https://stackoverflow.com/a/74401288 to make playback controls show up within the TabView
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(.top, Theme.safeAreaInsets?.top)
|
||||
.padding(.bottom, Theme.safeAreaInsets?.bottom)
|
||||
.modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}))
|
||||
.ignoresSafeArea()
|
||||
ImageContainerView(
|
||||
video_coordinator: video_coordinator,
|
||||
url: urls[index],
|
||||
settings: settings,
|
||||
imageDict: $imageDict
|
||||
)
|
||||
.modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}))
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
else {
|
||||
ZoomableScrollView {
|
||||
ImageContainerView(video_controller: video_controller, url: urls[index], settings: settings)
|
||||
ImageContainerView(video_coordinator: video_coordinator, url: urls[index], settings: settings, imageDict: $imageDict)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(.top, Theme.safeAreaInsets?.top)
|
||||
.padding(.bottom, Theme.safeAreaInsets?.bottom)
|
||||
@@ -96,17 +97,49 @@ struct FullScreenCarouselView<Content: View>: View {
|
||||
GeometryReader { geo in
|
||||
VStack {
|
||||
if showMenu {
|
||||
NavDismissBarView(showBackgroundCircle: false)
|
||||
.foregroundColor(.white)
|
||||
HStack {
|
||||
Button(action: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}, label: {
|
||||
Image(systemName: "xmark")
|
||||
.frame(width: 30, height: 30)
|
||||
})
|
||||
.buttonStyle(PlayerCircleButtonStyle())
|
||||
|
||||
Spacer()
|
||||
|
||||
if let url = urls[safe: carouselSelection.index],
|
||||
let image = imageDict[url.url] {
|
||||
|
||||
ShareLink(item: Image(uiImage: image),
|
||||
preview: SharePreview(NSLocalizedString("Shared Picture",
|
||||
comment: "Label for the preview of the image being picture"),
|
||||
image: Image(uiImage: image))) {
|
||||
Image(systemName: "ellipsis")
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
.buttonStyle(PlayerCircleButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
|
||||
if urls.count > 1 {
|
||||
PageControlView(currentPage: $selectedIndex, numberOfPages: urls.count)
|
||||
.frame(maxWidth: 0, maxHeight: 0)
|
||||
.padding(.top, 5)
|
||||
VStack {
|
||||
if urls.count > 1 {
|
||||
PageControlView(currentPage: $carouselSelection.index, numberOfPages: urls.count)
|
||||
.frame(maxWidth: 0, maxHeight: 0)
|
||||
.padding(.top, 5)
|
||||
}
|
||||
|
||||
if let focused_video = video_coordinator.focused_video {
|
||||
DamusVideoControlsView(video: focused_video)
|
||||
}
|
||||
|
||||
self.content?()
|
||||
}
|
||||
|
||||
self.content?()
|
||||
.padding(.top, 5)
|
||||
.background(Color.black.opacity(0.7))
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut, value: showMenu)
|
||||
@@ -128,7 +161,7 @@ fileprivate struct FullScreenCarouselPreviewView<Content: View>: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
FullScreenCarouselView(video_controller: test_damus_state.video, urls: [test_video_url, url], settings: test_damus_state.settings, selectedIndex: $selectedIndex) {
|
||||
FullScreenCarouselView(video_coordinator: test_damus_state.video, urls: [test_video_url, url], settings: test_damus_state.settings, selectedIndex: $selectedIndex) {
|
||||
self.custom_content?()
|
||||
}
|
||||
.environmentObject(OrientationTracker())
|
||||
@@ -156,3 +189,11 @@ struct FullScreenCarouselView_Previews: PreviewProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Class to define object for monitoring selectedIndex and updating mutlples views
|
||||
final class CarouselSelection: ObservableObject {
|
||||
@Published var index: Int
|
||||
init(index: Int) {
|
||||
self.index = index
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,18 +10,29 @@ import Kingfisher
|
||||
|
||||
|
||||
struct ImageContainerView: View {
|
||||
let video_controller: VideoController
|
||||
let video_coordinator: DamusVideoCoordinator
|
||||
let url: MediaUrl
|
||||
let settings: UserSettingsStore
|
||||
|
||||
@Binding var imageDict: [URL: UIImage]
|
||||
@State private var image: UIImage?
|
||||
@State private var showShareSheet = false
|
||||
|
||||
init(video_coordinator: DamusVideoCoordinator, url: MediaUrl, settings: UserSettingsStore, imageDict: Binding<[URL: UIImage]>) {
|
||||
self.video_coordinator = video_coordinator
|
||||
self.url = url
|
||||
self.settings = settings
|
||||
self._imageDict = imageDict
|
||||
}
|
||||
|
||||
private struct ImageHandler: ImageModifier {
|
||||
@Binding var handler: UIImage?
|
||||
@Binding var imageDict: [URL: UIImage]
|
||||
let url: URL
|
||||
|
||||
func modify(_ image: UIImage) -> UIImage {
|
||||
handler = image
|
||||
imageDict[url] = image
|
||||
return image
|
||||
}
|
||||
}
|
||||
@@ -32,7 +43,8 @@ struct ImageContainerView: View {
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.imageModifier(ImageHandler(handler: $image))
|
||||
.imageModifier(ImageHandler(handler: $image, imageDict: $imageDict, url: url))
|
||||
.kfClickable()
|
||||
.clipped()
|
||||
.modifier(ImageContextMenuModifier(url: url, image: image, settings: settings, showShareSheet: $showShareSheet))
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
@@ -46,7 +58,7 @@ struct ImageContainerView: View {
|
||||
case .image(let url):
|
||||
Img(url: url)
|
||||
case .video(let url):
|
||||
DamusVideoPlayer(url: url, video_size: .constant(nil), controller: video_controller, style: .full, visibility_tracking_method: .generic)
|
||||
DamusVideoPlayerView(url: url, coordinator: video_coordinator, style: .no_controls(on_tap: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,10 +69,11 @@ fileprivate let test_video_url = URL(string: "http://cdn.jb55.com/s/zaps-build.m
|
||||
|
||||
struct ImageContainerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
@State var imageDict: [URL: UIImage] = [:]
|
||||
Group {
|
||||
ImageContainerView(video_controller: test_damus_state.video, url: .image(test_image_url), settings: test_damus_state.settings)
|
||||
ImageContainerView(video_coordinator: test_damus_state.video, url: .image(test_image_url), settings: test_damus_state.settings, imageDict: $imageDict)
|
||||
.previewDisplayName("Image")
|
||||
ImageContainerView(video_controller: test_damus_state.video, url: .video(test_video_url), settings: test_damus_state.settings)
|
||||
ImageContainerView(video_coordinator: test_damus_state.video, url: .video(test_video_url), settings: test_damus_state.settings, imageDict: $imageDict)
|
||||
.previewDisplayName("Video")
|
||||
}
|
||||
.environmentObject(OrientationTracker())
|
||||
|
||||
@@ -78,7 +78,7 @@ struct ImageContextMenuModifier: ViewModifier {
|
||||
Label(NSLocalizedString("Share", comment: "Button to share an image."), image: "upload")
|
||||
}
|
||||
}
|
||||
.alert(NSLocalizedString("Found\n \(qrCodeValue)", comment: "Alert message asking if the user wants to open the link.").truncate(maxLength: 50), isPresented: $open_link_confirm) {
|
||||
.alert(String(format: NSLocalizedString("Found\n %@", comment: "Alert message asking if the user wants to open the link."), qrCodeValue).truncate(maxLength: 50), isPresented: $open_link_confirm) {
|
||||
if open_wallet_confirm {
|
||||
Button(NSLocalizedString("Open in wallet", comment: "Button to open the value found in browser."), role: .none) {
|
||||
do {
|
||||
|
||||
@@ -10,8 +10,7 @@ import Kingfisher
|
||||
struct ProfileImageContainerView: View {
|
||||
let url: URL?
|
||||
let settings: UserSettingsStore
|
||||
|
||||
@State private var image: UIImage?
|
||||
@Binding var image: UIImage?
|
||||
@State private var showShareSheet = false
|
||||
|
||||
private struct ImageHandler: ImageModifier {
|
||||
@@ -33,19 +32,25 @@ struct ProfileImageContainerView: View {
|
||||
.imageModifier(ImageHandler(handler: $image))
|
||||
.clipShape(Circle())
|
||||
.modifier(ImageContextMenuModifier(url: url, image: image, settings: settings, showShareSheet: $showShareSheet))
|
||||
.kfClickable()
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
ShareSheet(activityItems: [url])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NavDismissBarContainer {
|
||||
case fullScreenCarousel
|
||||
case profilePicImageView
|
||||
}
|
||||
|
||||
struct NavDismissBarView: View {
|
||||
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
let showBackgroundCircle: Bool
|
||||
let navDismissBarContainer: NavDismissBarContainer
|
||||
|
||||
init(showBackgroundCircle: Bool = true) {
|
||||
self.showBackgroundCircle = showBackgroundCircle
|
||||
init(navDismissBarContainer: NavDismissBarContainer) {
|
||||
self.navDismissBarContainer = navDismissBarContainer
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -53,15 +58,18 @@ struct NavDismissBarView: View {
|
||||
Button(action: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}, label: {
|
||||
if showBackgroundCircle {
|
||||
switch navDismissBarContainer {
|
||||
case .profilePicImageView:
|
||||
Image("close")
|
||||
.frame(width: 33, height: 33)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
else {
|
||||
|
||||
case .fullScreenCarousel:
|
||||
Image("close")
|
||||
.frame(width: 33, height: 33)
|
||||
.background(.damusBlack)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -75,6 +83,10 @@ struct ProfilePicImageView: View {
|
||||
let pubkey: Pubkey
|
||||
let profiles: Profiles
|
||||
let settings: UserSettingsStore
|
||||
let nav: NavigationCoordinator
|
||||
let shouldShowEditButton: Bool
|
||||
@State var image: UIImage?
|
||||
@State var showMenu = true
|
||||
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
@@ -84,18 +96,57 @@ struct ProfilePicImageView: View {
|
||||
.ignoresSafeArea()
|
||||
|
||||
ZoomableScrollView {
|
||||
ProfileImageContainerView(url: get_profile_url(picture: nil, pubkey: pubkey, profiles: profiles), settings: settings)
|
||||
ProfileImageContainerView(url: get_profile_url(picture: nil, pubkey: pubkey, profiles: profiles), settings: settings, image: $image)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(.top, Theme.safeAreaInsets?.top)
|
||||
.padding(.bottom, Theme.safeAreaInsets?.bottom)
|
||||
.padding(.horizontal)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.modifier(SwipeToDismissModifier(minDistance: 50, onDismiss: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}))
|
||||
}
|
||||
.overlay(NavDismissBarView(), alignment: .top)
|
||||
.overlay(
|
||||
Group {
|
||||
if showMenu {
|
||||
HStack {
|
||||
NavDismissBarView(navDismissBarContainer: .profilePicImageView)
|
||||
if let image = image {
|
||||
ShareLink(item: Image(uiImage: image),
|
||||
preview: SharePreview(NSLocalizedString("Damus Profile", comment: "Label for the preview of the profile picture"), image: Image(uiImage: image))) {
|
||||
Image(systemName: "ellipsis")
|
||||
.frame(width: 33, height: 33)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
alignment: .top
|
||||
)
|
||||
.overlay(
|
||||
shouldShowEditButton && showMenu ?
|
||||
Button(action: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
nav.push(route: Route.EditMetadata)
|
||||
}) {
|
||||
Text("Edit", comment: "Edit Button for editing profile")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(Color("DamusPurple"))
|
||||
Spacer()
|
||||
}
|
||||
.padding([.vertical, .leading], 20)
|
||||
: nil,
|
||||
alignment: .bottomLeading
|
||||
)
|
||||
.gesture(TapGesture(count: 1).onEnded {
|
||||
showMenu.toggle()
|
||||
})
|
||||
.animation(.easeInOut, value: showMenu)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +155,6 @@ struct ProfileZoomView_Previews: PreviewProvider {
|
||||
ProfilePicImageView(
|
||||
pubkey: test_pubkey,
|
||||
profiles: make_preview_profiles(test_pubkey),
|
||||
settings: test_damus_state.settings
|
||||
)
|
||||
settings: test_damus_state.settings, nav: test_damus_state.nav, shouldShowEditButton: true)
|
||||
}
|
||||
}
|
||||
|
||||
+32
-19
@@ -5,6 +5,7 @@
|
||||
// Created by William Casarin on 2022-05-22.
|
||||
//
|
||||
|
||||
import CodeScanner
|
||||
import SwiftUI
|
||||
|
||||
enum ParsedKey {
|
||||
@@ -103,6 +104,7 @@ struct LoginView: View {
|
||||
}
|
||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: 12, alignment: .center)
|
||||
}
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.sign_in_confirm_button.rawValue)
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
.padding(.top, 10)
|
||||
}
|
||||
@@ -298,27 +300,35 @@ struct KeyInput: View {
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
.foregroundColor(.gray)
|
||||
.onTapGesture {
|
||||
if let pastedkey = UIPasteboard.general.string {
|
||||
self.key.wrappedValue = pastedkey
|
||||
}
|
||||
Button(action: {
|
||||
if let pastedkey = UIPasteboard.general.string {
|
||||
self.key.wrappedValue = pastedkey
|
||||
}
|
||||
}, label: {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
})
|
||||
.foregroundColor(.gray)
|
||||
.accessibilityLabel(NSLocalizedString("Paste private key", comment: "Accessibility label for the private key paste button"))
|
||||
|
||||
SignInScan(shouldSaveKey: shouldSaveKey, loginKey: key, privKeyFound: privKeyFound)
|
||||
|
||||
if is_secured {
|
||||
SecureField("", text: key)
|
||||
.nsecLoginStyle(key: key.wrappedValue, title: title)
|
||||
} else {
|
||||
TextField("", text: key)
|
||||
.nsecLoginStyle(key: key.wrappedValue, title: title)
|
||||
}
|
||||
Image(systemName: "eye.slash")
|
||||
.foregroundColor(.gray)
|
||||
.onTapGesture {
|
||||
is_secured.toggle()
|
||||
}
|
||||
SecureField("", text: key)
|
||||
.nsecLoginStyle(key: key.wrappedValue, title: title)
|
||||
.accessibilityLabel(NSLocalizedString("Account private key", comment: "Accessibility label for the private key input field"))
|
||||
} else {
|
||||
TextField("", text: key)
|
||||
.nsecLoginStyle(key: key.wrappedValue, title: title)
|
||||
.accessibilityLabel(NSLocalizedString("Account private key", comment: "Accessibility label for the private key input field"))
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
is_secured.toggle()
|
||||
}, label: {
|
||||
Image(systemName: "eye.slash")
|
||||
})
|
||||
.foregroundColor(.gray)
|
||||
.accessibilityLabel(NSLocalizedString("Toggle key visibility", comment: "Accessibility label for toggling the visibility of the private key input field"))
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 10)
|
||||
@@ -341,6 +351,7 @@ struct SignInHeader: View {
|
||||
.frame(width: 56, height: 56, alignment: .center)
|
||||
.shadow(color: DamusColors.purple, radius: 2)
|
||||
.padding(.bottom)
|
||||
.accessibilityLabel(NSLocalizedString("Damus logo", comment: "Accessibility label for damus logo"))
|
||||
|
||||
Text("Sign in", comment: "Title of view to log into an account.")
|
||||
.foregroundColor(DamusColors.neutral6)
|
||||
@@ -364,10 +375,12 @@ struct SignInEntry: View {
|
||||
.fontWeight(.medium)
|
||||
.padding(.top, 30)
|
||||
|
||||
KeyInput(NSLocalizedString("nsec1...", comment: "Prompt for user to enter in an account key to login. This text shows the characters the key could start with if it was a private key."),
|
||||
KeyInput(NSLocalizedString("nsec1…", comment: "Prompt for user to enter in an account key to login. This text shows the characters the key could start with if it was a private key."),
|
||||
key: key,
|
||||
shouldSaveKey: shouldSaveKey,
|
||||
privKeyFound: $privKeyFound)
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.sign_in_nsec_key_entry_field.rawValue)
|
||||
|
||||
if privKeyFound {
|
||||
Toggle(NSLocalizedString("Save Key in Secure Keychain", comment: "Toggle to save private key to the Apple secure keychain."), isOn: shouldSaveKey)
|
||||
}
|
||||
@@ -388,7 +401,7 @@ struct SignInScan: View {
|
||||
Button(action: { showQR.toggle() }, label: {
|
||||
Image(systemName: "qrcode.viewfinder")})
|
||||
.foregroundColor(.gray)
|
||||
|
||||
.accessibilityLabel(NSLocalizedString("Scan QR code", comment: "Accessibility label for a button that scans a private key QR code"))
|
||||
}
|
||||
.sheet(isPresented: $showQR, onDismiss: {
|
||||
if qrkey == nil { resetView() }}
|
||||
|
||||
@@ -66,7 +66,9 @@ struct TabButton: View {
|
||||
|
||||
struct TabBar: View {
|
||||
var nstatus: NotificationStatusModel
|
||||
var navIsAtRoot: Bool
|
||||
@Binding var selected: Timeline
|
||||
@Binding var headerOffset: CGFloat
|
||||
|
||||
let settings: UserSettingsStore
|
||||
let action: (Timeline) -> ()
|
||||
@@ -81,5 +83,6 @@ struct TabBar: View {
|
||||
TabButton(timeline: .notifications, img: "notification-bell", selected: $selected, nstatus: nstatus, settings: settings, action: action).keyboardShortcut("4")
|
||||
}
|
||||
}
|
||||
.opacity(selected != .home || (selected == .home && !navIsAtRoot) ? 1.0 : 0.35 + abs(1.25 - (abs(headerOffset/100.0))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,18 +9,27 @@ import UIKit
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
enum MediaPickerEntry {
|
||||
case editPictureControl
|
||||
case postView
|
||||
}
|
||||
|
||||
struct MediaPicker: UIViewControllerRepresentable {
|
||||
|
||||
@Environment(\.presentationMode)
|
||||
@Binding private var presentationMode
|
||||
let mediaPickerEntry: MediaPickerEntry
|
||||
|
||||
@Binding var image_upload_confirm: Bool
|
||||
var imagesOnly: Bool = false
|
||||
let onMediaPicked: (PreUploadedMedia) -> Void
|
||||
|
||||
|
||||
final class Coordinator: NSObject, PHPickerViewControllerDelegate {
|
||||
let parent: MediaPicker
|
||||
var parent: MediaPicker
|
||||
|
||||
// properties used for returning medias in the same order as picking
|
||||
let dispatchGroup: DispatchGroup = DispatchGroup()
|
||||
var orderIds: [String] = []
|
||||
var orderMap: [String: PreUploadedMedia] = [:]
|
||||
|
||||
init(_ parent: MediaPicker) {
|
||||
self.parent = parent
|
||||
@@ -31,7 +40,16 @@ struct MediaPicker: UIViewControllerRepresentable {
|
||||
self.parent.presentationMode.dismiss()
|
||||
}
|
||||
|
||||
// When user dismiss the upload confirmation and re-adds again, reset orderIds and orderMap
|
||||
orderIds.removeAll()
|
||||
orderMap.removeAll()
|
||||
|
||||
for result in results {
|
||||
|
||||
let orderId = result.assetIdentifier ?? UUID().uuidString
|
||||
orderIds.append(orderId)
|
||||
dispatchGroup.enter()
|
||||
|
||||
if result.itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
||||
result.itemProvider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { (item, error) in
|
||||
guard let url = item as? URL else { return }
|
||||
@@ -50,7 +68,7 @@ struct MediaPicker: UIViewControllerRepresentable {
|
||||
do {
|
||||
try imageData.write(to: destinationURL)
|
||||
Task {
|
||||
await self.chooseMedia(.processed_image(destinationURL))
|
||||
await self.chooseMedia(.processed_image(destinationURL), orderId: orderId)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
@@ -64,13 +82,13 @@ struct MediaPicker: UIViewControllerRepresentable {
|
||||
url: url,
|
||||
fallback: processImage,
|
||||
unprocessedEnum: {.unprocessed_image($0)},
|
||||
processedEnum: {.processed_image($0)}
|
||||
)
|
||||
processedEnum: {.processed_image($0)},
|
||||
orderId: orderId)
|
||||
} else {
|
||||
// Media was taken from camera
|
||||
result.itemProvider.loadObject(ofClass: UIImage.self) { (image, error) in
|
||||
if let image = image as? UIImage, error == nil {
|
||||
self.chooseMedia(.uiimage(image))
|
||||
self.chooseMedia(.uiimage(image), orderId: orderId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,41 +101,60 @@ struct MediaPicker: UIViewControllerRepresentable {
|
||||
url: url,
|
||||
fallback: processVideo,
|
||||
unprocessedEnum: {.unprocessed_video($0)},
|
||||
processedEnum: {.processed_video($0)}
|
||||
processedEnum: {.processed_video($0)}, orderId: orderId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.notify(queue: .main) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
var arrMedia: [PreUploadedMedia] = []
|
||||
for id in self.orderIds {
|
||||
if let media = self.orderMap[id] {
|
||||
arrMedia.append(media)
|
||||
self.parent.onMediaPicked(media)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseMedia(_ media: PreUploadedMedia) {
|
||||
self.parent.onMediaPicked(media)
|
||||
|
||||
private func chooseMedia(_ media: PreUploadedMedia, orderId: String) {
|
||||
self.parent.image_upload_confirm = true
|
||||
self.orderMap[orderId] = media
|
||||
self.dispatchGroup.leave()
|
||||
}
|
||||
|
||||
private func attemptAcquireResourceAndChooseMedia(url: URL, fallback: (URL) -> URL?, unprocessedEnum: (URL) -> PreUploadedMedia, processedEnum: (URL) -> PreUploadedMedia) {
|
||||
private func attemptAcquireResourceAndChooseMedia(url: URL, fallback: (URL) -> URL?, unprocessedEnum: (URL) -> PreUploadedMedia, processedEnum: (URL) -> PreUploadedMedia, orderId: String) {
|
||||
if url.startAccessingSecurityScopedResource() {
|
||||
// Have permission from system to use url out of scope
|
||||
print("Acquired permission to security scoped resource")
|
||||
self.chooseMedia(unprocessedEnum(url))
|
||||
self.chooseMedia(unprocessedEnum(url), orderId: orderId)
|
||||
} else {
|
||||
// Need to copy URL to non-security scoped location
|
||||
guard let newUrl = fallback(url) else { return }
|
||||
self.chooseMedia(processedEnum(newUrl))
|
||||
self.chooseMedia(processedEnum(newUrl), orderId: orderId)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> PHPickerViewController {
|
||||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||
configuration.selectionLimit = 1
|
||||
configuration.filter = imagesOnly ? .images : .any(of: [.images, .videos])
|
||||
|
||||
switch mediaPickerEntry {
|
||||
case .postView:
|
||||
configuration.selectionLimit = 0 // allows multiple media selection
|
||||
configuration.filter = .any(of: [.images, .videos])
|
||||
configuration.selection = .ordered // images are returned in the order they were selected + numbered badge displayed
|
||||
case .editPictureControl:
|
||||
configuration.selectionLimit = 1 // allows one media selection
|
||||
configuration.filter = .images // allows image only
|
||||
}
|
||||
let picker = PHPickerViewController(configuration: configuration)
|
||||
picker.delegate = context.coordinator as any PHPickerViewControllerDelegate
|
||||
return picker
|
||||
|
||||
@@ -8,11 +8,15 @@ import SwiftUI
|
||||
|
||||
struct AddMuteItemView: View {
|
||||
let state: DamusState
|
||||
@State var new_text: String = ""
|
||||
@Binding var new_text: String
|
||||
@State var expiration: DamusDuration = .indefinite
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var trimmedText: String {
|
||||
new_text.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("Add mute item", comment: "Title text to indicate user to an add an item to their mutelist.")
|
||||
@@ -30,12 +34,13 @@ struct AddMuteItemView: View {
|
||||
Text("Duration", comment: "The duration in which to mute the given item.")
|
||||
}
|
||||
|
||||
let trimmedText = self.trimmedText
|
||||
|
||||
HStack {
|
||||
Label("", image: "copy2")
|
||||
.onTapGesture {
|
||||
if let pasted_text = UIPasteboard.general.string {
|
||||
self.new_text = pasted_text
|
||||
self.new_text = pasted_text.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
TextField(NSLocalizedString("npub, #hashtag, phrase", comment: "Placeholder example for relay server address."), text: $new_text)
|
||||
@@ -44,7 +49,7 @@ struct AddMuteItemView: View {
|
||||
|
||||
Label("", image: "close-circle")
|
||||
.foregroundColor(.accentColor)
|
||||
.opacity((new_text == "") ? 0.0 : 1.0)
|
||||
.opacity(trimmedText.isEmpty ? 0.0 : 1.0)
|
||||
.onTapGesture {
|
||||
self.new_text = ""
|
||||
}
|
||||
@@ -56,17 +61,17 @@ struct AddMuteItemView: View {
|
||||
Button(action: {
|
||||
let expiration_date: Date? = self.expiration.date_from_now
|
||||
let mute_item: MuteItem? = {
|
||||
if new_text.starts(with: "npub") {
|
||||
if let pubkey: Pubkey = bech32_pubkey_decode(new_text) {
|
||||
if trimmedText.starts(with: "npub") {
|
||||
if let pubkey: Pubkey = bech32_pubkey_decode(trimmedText) {
|
||||
return .user(pubkey, expiration_date)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
} else if new_text.starts(with: "#") {
|
||||
} else if trimmedText.starts(with: "#") {
|
||||
// Remove the starting `#` character
|
||||
return .hashtag(Hashtag(hashtag: String("\(new_text)".dropFirst())), expiration_date)
|
||||
return .hashtag(Hashtag(hashtag: String("\(trimmedText)".dropFirst())), expiration_date)
|
||||
} else {
|
||||
return .word(new_text, expiration_date)
|
||||
return .word(trimmedText, expiration_date)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -87,18 +92,20 @@ struct AddMuteItemView: View {
|
||||
|
||||
new_text = ""
|
||||
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
|
||||
dismiss()
|
||||
}) {
|
||||
HStack {
|
||||
Text(verbatim: "Add mute item")
|
||||
Text("Add mute item", comment: "Button to an add an item to the user's mutelist.")
|
||||
.bold()
|
||||
}
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle(padding: 10))
|
||||
.padding(.vertical)
|
||||
.opacity(trimmedText.isEmpty ? 0.5 : 1.0)
|
||||
.disabled(trimmedText.isEmpty)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
@@ -108,6 +115,6 @@ struct AddMuteItemView: View {
|
||||
|
||||
struct AddMuteItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddMuteItemView(state: test_damus_state)
|
||||
AddMuteItemView(state: test_damus_state, new_text: .constant(""))
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user