Compare commits
343 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 | |||
|
847f31f5a6
|
|||
| fd130b78e7 | |||
| 0be0273121 | |||
| b349de22b7 | |||
| cc2d196705 | |||
| 53be29efc2 | |||
| 529ee63f29 | |||
| 490e8ec1fb | |||
| df267ffd04 | |||
| b771e8f49a | |||
| a88e80a346 | |||
|
8ac9863765
|
|||
| 4a851501a1 | |||
| 4ccfe81558 | |||
| e7ed9dfe86 | |||
| 0dce7aea45 | |||
| 6376c61bad | |||
|
bdd1403a7d
|
|||
| 23c3130a82 | |||
|
9172102f4d
|
|||
| 8bcd8317f1 | |||
|
6cd9d7b1da
|
|||
| 2c84184dbd | |||
| 901a6fc98f | |||
| a0f6bdd8d9 | |||
| 8feb228ea0 | |||
| b148fb735e | |||
| 0a9bcb6189 | |||
| 5a68cfa448 | |||
| c99aaea598 | |||
| 46185c55d1 | |||
| 52aefc8d64 | |||
| 8dbdff7ff0 | |||
| 784fb20b4f | |||
| 0d9954290a | |||
| 13a7ee82d0 | |||
| 23138c5e03 | |||
| 213a622dde | |||
| 4ac3da7612 | |||
| bb1f912f78 | |||
| a190a5e8fb | |||
| 514a053dce | |||
| 0b199a18b4 | |||
| 23a125ea0f | |||
| f406d27507 | |||
| ceb6eb03fb | |||
| b917b4e9d6 | |||
| e981ae247e | |||
| dcd7b5b111 | |||
| a721256e9b | |||
| 007bcc8687 | |||
| ccb94e6d69 | |||
| 3c9fd36654 | |||
| 9a9b5d5f4f | |||
| d4f041aead | |||
| 0a6e40798a |
@@ -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>
|
||||
|
||||
@@ -28,7 +28,7 @@ struct NotificationExtensionState: HeadlessDamusState {
|
||||
self.settings = UserSettingsStore()
|
||||
|
||||
self.contacts = Contacts(our_pubkey: keypair.pubkey)
|
||||
self.mutelist_manager = MutelistManager()
|
||||
self.mutelist_manager = MutelistManager(user_keypair: keypair)
|
||||
self.keypair = keypair
|
||||
self.profiles = Profiles(ndb: ndb)
|
||||
self.zaps = Zaps(our_pubkey: keypair.pubkey)
|
||||
|
||||
@@ -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,23 +39,50 @@ 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) {
|
||||
// We cannot really suppress muted notifications until we have the notification supression entitlement.
|
||||
// The best we can do if we ever get those muted notifications (which we generally won't due to server-side processing) is to obscure the details
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = NSLocalizedString("Muted event", comment: "Title for a push notification which has been muted")
|
||||
content.body = NSLocalizedString("This is an event that has been muted according to your mute list rules. We cannot suppress this notification, but we obscured the details to respect your preferences", comment: "Description for a push notification which has been muted, and explanation that we cannot suppress it")
|
||||
content.sound = UNNotificationSound.default
|
||||
contentHandler(content)
|
||||
return
|
||||
}
|
||||
|
||||
guard should_display_notification(state: state, event: nostr_event) else {
|
||||
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())
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
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())
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-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>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>1C8F.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-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>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>1C8F.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
+2565
-88
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,32 @@
|
||||
{
|
||||
"originHash" : "534c8e58993919d5ead25ceb4788c8e492c86bc2cf5833dc651ae60a0f30169c",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "codescanner",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/twostraws/CodeScanner.git",
|
||||
"state" : {
|
||||
"revision" : "9fa582f4b36c69c2a55bff5fb3377eb170ae273c"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "emojikit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/tyiu/EmojiKit",
|
||||
"state" : {
|
||||
"revision" : "05805f72d63a6d6a2d7dc7fe14abd37c1317b11a",
|
||||
"version" : "0.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "emojipicker",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/tyiu/EmojiPicker.git",
|
||||
"state" : {
|
||||
"revision" : "0c28b4a1a6b8840cf2580bda59517f6d0a733dc8",
|
||||
"version" : "0.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gsplayer",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -18,15 +45,6 @@
|
||||
"version" : "7.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mcemojipicker",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/izyumkin/MCEmojiPicker",
|
||||
"state" : {
|
||||
"revision" : "e0b4903b75ae1cc418d276d84d1cb946b8a1d73c",
|
||||
"version" : "1.2.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "secp256k1.swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -35,6 +53,15 @@
|
||||
"revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "ee97538f5b81ae89698fd95938896dec5217b148",
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown-ui",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -60,7 +87,24 @@
|
||||
"revision" : "74203046135342e4a4a627476dd6caf8b28fe11b",
|
||||
"version" : "509.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-trie",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/tyiu/swift-trie",
|
||||
"state" : {
|
||||
"revision" : "4c50bff6c168f74425f70476be62a072980d2da7",
|
||||
"version" : "0.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swipeactions",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/damus-io/SwipeActions.git",
|
||||
"state" : {
|
||||
"revision" : "33d99756c3112e1a07c1732e3cddc5ad5bd0c5f4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
"version" : 3
|
||||
}
|
||||
|
||||
@@ -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,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xD7",
|
||||
"green" : "0xD1",
|
||||
"red" : "0xD1"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x13",
|
||||
"green" : "0x11",
|
||||
"red" : "0x11"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xF9",
|
||||
"green" : "0xF3",
|
||||
"red" : "0xF3"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x25",
|
||||
"green" : "0x22",
|
||||
"red" : "0x22"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "244",
|
||||
"green" : "218",
|
||||
"red" : "244"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "92",
|
||||
"green" : "45",
|
||||
"red" : "93"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "236",
|
||||
"green" : "194",
|
||||
"red" : "238"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "109",
|
||||
"green" : "49",
|
||||
"red" : "111"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "197",
|
||||
"green" : "67",
|
||||
"red" : "204"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "255",
|
||||
"green" : "194",
|
||||
"red" : "255"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xF2",
|
||||
"green" : "0xD8",
|
||||
"red" : "0xF4"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x45",
|
||||
"green" : "0x17",
|
||||
"red" : "0x47"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,6 +10,11 @@ import SwiftUI
|
||||
|
||||
class DamusColors {
|
||||
static let adaptableGrey = Color("DamusAdaptableGrey")
|
||||
static let adaptableGrey2 = Color("DamusAdaptableGrey 2")
|
||||
static let adaptableLighterGrey = Color("DamusAdaptableLighterGrey")
|
||||
static let adaptablePurpleBackground = Color("DamusAdaptablePurpleBackground 1")
|
||||
static let adaptablePurpleBackground2 = Color("DamusAdaptablePurpleBackground 2")
|
||||
static let adaptablePurpleForeground = Color("DamusAdaptablePurpleForeground")
|
||||
static let adaptableBlack = Color("DamusAdaptableBlack")
|
||||
static let adaptableWhite = Color("DamusAdaptableWhite")
|
||||
static let white = Color("DamusWhite")
|
||||
@@ -23,6 +28,7 @@ class DamusColors {
|
||||
static let green = Color("DamusGreen")
|
||||
static let purple = Color("DamusPurple")
|
||||
static let deepPurple = Color("DamusDeepPurple")
|
||||
static let highlight = Color("DamusHighlight")
|
||||
static let blue = Color("DamusBlue")
|
||||
static let bitcoin = Color("Bitcoin")
|
||||
static let success = Color("DamusSuccessPrimary")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -9,16 +9,19 @@ import UIKit
|
||||
import SwiftUI
|
||||
|
||||
struct SelectableText: View {
|
||||
|
||||
let damus_state: DamusState
|
||||
let event: NostrEvent?
|
||||
let attributedString: AttributedString
|
||||
let textAlignment: NSTextAlignment
|
||||
|
||||
@State private var selectedTextActionState: SelectedTextActionState = .hide
|
||||
@State private var selectedTextHeight: CGFloat = .zero
|
||||
@State private var selectedTextWidth: CGFloat = .zero
|
||||
|
||||
|
||||
let size: EventViewKind
|
||||
|
||||
init(attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) {
|
||||
|
||||
init(damus_state: DamusState, event: NostrEvent?, attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) {
|
||||
self.damus_state = damus_state
|
||||
self.event = event
|
||||
self.attributedString = attributedString
|
||||
self.textAlignment = textAlignment ?? NSTextAlignment.natural
|
||||
self.size = size
|
||||
@@ -32,6 +35,13 @@ struct SelectableText: View {
|
||||
font: eventviewsize_to_uifont(size),
|
||||
fixedWidth: selectedTextWidth,
|
||||
textAlignment: self.textAlignment,
|
||||
enableHighlighting: self.enableHighlighting(),
|
||||
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)
|
||||
@@ -46,22 +56,123 @@ struct SelectableText: View {
|
||||
self.selectedTextWidth = newSize.width
|
||||
}
|
||||
}
|
||||
.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(300), .medium, .large])
|
||||
}
|
||||
}
|
||||
.frame(height: selectedTextHeight)
|
||||
}
|
||||
|
||||
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 struct TextViewRepresentable: UIViewRepresentable {
|
||||
fileprivate class TextView: UITextView {
|
||||
var postHighlight: (String) -> Void
|
||||
var muteWord: (String) -> Void
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
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 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 {
|
||||
|
||||
let attributedString: AttributedString
|
||||
let textColor: UIColor
|
||||
let font: UIFont
|
||||
let fixedWidth: CGFloat
|
||||
let textAlignment: NSTextAlignment
|
||||
|
||||
let enableHighlighting: Bool
|
||||
let postHighlight: (String) -> Void
|
||||
let muteWord: (String) -> Void
|
||||
@Binding var height: CGFloat
|
||||
|
||||
func makeUIView(context: UIViewRepresentableContext<Self>) -> UITextView {
|
||||
let view = UITextView()
|
||||
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
|
||||
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord)
|
||||
view.isEditable = false
|
||||
view.dataDetectorTypes = .all
|
||||
view.isSelectable = true
|
||||
@@ -71,10 +182,16 @@ struct SelectableText: View {
|
||||
view.textContainerInset.left = 1.0
|
||||
view.textContainerInset.right = 1.0
|
||||
view.textAlignment = textAlignment
|
||||
|
||||
let menuController = UIMenuController.shared
|
||||
let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:)))
|
||||
let muteItem = UIMenuItem(title: "Mute", action: #selector(view.muteText(_:)))
|
||||
menuController.menuItems = self.enableHighlighting ? [highlightItem, muteItem] : []
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<Self>) {
|
||||
func updateUIView(_ uiView: TextView, context: UIViewRepresentableContext<Self>) {
|
||||
let mutableAttributedString = createNSAttributedString()
|
||||
uiView.attributedText = mutableAttributedString
|
||||
uiView.textAlignment = self.textAlignment
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -51,9 +58,9 @@ struct TranslateView: View {
|
||||
.foregroundColor(.gray)
|
||||
.font(.footnote)
|
||||
.padding([.top, .bottom], 10)
|
||||
|
||||
|
||||
if self.size == .selected {
|
||||
SelectableText(attributedString: artifacts.content.attributed, size: self.size)
|
||||
SelectableText(damus_state: damus_state, event: event, attributedString: artifacts.content.attributed, size: self.size)
|
||||
} else {
|
||||
artifacts.content.text
|
||||
.font(eventviewsize_to_font(self.size, font_size: font_size))
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,12 @@ import SwiftUI
|
||||
struct TruncatedText: View {
|
||||
let text: CompatibleText
|
||||
let maxChars: Int
|
||||
let show_show_more_button: Bool
|
||||
|
||||
init(text: CompatibleText, maxChars: Int = 280) {
|
||||
init(text: CompatibleText, maxChars: Int = 280, show_show_more_button: Bool) {
|
||||
self.text = text
|
||||
self.maxChars = maxChars
|
||||
self.show_show_more_button = show_show_more_button
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -29,8 +31,10 @@ struct TruncatedText: View {
|
||||
|
||||
if truncatedAttributedString != nil {
|
||||
Spacer()
|
||||
Button(NSLocalizedString("Show more", comment: "Button to show entire note.")) { }
|
||||
.allowsHitTesting(false)
|
||||
if self.show_show_more_button {
|
||||
Button(NSLocalizedString("Show more", comment: "Button to show entire note.")) { }
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,10 +42,10 @@ struct TruncatedText: View {
|
||||
struct TruncatedText_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack(spacing: 100) {
|
||||
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven"))
|
||||
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven"), show_show_more_button: true)
|
||||
.frame(width: 200, height: 200)
|
||||
|
||||
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour"))
|
||||
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour"), show_show_more_button: true)
|
||||
.frame(width: 200, height: 200)
|
||||
}
|
||||
}
|
||||
|
||||
+44
-53
@@ -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)
|
||||
@@ -59,66 +59,57 @@ func parse_note_content(content: NoteContent) -> Blocks {
|
||||
}
|
||||
}
|
||||
|
||||
func interpret_event_refs_ndb(blocks: [Block], tags: TagsSequence) -> [EventRef] {
|
||||
if tags.count == 0 {
|
||||
return []
|
||||
}
|
||||
|
||||
/// build a set of indices for each event mention
|
||||
let mention_indices = build_mention_indices(blocks, type: .e)
|
||||
|
||||
/// simpler case with no mentions
|
||||
if mention_indices.count == 0 {
|
||||
return interp_event_refs_without_mentions_ndb(tags.note.referenced_noterefs)
|
||||
}
|
||||
|
||||
return interp_event_refs_with_mentions_ndb(tags: tags, mention_indices: mention_indices)
|
||||
func interpret_event_refs(tags: TagsSequence) -> ThreadReply? {
|
||||
// migration is long over, lets just do this to fix tests
|
||||
return interpret_event_refs_ndb(tags: tags)
|
||||
}
|
||||
|
||||
func interp_event_refs_without_mentions_ndb(_ ev_tags: References<NoteRef>) -> [EventRef] {
|
||||
func interpret_event_refs_ndb(tags: TagsSequence) -> ThreadReply? {
|
||||
if tags.count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var count = 0
|
||||
var evrefs: [EventRef] = []
|
||||
return interp_event_refs_without_mentions_ndb(References<NoteRef>(tags: tags))
|
||||
}
|
||||
|
||||
func interp_event_refs_without_mentions_ndb(_ ev_tags: References<NoteRef>) -> ThreadReply? {
|
||||
var first: Bool = true
|
||||
var first_ref: NoteRef? = nil
|
||||
var root_id: NoteRef? = nil
|
||||
var reply_id: NoteRef? = nil
|
||||
var mention: NoteRef? = nil
|
||||
var any_marker: Bool = false
|
||||
|
||||
for ref in ev_tags {
|
||||
if first {
|
||||
first_ref = ref
|
||||
evrefs.append(.thread_id(ref))
|
||||
first = false
|
||||
} else {
|
||||
|
||||
evrefs.append(.reply(ref))
|
||||
}
|
||||
count += 1
|
||||
}
|
||||
|
||||
if let first_ref, count == 1 {
|
||||
let r = first_ref
|
||||
return [.reply_to_root(r)]
|
||||
}
|
||||
|
||||
return evrefs
|
||||
}
|
||||
|
||||
func interp_event_refs_with_mentions_ndb(tags: TagsSequence, mention_indices: Set<Int>) -> [EventRef] {
|
||||
var mentions: [EventRef] = []
|
||||
var ev_refs: [NoteRef] = []
|
||||
var i: Int = 0
|
||||
|
||||
for tag in tags {
|
||||
if let note_id = NoteRef.from_tag(tag: tag) {
|
||||
if mention_indices.contains(i) {
|
||||
mentions.append(.mention(.noteref(note_id, index: i)))
|
||||
if let marker = ref.marker {
|
||||
any_marker = true
|
||||
switch marker {
|
||||
case .root: root_id = ref
|
||||
case .reply: reply_id = ref
|
||||
case .mention: mention = ref
|
||||
}
|
||||
// deprecated form, only activate if we don't have any markers set
|
||||
} else if !any_marker {
|
||||
if first {
|
||||
root_id = ref
|
||||
first = false
|
||||
} else {
|
||||
ev_refs.append(note_id)
|
||||
reply_id = ref
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
var replies = interp_event_refs_without_mentions(ev_refs)
|
||||
replies.append(contentsOf: mentions)
|
||||
return replies
|
||||
|
||||
// If either reply or root_id is blank while the other is not, then this is
|
||||
// considered reply-to-root. We should always have a root and reply tag, if they
|
||||
// are equal this is reply-to-root
|
||||
if reply_id == nil && root_id != nil {
|
||||
reply_id = root_id
|
||||
} else if root_id == nil && reply_id != nil {
|
||||
root_id = reply_id
|
||||
}
|
||||
|
||||
guard let reply_id, let root_id else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ThreadReply(root: root_id, reply: reply_id, mention: mention.map { m in .noteref(m) })
|
||||
}
|
||||
|
||||
+125
-101
@@ -8,6 +8,7 @@
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
import MediaPlayer
|
||||
import EmojiPicker
|
||||
|
||||
struct ZapSheet {
|
||||
let target: ZapTarget
|
||||
@@ -56,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?
|
||||
@@ -71,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()
|
||||
}
|
||||
@@ -152,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 {
|
||||
@@ -170,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,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) {
|
||||
@@ -276,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!)
|
||||
@@ -288,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,7 +309,7 @@ struct ContentView: View {
|
||||
active_sheet = .onboardingSuggestions
|
||||
hasSeenOnboardingSuggestions = true
|
||||
}
|
||||
self.appDelegate?.settings = damus_state?.settings
|
||||
self.appDelegate?.state = damus_state
|
||||
}
|
||||
.sheet(item: $active_sheet) { item in
|
||||
switch item {
|
||||
@@ -452,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
|
||||
@@ -699,7 +703,7 @@ struct ContentView: View {
|
||||
likes: EventCounter(our_pubkey: pubkey),
|
||||
boosts: EventCounter(our_pubkey: pubkey),
|
||||
contacts: Contacts(our_pubkey: pubkey),
|
||||
mutelist_manager: MutelistManager(),
|
||||
mutelist_manager: MutelistManager(user_keypair: keypair),
|
||||
profiles: Profiles(ndb: ndb),
|
||||
dms: home.dms,
|
||||
previews: PreviewCache(),
|
||||
@@ -717,9 +721,10 @@ 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)
|
||||
quote_reposts: .init(our_pubkey: pubkey),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
)
|
||||
|
||||
home.damus_state = self.damus_state!
|
||||
@@ -772,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
|
||||
@@ -780,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)
|
||||
@@ -873,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
|
||||
@@ -1105,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)
|
||||
@@ -1166,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(.empty)
|
||||
return ev.known_kind == .boost || ev.known_kind == .highlight || !ev.is_reply()
|
||||
case .posts_and_replies:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -9,31 +9,31 @@ import Foundation
|
||||
|
||||
|
||||
class CreateAccountModel: ObservableObject {
|
||||
@Published var real_name: String = ""
|
||||
@Published var nick_name: String = ""
|
||||
@Published var display_name: String = ""
|
||||
@Published var name: String = ""
|
||||
@Published var about: String = ""
|
||||
@Published var pubkey: Pubkey = .empty
|
||||
@Published var privkey: Privkey = .empty
|
||||
@Published var profile_image: URL? = nil
|
||||
|
||||
var rendered_name: String {
|
||||
if real_name.isEmpty {
|
||||
return nick_name
|
||||
if display_name.isEmpty {
|
||||
return name
|
||||
}
|
||||
return real_name
|
||||
return display_name
|
||||
}
|
||||
|
||||
var keypair: Keypair {
|
||||
return Keypair(pubkey: self.pubkey, privkey: self.privkey)
|
||||
}
|
||||
|
||||
init(real: String = "", nick: String = "", about: String = "") {
|
||||
init(display_name: String = "", name: String = "", about: String = "") {
|
||||
let keypair = generate_new_keypair()
|
||||
self.pubkey = keypair.pubkey
|
||||
self.privkey = keypair.privkey
|
||||
|
||||
self.real_name = real
|
||||
self.nick_name = nick
|
||||
self.display_name = display_name
|
||||
self.name = name
|
||||
self.about = about
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
import LinkPresentation
|
||||
import EmojiPicker
|
||||
|
||||
class DamusState: HeadlessDamusState {
|
||||
let pool: RelayPool
|
||||
@@ -33,11 +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) {
|
||||
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
|
||||
@@ -68,6 +71,81 @@ class DamusState: HeadlessDamusState {
|
||||
keypair: keypair
|
||||
)
|
||||
self.quote_reposts = quote_reposts
|
||||
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
|
||||
@@ -97,6 +175,7 @@ class DamusState: HeadlessDamusState {
|
||||
|
||||
func close() {
|
||||
print("txn: damus close")
|
||||
wallet.disconnect()
|
||||
pool.close()
|
||||
ndb.close()
|
||||
}
|
||||
@@ -112,7 +191,7 @@ class DamusState: HeadlessDamusState {
|
||||
likes: EventCounter(our_pubkey: empty_pub),
|
||||
boosts: EventCounter(our_pubkey: empty_pub),
|
||||
contacts: Contacts(our_pubkey: empty_pub),
|
||||
mutelist_manager: MutelistManager(),
|
||||
mutelist_manager: MutelistManager(user_keypair: kp),
|
||||
profiles: Profiles(ndb: .empty),
|
||||
dms: DirectMessagesModel(our_pubkey: empty_pub),
|
||||
previews: PreviewCache(),
|
||||
@@ -130,9 +209,10 @@ class DamusState: HeadlessDamusState {
|
||||
wallet: WalletModel(settings: UserSettingsStore()),
|
||||
nav: NavigationCoordinator(),
|
||||
music: nil,
|
||||
video: VideoController(),
|
||||
video: DamusVideoCoordinator(),
|
||||
ndb: .empty,
|
||||
quote_reposts: .init(our_pubkey: empty_pub)
|
||||
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] = [:]
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
//
|
||||
// EventRef.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-05-08.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum EventRef: Equatable {
|
||||
case mention(Mention<NoteRef>)
|
||||
case thread_id(NoteRef)
|
||||
case reply(NoteRef)
|
||||
case reply_to_root(NoteRef)
|
||||
|
||||
var is_mention: NoteRef? {
|
||||
if case .mention(let m) = self { return m.ref }
|
||||
return nil
|
||||
}
|
||||
|
||||
var is_direct_reply: NoteRef? {
|
||||
switch self {
|
||||
case .mention:
|
||||
return nil
|
||||
case .thread_id:
|
||||
return nil
|
||||
case .reply(let refid):
|
||||
return refid
|
||||
case .reply_to_root(let refid):
|
||||
return refid
|
||||
}
|
||||
}
|
||||
|
||||
var is_thread_id: NoteRef? {
|
||||
switch self {
|
||||
case .mention:
|
||||
return nil
|
||||
case .thread_id(let referencedId):
|
||||
return referencedId
|
||||
case .reply:
|
||||
return nil
|
||||
case .reply_to_root(let referencedId):
|
||||
return referencedId
|
||||
}
|
||||
}
|
||||
|
||||
var is_reply: NoteRef? {
|
||||
switch self {
|
||||
case .mention:
|
||||
return nil
|
||||
case .thread_id:
|
||||
return nil
|
||||
case .reply(let refid):
|
||||
return refid
|
||||
case .reply_to_root(let refid):
|
||||
return refid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func build_mention_indices(_ blocks: [Block], type: MentionType) -> Set<Int> {
|
||||
return blocks.reduce(into: []) { acc, block in
|
||||
switch block {
|
||||
case .mention(let m):
|
||||
if m.ref.key == type, let idx = m.index {
|
||||
acc.insert(idx)
|
||||
}
|
||||
case .relay:
|
||||
return
|
||||
case .text:
|
||||
return
|
||||
case .hashtag:
|
||||
return
|
||||
case .url:
|
||||
return
|
||||
case .invoice:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func interp_event_refs_without_mentions(_ refs: [NoteRef]) -> [EventRef] {
|
||||
if refs.count == 0 {
|
||||
return []
|
||||
}
|
||||
|
||||
if refs.count == 1 {
|
||||
return [.reply_to_root(refs[0])]
|
||||
}
|
||||
|
||||
var evrefs: [EventRef] = []
|
||||
var first: Bool = true
|
||||
for ref in refs {
|
||||
if first {
|
||||
evrefs.append(.thread_id(ref))
|
||||
first = false
|
||||
} else {
|
||||
evrefs.append(.reply(ref))
|
||||
}
|
||||
}
|
||||
return evrefs
|
||||
}
|
||||
|
||||
func interp_event_refs_with_mentions(tags: Tags, mention_indices: Set<Int>) -> [EventRef] {
|
||||
var mentions: [EventRef] = []
|
||||
var ev_refs: [NoteRef] = []
|
||||
var i: Int = 0
|
||||
|
||||
for tag in tags {
|
||||
if let ref = NoteRef.from_tag(tag: tag) {
|
||||
if mention_indices.contains(i) {
|
||||
let mention = Mention<NoteRef>(index: i, ref: ref)
|
||||
mentions.append(.mention(mention))
|
||||
} else {
|
||||
ev_refs.append(ref)
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
var replies = interp_event_refs_without_mentions(ev_refs)
|
||||
replies.append(contentsOf: mentions)
|
||||
return replies
|
||||
}
|
||||
|
||||
func interpret_event_refs(blocks: [Block], tags: Tags) -> [EventRef] {
|
||||
if tags.count == 0 {
|
||||
return []
|
||||
}
|
||||
|
||||
/// build a set of indices for each event mention
|
||||
let mention_indices = build_mention_indices(blocks, type: .e)
|
||||
|
||||
/// simpler case with no mentions
|
||||
if mention_indices.count == 0 {
|
||||
return interp_event_refs_without_mentions_ndb(References<NoteRef>(tags: tags))
|
||||
}
|
||||
|
||||
return interp_event_refs_with_mentions(tags: tags, mention_indices: mention_indices)
|
||||
}
|
||||
|
||||
|
||||
func event_is_reply(_ refs: [EventRef]) -> Bool {
|
||||
return refs.contains { evref in
|
||||
return evref.is_reply != nil
|
||||
}
|
||||
}
|
||||
@@ -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'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
//
|
||||
// HighlightEvent.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 4/22/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct HighlightEvent {
|
||||
let event: NostrEvent
|
||||
|
||||
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":
|
||||
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 []
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,10 @@ enum HomeResubFilter {
|
||||
}
|
||||
|
||||
class HomeModel: ContactsDelegate {
|
||||
// The maximum amount of contacts placed on a home feed subscription filter.
|
||||
// If the user has more contacts, chunking or other techniques will be used to avoid sending huge filters
|
||||
let MAX_CONTACTS_ON_FILTER = 500
|
||||
|
||||
// Don't trigger a user notification for events older than a certain age
|
||||
static let event_max_age_for_notification: TimeInterval = EVENT_MAX_AGE_FOR_NOTIFICATION
|
||||
|
||||
@@ -123,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
|
||||
@@ -180,7 +184,7 @@ class HomeModel: ContactsDelegate {
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case .chat, .longform, .text:
|
||||
case .chat, .longform, .text, .highlight:
|
||||
handle_text_event(sub_id: sub_id, ev)
|
||||
case .contacts:
|
||||
handle_contact_event(sub_id: sub_id, relay_id: relay_id, ev: ev)
|
||||
@@ -545,7 +549,8 @@ class HomeModel: ContactsDelegate {
|
||||
notifications_filter.limit = 500
|
||||
|
||||
var notifications_filters = [notifications_filter]
|
||||
var contacts_filters = [contacts_filter, our_contacts_filter, our_blocklist_filter, our_old_blocklist_filter]
|
||||
let contacts_filter_chunks = contacts_filter.chunked(on: .authors, into: MAX_CONTACTS_ON_FILTER)
|
||||
var contacts_filters = contacts_filter_chunks + [our_contacts_filter, our_blocklist_filter, our_old_blocklist_filter]
|
||||
var dms_filters = [dms_filter, our_dms_filter]
|
||||
let last_of_kind = get_last_of_kind(relay_id: relay_id)
|
||||
|
||||
@@ -581,7 +586,7 @@ class HomeModel: ContactsDelegate {
|
||||
func subscribe_to_home_filters(friends fs: [Pubkey]? = nil, relay_id: RelayURL? = nil) {
|
||||
// TODO: separate likes?
|
||||
var home_filter_kinds: [NostrKind] = [
|
||||
.text, .longform, .boost
|
||||
.text, .longform, .boost, .highlight
|
||||
]
|
||||
if !damus_state.settings.onlyzaps_mode {
|
||||
home_filter_kinds.append(.like)
|
||||
@@ -598,7 +603,7 @@ class HomeModel: ContactsDelegate {
|
||||
home_filter.authors = friends
|
||||
home_filter.limit = 500
|
||||
|
||||
var home_filters = [home_filter]
|
||||
var home_filters = home_filter.chunked(on: .authors, into: MAX_CONTACTS_ON_FILTER)
|
||||
|
||||
let followed_hashtags = Array(damus_state.contacts.get_followed_hashtags())
|
||||
if followed_hashtags.count != 0 {
|
||||
@@ -728,7 +733,7 @@ class HomeModel: ContactsDelegate {
|
||||
func got_new_dm(notifs: NewEventsBits, ev: NostrEvent) {
|
||||
notification_status.new_events = notifs
|
||||
|
||||
guard should_display_notification(state: damus_state, event: ev),
|
||||
guard should_display_notification(state: damus_state, event: ev, mode: .local),
|
||||
let notification_object = generate_local_notification_object(from: ev, state: damus_state)
|
||||
else {
|
||||
return
|
||||
@@ -1148,8 +1153,8 @@ func should_show_event(event: NostrEvent, damus_state: DamusState) -> Bool {
|
||||
)
|
||||
}
|
||||
|
||||
func should_show_event(state: DamusState, ev: NostrEvent, keypair: Keypair? = nil) -> Bool {
|
||||
let event_muted = state.mutelist_manager.is_event_muted(ev, keypair: keypair)
|
||||
func should_show_event(state: DamusState, ev: NostrEvent) -> Bool {
|
||||
let event_muted = state.mutelist_manager.is_event_muted(ev)
|
||||
if event_muted {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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,47 +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 tags = post.references.map({ r in r.tag }) + post.tags
|
||||
let post_blocks = parse_post_blocks(content: post.content)
|
||||
let post_tags = make_post_tags(post_blocks: post_blocks, tags: 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)
|
||||
}
|
||||
|
||||
@@ -8,12 +8,27 @@
|
||||
import Foundation
|
||||
|
||||
class MutelistManager {
|
||||
let user_keypair: Keypair
|
||||
private(set) var event: NostrEvent? = nil
|
||||
|
||||
var users: Set<MuteItem> = []
|
||||
var hashtags: Set<MuteItem> = []
|
||||
var threads: Set<MuteItem> = []
|
||||
var words: Set<MuteItem> = []
|
||||
var users: Set<MuteItem> = [] {
|
||||
didSet { self.reset_cache() }
|
||||
}
|
||||
var hashtags: Set<MuteItem> = [] {
|
||||
didSet { self.reset_cache() }
|
||||
}
|
||||
var threads: Set<MuteItem> = [] {
|
||||
didSet { self.reset_cache() }
|
||||
}
|
||||
var words: Set<MuteItem> = [] {
|
||||
didSet { self.reset_cache() }
|
||||
}
|
||||
|
||||
var muted_notes_cache: [NoteId: EventMuteStatus] = [:]
|
||||
|
||||
init(user_keypair: Keypair) {
|
||||
self.user_keypair = user_keypair
|
||||
}
|
||||
|
||||
func refresh_sets() {
|
||||
guard let referenced_mute_items = event?.referenced_mute_items else { return }
|
||||
@@ -41,6 +56,10 @@ class MutelistManager {
|
||||
threads = new_threads
|
||||
words = new_words
|
||||
}
|
||||
|
||||
func reset_cache() {
|
||||
self.muted_notes_cache = [:]
|
||||
}
|
||||
|
||||
func is_muted(_ item: MuteItem) -> Bool {
|
||||
switch item {
|
||||
@@ -55,8 +74,8 @@ class MutelistManager {
|
||||
}
|
||||
}
|
||||
|
||||
func is_event_muted(_ ev: NostrEvent, keypair: Keypair? = nil) -> Bool {
|
||||
return event_muted_reason(ev, keypair: keypair) != nil
|
||||
func is_event_muted(_ ev: NostrEvent) -> Bool {
|
||||
return self.event_muted_reason(ev) != nil
|
||||
}
|
||||
|
||||
func set_mutelist(_ ev: NostrEvent) {
|
||||
@@ -92,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)
|
||||
}
|
||||
}
|
||||
@@ -114,15 +137,27 @@ class MutelistManager {
|
||||
threads.remove(item)
|
||||
}
|
||||
}
|
||||
|
||||
func event_muted_reason(_ ev: NostrEvent) -> MuteItem? {
|
||||
if let cached_mute_status = self.muted_notes_cache[ev.id] {
|
||||
return cached_mute_status.mute_reason()
|
||||
}
|
||||
if let reason = self.compute_event_muted_reason(ev) {
|
||||
self.muted_notes_cache[ev.id] = .muted(reason: reason)
|
||||
return reason
|
||||
}
|
||||
self.muted_notes_cache[ev.id] = .not_muted
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
/// Check if an event is muted given a collection of ``MutedItem``.
|
||||
///
|
||||
/// - Parameter ev: The ``NostrEvent`` that you want to check the muted reason for.
|
||||
/// - Returns: The ``MuteItem`` that matched the event. Or `nil` if the event is not muted.
|
||||
func event_muted_reason(_ ev: NostrEvent, keypair: Keypair? = nil) -> MuteItem? {
|
||||
func compute_event_muted_reason(_ ev: NostrEvent) -> MuteItem? {
|
||||
// Events from the current user should not be muted.
|
||||
guard keypair?.pubkey != ev.pubkey else { return nil }
|
||||
guard self.user_keypair.pubkey != ev.pubkey else { return nil }
|
||||
|
||||
// Check if user is muted
|
||||
let check_user_item = MuteItem.user(ev.pubkey, nil)
|
||||
@@ -147,7 +182,7 @@ class MutelistManager {
|
||||
}
|
||||
|
||||
// Check if word is muted
|
||||
if let keypair, let content: String = ev.maybe_get_content(keypair)?.lowercased() {
|
||||
if let content: String = ev.maybe_get_content(self.user_keypair)?.lowercased() {
|
||||
for word in words {
|
||||
if case .word(let string, _) = word {
|
||||
if content.contains(string.lowercased()) {
|
||||
@@ -159,4 +194,18 @@ class MutelistManager {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
enum EventMuteStatus {
|
||||
case muted(reason: MuteItem)
|
||||
case not_muted
|
||||
|
||||
func mute_reason() -> MuteItem? {
|
||||
switch self {
|
||||
case .muted(reason: let reason):
|
||||
return reason
|
||||
case .not_muted:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import UIKit
|
||||
let EVENT_MAX_AGE_FOR_NOTIFICATION: TimeInterval = 12 * 60 * 60
|
||||
|
||||
func process_local_notification(state: HeadlessDamusState, event ev: NostrEvent) {
|
||||
guard should_display_notification(state: state, event: ev) else {
|
||||
guard should_display_notification(state: state, event: ev, mode: .local) else {
|
||||
// We should not display notification. Exit.
|
||||
return
|
||||
}
|
||||
@@ -25,7 +25,12 @@ func process_local_notification(state: HeadlessDamusState, event ev: NostrEvent)
|
||||
create_local_notification(profiles: state.profiles, notify: local_notification)
|
||||
}
|
||||
|
||||
func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent) -> Bool {
|
||||
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.notification_mode == mode else {
|
||||
return false
|
||||
}
|
||||
|
||||
if ev.known_kind == nil {
|
||||
return false
|
||||
}
|
||||
@@ -37,7 +42,7 @@ func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent
|
||||
}
|
||||
|
||||
// Don't show notifications that match mute list.
|
||||
if state.mutelist_manager.is_event_muted(ev, keypair: state.keypair) {
|
||||
if state.mutelist_manager.is_event_muted(ev) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -56,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
|
||||
|
||||
+78
-4
@@ -10,19 +10,93 @@ import Foundation
|
||||
struct NostrPost {
|
||||
let kind: NostrKind
|
||||
let content: String
|
||||
let references: [RefId]
|
||||
let tags: [[String]]
|
||||
|
||||
init(content: String, references: [RefId], kind: NostrKind = .text, tags: [[String]] = []) {
|
||||
init(content: String, kind: NostrKind = .text, tags: [[String]] = []) {
|
||||
self.content = content
|
||||
self.references = references
|
||||
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
|
||||
}
|
||||
|
||||
@@ -60,11 +60,11 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
damus.pool.unsubscribe(sub_id: sub_id)
|
||||
damus.pool.unsubscribe(sub_id: prof_subid)
|
||||
}
|
||||
|
||||
|
||||
func subscribe() {
|
||||
var text_filter = NostrFilter(kinds: [.text, .longform])
|
||||
var text_filter = NostrFilter(kinds: [.text, .longform, .highlight])
|
||||
var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost])
|
||||
|
||||
|
||||
profile_filter.authors = [pubkey]
|
||||
|
||||
text_filter.authors = [pubkey]
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
//
|
||||
// PushNotificationClient.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-05-17.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
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_push_notifications && settings.notification_mode == .push {
|
||||
try await self.send_token()
|
||||
}
|
||||
}
|
||||
|
||||
func send_token() async throws {
|
||||
// Send the device token and pubkey to the server
|
||||
guard let token = device_token_hex else { return }
|
||||
|
||||
Log.info("Sending device token to server: %s", for: .push_notifications, token)
|
||||
|
||||
// create post request
|
||||
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: .put,
|
||||
url: url,
|
||||
payload: nil,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
Log.info("Sent device token to Damus push notification server successfully", for: .push_notifications)
|
||||
default:
|
||||
Log.error("Error in sending device_token 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 revoke_token() async throws {
|
||||
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
|
||||
|
||||
// create post request
|
||||
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: .delete,
|
||||
url: url,
|
||||
payload: nil,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
Log.info("Sent device token removal request to Damus push notification server successfully", for: .push_notifications)
|
||||
default:
|
||||
Log.error("Error in sending device_token removal 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 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
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ class SearchHomeModel: ObservableObject {
|
||||
guard sub_id == self.base_subid || sub_id == self.profiles_subid else {
|
||||
return
|
||||
}
|
||||
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply(damus_state.keypair)
|
||||
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
|
||||
{
|
||||
if !damus_state.settings.multiple_events_per_pubkey && seen_pubkey.contains(ev.pubkey) {
|
||||
return
|
||||
|
||||
@@ -36,7 +36,7 @@ class SearchModel: ObservableObject {
|
||||
func subscribe() {
|
||||
// since 1 month
|
||||
search.limit = self.limit
|
||||
search.kinds = [.text, .like, .longform]
|
||||
search.kinds = [.text, .like, .longform, .highlight]
|
||||
|
||||
//likes_filter.ids = ref_events.referenced_ids!
|
||||
|
||||
|
||||
@@ -11,16 +11,24 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
func events() -> [NostrEvent] {
|
||||
return Array(event_map).sorted(by: { a, b in
|
||||
return a.created_at < b.created_at
|
||||
})
|
||||
}
|
||||
|
||||
var is_original: Bool {
|
||||
return original_event.id == event.id
|
||||
}
|
||||
@@ -60,7 +68,7 @@ class ThreadModel: ObservableObject {
|
||||
var event_filter = NostrFilter()
|
||||
var ref_events = NostrFilter()
|
||||
|
||||
let thread_id = event.thread_id(keypair: .empty)
|
||||
let thread_id = event.thread_id()
|
||||
|
||||
ref_events.referenced_ids = [thread_id, event.id]
|
||||
ref_events.kinds = [.text]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
//
|
||||
// Trie.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Terry Yiu on 6/26/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Tree data structure of all the substring permutations of a collection of strings optimized for searching for values of type V.
|
||||
///
|
||||
/// Each node in the tree can have child nodes.
|
||||
/// Each node represents a single character in substrings, and each of its child nodes represent the subsequent character in those substrings.
|
||||
///
|
||||
/// A node that has no children mean that there are no substrings with any additional characters beyond the branch of letters leading up to that node.
|
||||
///
|
||||
/// A node that has values mean that there are strings that end in the character represented by the node and contain the substring represented by the branch of letters leading up to that node.
|
||||
///
|
||||
/// https://en.wikipedia.org/wiki/Trie
|
||||
class Trie<V: Hashable> {
|
||||
private var children: [Character : Trie] = [:]
|
||||
|
||||
/// Separate exact matches from strict substrings so that exact matches appear first in returned results.
|
||||
private var exactMatchValues = Set<V>()
|
||||
private var substringMatchValues = Set<V>()
|
||||
|
||||
private var parent: Trie? = nil
|
||||
}
|
||||
|
||||
extension Trie {
|
||||
var hasChildren: Bool {
|
||||
return !self.children.isEmpty
|
||||
}
|
||||
|
||||
var hasValues: Bool {
|
||||
return !self.exactMatchValues.isEmpty || !self.substringMatchValues.isEmpty
|
||||
}
|
||||
|
||||
/// Finds the branch that matches the specified key and returns the values from all of its descendant nodes.
|
||||
func find(key: String) -> [V] {
|
||||
var currentNode = self
|
||||
|
||||
// Find branch with matching prefix.
|
||||
for char in key {
|
||||
if let child = currentNode.children[char] {
|
||||
currentNode = child
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Perform breadth-first search from matching branch and collect values from all descendants.
|
||||
var substringMatches = Set<V>(currentNode.substringMatchValues)
|
||||
var queue = Array(currentNode.children.values)
|
||||
|
||||
while !queue.isEmpty {
|
||||
let node = queue.removeFirst()
|
||||
substringMatches.formUnion(node.exactMatchValues)
|
||||
substringMatches.formUnion(node.substringMatchValues)
|
||||
queue.append(contentsOf: node.children.values)
|
||||
}
|
||||
|
||||
// Prioritize exact matches to be returned first, and then remove exact matches from the set of partial substring matches that are appended afterward.
|
||||
return Array(currentNode.exactMatchValues) + (substringMatches.subtracting(currentNode.exactMatchValues))
|
||||
}
|
||||
|
||||
/// Inserts value of type V into this trie for the specified key. This function stores all substring endings of the key, not only the key itself.
|
||||
/// Runtime performance is O(n^2) and storage cost is O(n), where n is the number of characters in the key.
|
||||
func insert(key: String, value: V) {
|
||||
// Create root branches for each character of the key to enable substring searches instead of only just prefix searches.
|
||||
// Hence the nested loop.
|
||||
for i in 0..<key.count {
|
||||
var currentNode = self
|
||||
|
||||
// Find branch with matching prefix.
|
||||
for char in key[key.index(key.startIndex, offsetBy: i)...] {
|
||||
if let child = currentNode.children[char] {
|
||||
currentNode = child
|
||||
} else {
|
||||
let child = Trie()
|
||||
child.parent = currentNode
|
||||
currentNode.children[char] = child
|
||||
currentNode = child
|
||||
}
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
currentNode.exactMatchValues.insert(value)
|
||||
} else {
|
||||
currentNode.substringMatchValues.insert(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes value of type V from this trie for the specified key.
|
||||
func remove(key: String, value: V) {
|
||||
for i in 0..<key.count {
|
||||
var currentNode = self
|
||||
|
||||
var foundLeafNode = true
|
||||
|
||||
// Find branch with matching prefix.
|
||||
for j in i..<key.count {
|
||||
let char = key[key.index(key.startIndex, offsetBy: j)]
|
||||
|
||||
if let child = currentNode.children[char] {
|
||||
currentNode = child
|
||||
} else {
|
||||
foundLeafNode = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if foundLeafNode {
|
||||
currentNode.exactMatchValues.remove(value)
|
||||
currentNode.substringMatchValues.remove(value)
|
||||
|
||||
// Clean up the tree if this leaf node no longer holds values or children.
|
||||
for j in (i..<key.count).reversed() {
|
||||
if let parent = currentNode.parent, !currentNode.hasValues && !currentNode.hasChildren {
|
||||
currentNode = parent
|
||||
let char = key[key.index(key.startIndex, offsetBy: j)]
|
||||
currentNode.children.removeValue(forKey: char)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
//
|
||||
// UserSearchCache.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Terry Yiu on 6/27/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Cache of searchable users by name, display_name, NIP-05 identifier, or own contact list petname.
|
||||
/// Optimized for fast searches of substrings by using a Trie.
|
||||
/// Optimal for performing user searches that could be initiated by typing quickly on a keyboard into a text input field.
|
||||
|
||||
// TODO: replace with lmdb (the b tree should handle this just fine ?)
|
||||
// we just need a name to profile index
|
||||
class UserSearchCache {
|
||||
private let trie = Trie<Pubkey>()
|
||||
|
||||
func search(key: String) -> [Pubkey] {
|
||||
let results = trie.find(key: key)
|
||||
return results
|
||||
}
|
||||
|
||||
/// Computes the differences between an old profile, if it exists, and a new profile, and updates the user search cache accordingly.
|
||||
@MainActor
|
||||
func updateProfile(id: Pubkey, profiles: Profiles, oldProfile: Profile?, newProfile: Profile) {
|
||||
// Remove searchable keys tied to the old profile if they differ from the new profile
|
||||
// to keep the trie clean without empty nodes while avoiding excessive graph searching.
|
||||
if let oldProfile {
|
||||
if let oldName = oldProfile.name, newProfile.name?.caseInsensitiveCompare(oldName) != .orderedSame {
|
||||
trie.remove(key: oldName.lowercased(), value: id)
|
||||
}
|
||||
if let oldDisplayName = oldProfile.display_name, newProfile.display_name?.caseInsensitiveCompare(oldDisplayName) != .orderedSame {
|
||||
trie.remove(key: oldDisplayName.lowercased(), value: id)
|
||||
}
|
||||
if let oldNip05 = oldProfile.nip05, newProfile.nip05?.caseInsensitiveCompare(oldNip05) != .orderedSame {
|
||||
trie.remove(key: oldNip05.lowercased(), value: id)
|
||||
}
|
||||
}
|
||||
|
||||
addProfile(id: id, profiles: profiles, profile: newProfile)
|
||||
}
|
||||
|
||||
/// Adds a profile to the user search cache.
|
||||
@MainActor
|
||||
private func addProfile(id: Pubkey, profiles: Profiles, profile: Profile) {
|
||||
// Searchable by name.
|
||||
if let name = profile.name {
|
||||
trie.insert(key: name.lowercased(), value: id)
|
||||
}
|
||||
|
||||
// Searchable by display name.
|
||||
if let displayName = profile.display_name {
|
||||
trie.insert(key: displayName.lowercased(), value: id)
|
||||
}
|
||||
|
||||
// Searchable by NIP-05 identifier.
|
||||
if let nip05 = profiles.is_validated(id) {
|
||||
trie.insert(key: "\(nip05.username.lowercased())@\(nip05.host.lowercased())", value: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the diffences between an old contacts event and a new contacts event for our own user, and updates the search cache accordingly.
|
||||
func updateOwnContactsPetnames(id: Pubkey, oldEvent: NostrEvent?, newEvent: NostrEvent) {
|
||||
guard newEvent.known_kind == .contacts && newEvent.pubkey == id else {
|
||||
return
|
||||
}
|
||||
|
||||
var petnames: [Pubkey: String] = [:]
|
||||
for tag in newEvent.tags {
|
||||
guard tag.count > 3,
|
||||
let chr = tag[0].single_char, chr == "p",
|
||||
let id = tag[1].id()
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let pubkey = Pubkey(id)
|
||||
|
||||
petnames[pubkey] = tag[3].string()
|
||||
}
|
||||
|
||||
// Compute the diff with the old contacts list, if it exists,
|
||||
// mark the ones that are the same to not be removed from the user search cache,
|
||||
// and remove the old ones that are different from the user search cache.
|
||||
if let oldEvent, oldEvent.known_kind == .contacts, oldEvent.pubkey == id {
|
||||
for tag in oldEvent.tags {
|
||||
guard tag.count >= 4,
|
||||
tag[0].matches_char("p"),
|
||||
let id = tag[1].id()
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let pubkey = Pubkey(id)
|
||||
|
||||
let oldPetname = tag[3].string()
|
||||
|
||||
if let newPetname = petnames[pubkey] {
|
||||
if newPetname.caseInsensitiveCompare(oldPetname) == .orderedSame {
|
||||
petnames.removeValue(forKey: pubkey)
|
||||
} else {
|
||||
trie.remove(key: oldPetname, value: pubkey)
|
||||
}
|
||||
} else {
|
||||
trie.remove(key: oldPetname, value: pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new petnames to the user search cache.
|
||||
for (pubkey, petname) in petnames {
|
||||
trie.insert(key: petname, value: pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,9 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "like_notification", default_value: true)
|
||||
var like_notification: Bool
|
||||
|
||||
@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
|
||||
|
||||
@@ -204,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
|
||||
@@ -326,6 +330,36 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "latest_contact_event_id", default_value: nil)
|
||||
var latest_contact_event_id_hex: String?
|
||||
|
||||
|
||||
// MARK: Helper types
|
||||
|
||||
enum NotificationsMode: String, CaseIterable, Identifiable, StringCodable, Equatable {
|
||||
var id: String { self.rawValue }
|
||||
|
||||
func to_string() -> String {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
init?(from string: String) {
|
||||
guard let notifications_mode = NotificationsMode(rawValue: string) else {
|
||||
return nil
|
||||
}
|
||||
self = notifications_mode
|
||||
}
|
||||
|
||||
func text_description() -> String {
|
||||
switch self {
|
||||
case .local:
|
||||
NSLocalizedString("Local", comment: "Option for notification mode setting: Local notification mode")
|
||||
case .push:
|
||||
NSLocalizedString("Push", comment: "Option for notification mode setting: Push notification mode")
|
||||
}
|
||||
}
|
||||
|
||||
case local
|
||||
case push
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func pk_setting_key(_ pubkey: Pubkey, key: String) -> String {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// ThreadReply.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2024-05-09.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
struct ThreadReply {
|
||||
let root: NoteRef
|
||||
let reply: NoteRef
|
||||
let mention: Mention<NoteRef>?
|
||||
|
||||
var is_reply_to_root: Bool {
|
||||
return root.id == reply.id
|
||||
}
|
||||
|
||||
init(root: NoteRef, reply: NoteRef, mention: Mention<NoteRef>?) {
|
||||
self.root = root
|
||||
self.reply = reply
|
||||
self.mention = mention
|
||||
}
|
||||
|
||||
init?(tags: TagsSequence) {
|
||||
guard let tr = interpret_event_refs_ndb(tags: tags) else {
|
||||
return nil
|
||||
}
|
||||
self = tr
|
||||
}
|
||||
}
|
||||
@@ -54,4 +54,68 @@ struct NostrFilter: Codable, Equatable {
|
||||
public static func filter_hashtag(_ htags: [String]) -> NostrFilter {
|
||||
NostrFilter(hashtag: htags.map { $0.lowercased() })
|
||||
}
|
||||
|
||||
/// Splits the filter on a given filter path/axis into chunked filters
|
||||
///
|
||||
/// - Parameter path: The path where chunking should be done
|
||||
/// - Parameter chunk_size: The maximum size of each chunk.
|
||||
/// - Returns: An array of arrays, where each contained array is a chunk of the original array with up to `size` elements.
|
||||
func chunked(on path: ChunkPath, into chunk_size: Int) -> [Self] {
|
||||
let chunked_slices = self.get_slice(from: path).chunked(into: chunk_size)
|
||||
var chunked_filters: [NostrFilter] = []
|
||||
for chunked_slice in chunked_slices {
|
||||
var chunked_filter = self
|
||||
chunked_filter.apply_slice(chunked_slice)
|
||||
chunked_filters.append(chunked_filter)
|
||||
}
|
||||
return chunked_filters
|
||||
}
|
||||
|
||||
/// Gets a slice from a NostrFilter on a given path/axis
|
||||
///
|
||||
/// - Parameter path: The path where chunking should be done
|
||||
/// - Parameter chunk_size: The maximum size of each chunk.
|
||||
/// - Returns: An array of arrays, where each contained array is a chunk of the original array with up to `size` elements.
|
||||
func get_slice(from path: ChunkPath) -> Slice {
|
||||
switch path {
|
||||
case .pubkeys:
|
||||
return .pubkeys(self.pubkeys)
|
||||
case .authors:
|
||||
return .authors(self.authors)
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides one member/axis of a NostrFilter using a specific slice
|
||||
/// - Parameter slice: The slice to be applied on this NostrFilter
|
||||
mutating func apply_slice(_ slice: Slice) {
|
||||
switch slice {
|
||||
case .pubkeys(let pubkeys):
|
||||
self.pubkeys = pubkeys
|
||||
case .authors(let authors):
|
||||
self.authors = authors
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// A path to one of the axes of a NostrFilter.
|
||||
enum ChunkPath {
|
||||
case pubkeys
|
||||
case authors
|
||||
// Other paths/axes not supported yet
|
||||
}
|
||||
|
||||
/// Represents the value of a single axis of a NostrFilter
|
||||
enum Slice {
|
||||
case pubkeys([Pubkey]?)
|
||||
case authors([Pubkey]?)
|
||||
|
||||
func chunked(into chunk_size: Int) -> [Slice] {
|
||||
switch self {
|
||||
case .pubkeys(let array):
|
||||
return (array ?? []).chunked(into: chunk_size).map({ .pubkeys($0) })
|
||||
case .authors(let array):
|
||||
return (array ?? []).chunked(into: chunk_size).map({ .authors($0) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ enum NostrKind: UInt32, Codable {
|
||||
case longform = 30023
|
||||
case zap = 9735
|
||||
case zap_request = 9734
|
||||
case highlight = 9802
|
||||
case nwc_request = 23194
|
||||
case nwc_response = 23195
|
||||
case http_auth = 27235
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+27
-2
File diff suppressed because one or more lines are too long
@@ -10,11 +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: "https://notify.damus.io:8000/user-info")!
|
||||
static let DEVICE_TOKEN_RECEIVER_TEST_URL: URL = URL(string: "http://localhost:8000/user-info")!
|
||||
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")!
|
||||
@@ -28,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
|
||||
+30
-17
@@ -97,13 +97,13 @@ class EventCache {
|
||||
// TODO: remove me and change code to use ndb directly
|
||||
private let ndb: Ndb
|
||||
private var events: [NoteId: NostrEvent] = [:]
|
||||
private var replies = ReplyMap()
|
||||
private var cancellable: AnyCancellable?
|
||||
private var image_metadata: [String: ImageMetadataState] = [:] // lowercased URL key
|
||||
private var event_data: [NoteId: EventData] = [:]
|
||||
var replies = ReplyMap()
|
||||
|
||||
//private var thread_latest: [String: Int64]
|
||||
|
||||
|
||||
init(ndb: Ndb) {
|
||||
self.ndb = ndb
|
||||
cancellable = NotificationCenter.default.publisher(
|
||||
@@ -169,7 +169,7 @@ class EventCache {
|
||||
var ev = event
|
||||
|
||||
while true {
|
||||
guard let direct_reply = ev.direct_replies(keypair).last,
|
||||
guard let direct_reply = ev.direct_replies(),
|
||||
let next_ev = lookup(direct_reply), next_ev != ev
|
||||
else {
|
||||
break
|
||||
@@ -183,11 +183,11 @@ class EventCache {
|
||||
}
|
||||
|
||||
func add_replies(ev: NostrEvent, keypair: Keypair) {
|
||||
for reply in ev.direct_replies(keypair) {
|
||||
if let reply = ev.direct_replies() {
|
||||
replies.add(id: reply, reply_id: ev.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func child_events(event: NostrEvent) -> [NostrEvent] {
|
||||
guard let xs = replies.lookup(event.id) else {
|
||||
return []
|
||||
@@ -218,7 +218,16 @@ class EventCache {
|
||||
*/
|
||||
|
||||
func lookup(_ evid: NoteId) -> NostrEvent? {
|
||||
return events[evid]
|
||||
if let ev = events[evid] {
|
||||
return ev
|
||||
}
|
||||
|
||||
if let ev = self.ndb.lookup_note(evid)?.unsafeUnownedValue?.to_owned() {
|
||||
events[ev.id] = ev
|
||||
return ev
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func insert(_ ev: NostrEvent) {
|
||||
@@ -235,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.
|
||||
@@ -252,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
|
||||
@@ -404,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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// Array.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-05-10.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Array {
|
||||
/// Splits the array into chunks of the specified size.
|
||||
/// - Parameter size: The maximum size of each chunk.
|
||||
/// - Returns: An array of arrays, where each contained array is a chunk of the original array with up to `size` elements.
|
||||
func chunked(into size: Int) -> [[Element]] {
|
||||
guard size > 0 else { return [self] }
|
||||
return stride(from: 0, to: count, by: size).map {
|
||||
Array(self[$0..<Swift.min($0 + size, count)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element: Equatable {
|
||||
mutating func removeAll(equalTo item: Element) {
|
||||
self.removeAll(where: { $0 == item })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// VectorMath.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-06-17.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension CGPoint {
|
||||
/// Summing a vector to a point
|
||||
static func +(lhs: CGPoint, rhs: CGVector) -> CGPoint {
|
||||
return CGPoint(x: lhs.x + rhs.dx, y: lhs.y + rhs.dy)
|
||||
}
|
||||
|
||||
/// Subtracting a vector from a point
|
||||
static func -(lhs: CGPoint, rhs: CGVector) -> CGPoint {
|
||||
return CGPoint(x: lhs.x - rhs.dx, y: lhs.y - rhs.dy)
|
||||
}
|
||||
}
|
||||
|
||||
extension CGVector {
|
||||
/// Multiplying a vector by a scalar
|
||||
static func *(lhs: CGVector, rhs: CGFloat) -> CGVector {
|
||||
return CGVector(dx: lhs.dx * rhs, dy: lhs.dy * rhs)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -39,7 +39,7 @@ class ReplyCounter {
|
||||
|
||||
counted.insert(event.id)
|
||||
|
||||
for reply in event.direct_replies(keypair) {
|
||||
if let reply = event.direct_replies() {
|
||||
if event.pubkey == our_pubkey {
|
||||
self.our_replies[reply] = event
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -79,13 +80,13 @@ enum Route: Hashable {
|
||||
case .AppearanceSettings(let settings):
|
||||
AppearanceSettingsView(damus_state: damusState, settings: settings)
|
||||
case .NotificationSettings(let settings):
|
||||
NotificationSettingsView(settings: settings)
|
||||
NotificationSettingsView(damus_state: damusState, settings: settings)
|
||||
case .ZapSettings(let settings):
|
||||
ZapSettingsView(settings: settings)
|
||||
case .TranslationSettings(let settings):
|
||||
TranslationSettingsView(settings: settings, damus_state: damusState)
|
||||
case .ReactionsSettings(let settings):
|
||||
ReactionsSettingsView(settings: settings)
|
||||
ReactionsSettingsView(settings: settings, damus_state: damusState)
|
||||
case .SearchSettings(let settings):
|
||||
SearchSettingsView(settings: settings)
|
||||
case .DeveloperSettings(let settings):
|
||||
@@ -93,7 +94,8 @@ enum Route: Hashable {
|
||||
case .FirstAidSettings(settings: let settings):
|
||||
FirstAidSettingsView(damus_state: damusState, settings: settings)
|
||||
case .Thread(let thread):
|
||||
ThreadView(state: damusState, thread: thread)
|
||||
ChatroomThreadView(damus: damusState, thread: thread)
|
||||
//ThreadView(state: damusState, thread: thread)
|
||||
case .Reposts(let reposts):
|
||||
RepostsView(damus_state: damusState, model: reposts)
|
||||
case .QuoteReposts(let quote_reposts):
|
||||
@@ -104,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:
|
||||
@@ -199,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? {
|
||||
|
||||
@@ -6,28 +6,34 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import MCEmojiPicker
|
||||
import EmojiPicker
|
||||
import EmojiKit
|
||||
import SwipeActions
|
||||
|
||||
struct EventActionBar: View {
|
||||
let damus_state: DamusState
|
||||
let event: NostrEvent
|
||||
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||
let userProfile : ProfileModel
|
||||
let swipe_context: SwipeContext?
|
||||
let options: Options
|
||||
|
||||
// just used for previews
|
||||
@State var show_share_sheet: Bool = false
|
||||
@State var show_share_action: Bool = false
|
||||
@State var show_repost_action: Bool = false
|
||||
|
||||
@State private var isOnTopHalfOfScreen: Bool = false
|
||||
@State private var selectedEmoji: Emoji? = nil
|
||||
|
||||
@ObservedObject var bar: ActionBarModel
|
||||
|
||||
init(damus_state: DamusState, event: NostrEvent, bar: ActionBarModel? = nil) {
|
||||
init(damus_state: DamusState, event: NostrEvent, bar: ActionBarModel? = nil, options: Options = [], swipe_context: SwipeContext? = nil) {
|
||||
self.damus_state = damus_state
|
||||
self.event = event
|
||||
_bar = ObservedObject(wrappedValue: bar ?? make_actionbar_model(ev: event.id, damus: damus_state))
|
||||
self.userProfile = ProfileModel(pubkey: event.pubkey, damus: damus_state)
|
||||
self.options = options
|
||||
self.swipe_context = swipe_context
|
||||
}
|
||||
|
||||
var lnurl: String? {
|
||||
@@ -44,60 +50,176 @@ struct EventActionBar: View {
|
||||
return true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if damus_state.keypair.privkey != nil {
|
||||
HStack(spacing: 4) {
|
||||
EventActionButton(img: "bubble2", col: bar.replied ? DamusColors.purple : Color.gray) {
|
||||
notify(.compose(.replying_to(event)))
|
||||
}
|
||||
.accessibilityLabel(NSLocalizedString("Reply", comment: "Accessibility label for reply button"))
|
||||
Text(verbatim: "\(bar.replies > 0 ? "\(bar.replies)" : "")")
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundColor(bar.replied ? DamusColors.purple : Color.gray)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
|
||||
EventActionButton(img: "repost", col: bar.boosted ? Color.green : nil) {
|
||||
self.show_repost_action = true
|
||||
}
|
||||
.accessibilityLabel(NSLocalizedString("Reposts", comment: "Accessibility label for boosts button"))
|
||||
Text(verbatim: "\(bar.boosts > 0 ? "\(bar.boosts)" : "")")
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundColor(bar.boosted ? Color.green : Color.gray)
|
||||
}
|
||||
|
||||
if show_like {
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 4) {
|
||||
LikeButton(damus_state: damus_state, liked: bar.liked, liked_emoji: bar.our_like != nil ? to_reaction_emoji(ev: bar.our_like!) : nil, isOnTopHalfOfScreen: $isOnTopHalfOfScreen) { emoji in
|
||||
if bar.liked {
|
||||
//notify(.delete, bar.our_like)
|
||||
} else {
|
||||
send_like(emoji: emoji)
|
||||
}
|
||||
}
|
||||
|
||||
Text(verbatim: "\(bar.likes > 0 ? "\(bar.likes)" : "")")
|
||||
.font(.footnote.weight(.medium))
|
||||
.nip05_colorized(gradient: bar.liked)
|
||||
}
|
||||
}
|
||||
|
||||
if let lnurl = self.lnurl {
|
||||
Spacer()
|
||||
NoteZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: self.damus_state.events.get_cache_data(self.event.id).zaps_model)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
EventActionButton(img: "upload", col: Color.gray) {
|
||||
show_share_action = true
|
||||
}
|
||||
.accessibilityLabel(NSLocalizedString("Share", comment: "Button to share a note"))
|
||||
var space_if_spread: AnyView {
|
||||
if options.contains(.no_spread) {
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
else {
|
||||
return AnyView(Spacer())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Swipe action menu buttons
|
||||
|
||||
var reply_swipe_button: some View {
|
||||
SwipeAction(systemImage: "arrowshape.turn.up.left.fill", backgroundColor: DamusColors.adaptableGrey) {
|
||||
notify(.compose(.replying_to(event)))
|
||||
self.swipe_context?.state.wrappedValue = .closed
|
||||
}
|
||||
.allowSwipeToTrigger()
|
||||
.swipeButtonStyle()
|
||||
.accessibilityLabel(NSLocalizedString("Reply", comment: "Accessibility label for reply button"))
|
||||
}
|
||||
|
||||
var repost_swipe_button: some View {
|
||||
SwipeAction(image: "repost", backgroundColor: DamusColors.adaptableGrey) {
|
||||
self.show_repost_action = true
|
||||
self.swipe_context?.state.wrappedValue = .closed
|
||||
}
|
||||
.swipeButtonStyle()
|
||||
.accessibilityLabel(NSLocalizedString("Repost or quote this note", comment: "Accessibility label for repost/quote button"))
|
||||
}
|
||||
|
||||
var like_swipe_button: some View {
|
||||
SwipeAction(image: "shaka", backgroundColor: DamusColors.adaptableGrey) {
|
||||
send_like(emoji: damus_state.settings.default_emoji_reaction)
|
||||
self.swipe_context?.state.wrappedValue = .closed
|
||||
}
|
||||
.swipeButtonStyle()
|
||||
.accessibilityLabel(NSLocalizedString("React with default reaction emoji", comment: "Accessibility label for react button"))
|
||||
}
|
||||
|
||||
var share_swipe_button: some View {
|
||||
SwipeAction(image: "upload", backgroundColor: DamusColors.adaptableGrey) {
|
||||
show_share_action = true
|
||||
self.swipe_context?.state.wrappedValue = .closed
|
||||
}
|
||||
.swipeButtonStyle()
|
||||
.accessibilityLabel(NSLocalizedString("Share externally", comment: "Accessibility label for external share button"))
|
||||
}
|
||||
|
||||
// MARK: Bar buttons
|
||||
|
||||
var reply_button: some View {
|
||||
HStack(spacing: 4) {
|
||||
EventActionButton(img: "bubble2", col: bar.replied ? DamusColors.purple : Color.gray) {
|
||||
notify(.compose(.replying_to(event)))
|
||||
}
|
||||
.accessibilityLabel(NSLocalizedString("Reply", comment: "Accessibility label for reply button"))
|
||||
Text(verbatim: "\(bar.replies > 0 ? "\(bar.replies)" : "")")
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundColor(bar.replied ? DamusColors.purple : Color.gray)
|
||||
}
|
||||
}
|
||||
|
||||
var repost_button: some View {
|
||||
HStack(spacing: 4) {
|
||||
|
||||
EventActionButton(img: "repost", col: bar.boosted ? Color.green : nil) {
|
||||
self.show_repost_action = true
|
||||
}
|
||||
.accessibilityLabel(NSLocalizedString("Reposts", comment: "Accessibility label for boosts button"))
|
||||
Text(verbatim: "\(bar.boosts > 0 ? "\(bar.boosts)" : "")")
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundColor(bar.boosted ? Color.green : Color.gray)
|
||||
}
|
||||
}
|
||||
|
||||
var like_button: some View {
|
||||
HStack(spacing: 4) {
|
||||
LikeButton(damus_state: damus_state, liked: bar.liked, liked_emoji: bar.our_like != nil ? to_reaction_emoji(ev: bar.our_like!) : nil) { emoji in
|
||||
if bar.liked {
|
||||
//notify(.delete, bar.our_like)
|
||||
} else {
|
||||
send_like(emoji: emoji)
|
||||
}
|
||||
}
|
||||
|
||||
Text(verbatim: "\(bar.likes > 0 ? "\(bar.likes)" : "")")
|
||||
.font(.footnote.weight(.medium))
|
||||
.nip05_colorized(gradient: bar.liked)
|
||||
}
|
||||
}
|
||||
|
||||
var share_button: some View {
|
||||
EventActionButton(img: "upload", col: Color.gray) {
|
||||
show_share_action = true
|
||||
}
|
||||
.accessibilityLabel(NSLocalizedString("Share", comment: "Button to share a note"))
|
||||
}
|
||||
|
||||
// MARK: Main views
|
||||
|
||||
var swipe_action_menu_content: some View {
|
||||
Group {
|
||||
self.reply_swipe_button
|
||||
self.repost_swipe_button
|
||||
if show_like {
|
||||
self.like_swipe_button
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var swipe_action_menu_reverse_content: some View {
|
||||
Group {
|
||||
if show_like {
|
||||
self.like_swipe_button
|
||||
}
|
||||
self.repost_swipe_button
|
||||
self.reply_swipe_button
|
||||
}
|
||||
}
|
||||
|
||||
var action_bar_content: some View {
|
||||
let hide_items_without_activity = options.contains(.hide_items_without_activity)
|
||||
let should_hide_chat_bubble = hide_items_without_activity && bar.replies == 0
|
||||
let should_hide_repost = hide_items_without_activity && bar.boosts == 0
|
||||
let should_hide_reactions = hide_items_without_activity && bar.likes == 0
|
||||
let zap_model = self.damus_state.events.get_cache_data(self.event.id).zaps_model
|
||||
let should_hide_zap = hide_items_without_activity && zap_model.zap_total > 0
|
||||
let should_hide_share_button = hide_items_without_activity
|
||||
|
||||
return HStack(spacing: options.contains(.no_spread) ? 10 : 0) {
|
||||
if damus_state.keypair.privkey != nil && !should_hide_chat_bubble {
|
||||
self.reply_button
|
||||
}
|
||||
|
||||
if !should_hide_repost {
|
||||
self.space_if_spread
|
||||
self.repost_button
|
||||
}
|
||||
|
||||
if show_like && !should_hide_reactions {
|
||||
self.space_if_spread
|
||||
self.like_button
|
||||
}
|
||||
|
||||
if let lnurl = self.lnurl, !should_hide_zap {
|
||||
self.space_if_spread
|
||||
NoteZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: zap_model)
|
||||
}
|
||||
|
||||
if !should_hide_share_button {
|
||||
self.space_if_spread
|
||||
self.share_button
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
if options.contains(.swipe_action_menu) {
|
||||
AnyView(self.swipe_action_menu_content)
|
||||
}
|
||||
else if options.contains(.swipe_action_menu_reverse) {
|
||||
AnyView(self.swipe_action_menu_reverse_content)
|
||||
}
|
||||
else {
|
||||
AnyView(self.action_bar_content)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
self.content
|
||||
.onAppear {
|
||||
self.bar.update(damus: damus_state, evid: self.event.id)
|
||||
}
|
||||
@@ -136,20 +258,6 @@ struct EventActionBar: View {
|
||||
self.bar.our_like = liked.event
|
||||
}
|
||||
}
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
EmptyView()
|
||||
.onAppear {
|
||||
let eventActionBarY = geometry.frame(in: .global).midY
|
||||
let screenMidY = UIScreen.main.bounds.midY
|
||||
self.isOnTopHalfOfScreen = eventActionBarY > screenMidY
|
||||
}
|
||||
.onChange(of: geometry.frame(in: .global).midY) { newY in
|
||||
let screenMidY = UIScreen.main.bounds.midY
|
||||
self.isOnTopHalfOfScreen = newY > screenMidY
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func send_like(emoji: String) {
|
||||
@@ -164,6 +272,17 @@ struct EventActionBar: View {
|
||||
|
||||
damus_state.postbox.send(like_ev)
|
||||
}
|
||||
|
||||
// MARK: Helper structures
|
||||
|
||||
struct Options: OptionSet {
|
||||
let rawValue: UInt32
|
||||
|
||||
static let no_spread = Options(rawValue: 1 << 0)
|
||||
static let hide_items_without_activity = Options(rawValue: 1 << 1)
|
||||
static let swipe_action_menu = Options(rawValue: 1 << 2)
|
||||
static let swipe_action_menu_reverse = Options(rawValue: 1 << 3)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +302,6 @@ struct LikeButton: View {
|
||||
let damus_state: DamusState
|
||||
let liked: Bool
|
||||
let liked_emoji: String?
|
||||
@Binding var isOnTopHalfOfScreen: Bool
|
||||
let action: (_ emoji: String) -> Void
|
||||
|
||||
// For reactions background
|
||||
@@ -192,7 +310,7 @@ struct LikeButton: View {
|
||||
|
||||
@State private var isReactionsVisible = false
|
||||
|
||||
@State private var selectedEmoji: String = ""
|
||||
@State private var selectedEmoji: Emoji?
|
||||
|
||||
// Following four are Shaka animation properties
|
||||
let timer = Timer.publish(every: 0.10, on: .main, in: .common).autoconnect()
|
||||
@@ -231,6 +349,11 @@ struct LikeButton: View {
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isReactionsVisible) {
|
||||
NavigationView {
|
||||
EmojiPickerView(selectedEmoji: $selectedEmoji, emojiProvider: damus_state.emoji_provider)
|
||||
}.presentationDetents([.medium, .large])
|
||||
}
|
||||
.accessibilityLabel(NSLocalizedString("Like", comment: "Accessibility Label for Like button"))
|
||||
.rotationEffect(Angle(degrees: shouldAnimate ? rotationAngle : 0))
|
||||
.onReceive(self.timer) { _ in
|
||||
@@ -245,14 +368,10 @@ struct LikeButton: View {
|
||||
amountOfAngleIncrease = 20.0
|
||||
}
|
||||
})
|
||||
.emojiPicker(
|
||||
isPresented: $isReactionsVisible,
|
||||
selectedEmoji: $selectedEmoji,
|
||||
arrowDirection: isOnTopHalfOfScreen ? .down : .up,
|
||||
isDismissAfterChoosing: true
|
||||
)
|
||||
.onChange(of: selectedEmoji) { newSelectedEmoji in
|
||||
self.action(newSelectedEmoji)
|
||||
if let newSelectedEmoji {
|
||||
self.action(newSelectedEmoji.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +418,6 @@ struct LikeButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct EventActionBar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let ds = test_damus_state
|
||||
@@ -324,7 +442,44 @@ struct EventActionBar_Previews: PreviewProvider {
|
||||
EventActionBar(damus_state: ds, event: ev, bar: extra_max_bar)
|
||||
|
||||
EventActionBar(damus_state: ds, event: ev, bar: mega_max_bar)
|
||||
|
||||
EventActionBar(damus_state: ds, event: ev, bar: bar, options: [.no_spread])
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
fileprivate struct SwipeButtonStyle: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Color.damusAdaptableGrey2, lineWidth: 2))
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension View {
|
||||
func swipeButtonStyle() -> some View {
|
||||
modifier(SwipeButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Needed extensions for SwipeAction
|
||||
|
||||
public extension SwipeAction where Label == Image, Background == Color {
|
||||
init(
|
||||
image: String,
|
||||
backgroundColor: Color = Color.primary.opacity(0.1),
|
||||
highlightOpacity: Double = 0.5,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.init(action: action) { highlight in
|
||||
Image(image)
|
||||
} background: { highlight in
|
||||
backgroundColor
|
||||
.opacity(highlight ? highlightOpacity : 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//
|
||||
// ChatBubbleView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-06-17.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Use this view to display content inside of a custom-designed chat bubble shape.
|
||||
struct ChatBubble<T: View, U: ShapeStyle, V: View>: View {
|
||||
/// The direction at which the chat bubble tip will be pointing towards
|
||||
let direction: Direction
|
||||
let stroke_content: U
|
||||
let stroke_style: StrokeStyle
|
||||
let background_style: V
|
||||
@ViewBuilder let content: T
|
||||
|
||||
// Constants, which are loosely tied to `OFFSET_X` and `OFFSET_Y`
|
||||
let OFFSET_X_PADDING: CGFloat = 6
|
||||
let OFFSET_Y_BOTTOM_PADDING: CGFloat = 3
|
||||
|
||||
var body: some View {
|
||||
self.content
|
||||
.padding(direction == .left ? .leading : .trailing, OFFSET_X_PADDING)
|
||||
.padding(.bottom, OFFSET_Y_BOTTOM_PADDING)
|
||||
.background(self.background_style)
|
||||
.clipShape(
|
||||
BubbleShape(direction: self.direction)
|
||||
)
|
||||
.overlay(
|
||||
BubbleShape(direction: self.direction)
|
||||
.stroke(self.stroke_content, style: self.stroke_style)
|
||||
)
|
||||
.padding(direction == .left ? .leading : .trailing, -OFFSET_X_PADDING)
|
||||
.padding(.bottom, -OFFSET_Y_BOTTOM_PADDING)
|
||||
}
|
||||
|
||||
enum Direction {
|
||||
case right
|
||||
case left
|
||||
}
|
||||
|
||||
struct BubbleShape: Shape {
|
||||
/// The direction at which the chat bubble tip will be pointing towards
|
||||
let direction: Direction
|
||||
|
||||
// MARK: Constant parameters that defines the shape and look of the chat bubbles
|
||||
|
||||
/// The corner radius of the round edges
|
||||
let CORNER_RADIUS: CGFloat = 10
|
||||
/// The height of the chat bubble tip detail
|
||||
let DETAIL_HEIGHT: CGFloat = 10
|
||||
/// The horizontal distance between the chat bubble tip and the vertical edge of the bubble
|
||||
let OFFSET_X: CGFloat = 7
|
||||
/// The vertical distance between the chat bubble tip and the bottom edge of the bubble
|
||||
let OFFSET_Y: CGFloat = 5
|
||||
/// Value between 0 and 1 that determines curvature of the upper chat bubble curve detail
|
||||
let DETAIL_CURVE_FACTOR: CGFloat = 0.75
|
||||
/// Value between 0 and 1 that determines curvature of the lower chat bubble curve detail
|
||||
let LOWER_DETAIL_CURVE_FACTOR: CGFloat = 0.4
|
||||
/// The horizontal distance between the chat bubble tip and the point at which the lower chat bubble curve detail attaches to the bottom of the chat bubble
|
||||
let LOWER_DETAIL_ATTACHMENT_OFFSET_X: CGFloat = 20
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
return self.direction == .left ? self.draw_left_bubble(in: rect) : self.draw_right_bubble(in: rect)
|
||||
}
|
||||
|
||||
func draw_left_bubble(in rect: CGRect) -> Path {
|
||||
return Path { p in
|
||||
// Start at the top left, just below the end of the corner radius
|
||||
let start = CGPoint(x: OFFSET_X, y: CORNER_RADIUS)
|
||||
// Left edge
|
||||
p.move(to: start)
|
||||
p.addLine(to: CGPoint(x: OFFSET_X, y: rect.height - DETAIL_HEIGHT))
|
||||
// Draw the chat bubble tip
|
||||
p.addLine(to: CGPoint(x: OFFSET_X, y: rect.height - DETAIL_HEIGHT))
|
||||
let tip_of_bubble = CGPoint(x: 0, y: rect.height)
|
||||
p.addQuadCurve(
|
||||
to: tip_of_bubble,
|
||||
control: CGPoint(x: 0, y: rect.height - DETAIL_HEIGHT) + CGVector(dx: OFFSET_X, dy: DETAIL_HEIGHT) * DETAIL_CURVE_FACTOR
|
||||
)
|
||||
let lower_detail_attachment = CGPoint(x: LOWER_DETAIL_ATTACHMENT_OFFSET_X, y: rect.height - OFFSET_Y)
|
||||
p.addCurve(
|
||||
to: lower_detail_attachment,
|
||||
control1: tip_of_bubble + CGVector(dx: LOWER_DETAIL_ATTACHMENT_OFFSET_X, dy: 0) * LOWER_DETAIL_CURVE_FACTOR,
|
||||
control2: lower_detail_attachment - CGVector(dx: LOWER_DETAIL_ATTACHMENT_OFFSET_X, dy: 0) * LOWER_DETAIL_CURVE_FACTOR
|
||||
)
|
||||
// Draw the bottom edge
|
||||
p.addLine(to: CGPoint(x: rect.width - CORNER_RADIUS, y: rect.height - OFFSET_Y))
|
||||
// Draw the bottom right round corner
|
||||
p.addQuadCurve(
|
||||
to: CGPoint(x: rect.width, y: rect.height - OFFSET_Y - CORNER_RADIUS),
|
||||
control: CGPoint(x: rect.width, y: rect.height - OFFSET_Y)
|
||||
)
|
||||
// Draw right edge
|
||||
p.addLine(to: CGPoint(x: rect.width, y: CORNER_RADIUS))
|
||||
// Draw top right round corner
|
||||
p.addQuadCurve(
|
||||
to: CGPoint(x: rect.width - CORNER_RADIUS, y: 0),
|
||||
control: CGPoint(x: rect.width, y: 0)
|
||||
)
|
||||
// Draw top edge
|
||||
p.addLine(to: CGPoint(x: CORNER_RADIUS + OFFSET_X, y: 0))
|
||||
// Draw top left round corner
|
||||
p.addQuadCurve(
|
||||
to: start,
|
||||
control: CGPoint(x: OFFSET_X, y: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func draw_right_bubble(in rect: CGRect) -> Path {
|
||||
return Path { p in
|
||||
// Start at the top right, just below the end of the corner radius
|
||||
let right_edge = rect.width - OFFSET_X
|
||||
let start = CGPoint(x: right_edge, y: CORNER_RADIUS)
|
||||
p.move(to: start)
|
||||
// Right edge
|
||||
p.addLine(to: CGPoint(x: right_edge, y: rect.height - DETAIL_HEIGHT))
|
||||
// Draw the chat bubble tip
|
||||
let tip_of_bubble = CGPoint(x: rect.width, y: rect.height)
|
||||
p.addQuadCurve(
|
||||
to: tip_of_bubble,
|
||||
control: CGPoint(x: rect.width, y: rect.height - DETAIL_HEIGHT) + CGVector(dx: -OFFSET_X, dy: DETAIL_HEIGHT) * DETAIL_CURVE_FACTOR
|
||||
)
|
||||
let lower_detail_attachment = CGPoint(x: rect.width - LOWER_DETAIL_ATTACHMENT_OFFSET_X, y: rect.height - OFFSET_Y)
|
||||
p.addCurve(
|
||||
to: lower_detail_attachment,
|
||||
control1: tip_of_bubble - CGVector(dx: LOWER_DETAIL_ATTACHMENT_OFFSET_X, dy: 0) * LOWER_DETAIL_CURVE_FACTOR,
|
||||
control2: lower_detail_attachment + CGVector(dx: LOWER_DETAIL_ATTACHMENT_OFFSET_X, dy: 0) * LOWER_DETAIL_CURVE_FACTOR
|
||||
)
|
||||
// Draw the bottom edge
|
||||
p.addLine(to: CGPoint(x: CORNER_RADIUS, y: rect.height - OFFSET_Y))
|
||||
// Draw the bottom left round corner
|
||||
p.addQuadCurve(
|
||||
to: CGPoint(x: 0, y: rect.height - OFFSET_Y - CORNER_RADIUS),
|
||||
control: CGPoint(x: 0, y: rect.height - OFFSET_Y)
|
||||
)
|
||||
// Draw left edge
|
||||
p.addLine(to: CGPoint(x: 0, y: CORNER_RADIUS))
|
||||
// Draw top right round corner
|
||||
p.addQuadCurve(
|
||||
to: CGPoint(x: CORNER_RADIUS, y: 0),
|
||||
control: CGPoint(x: 0, y: 0)
|
||||
)
|
||||
// Draw top edge
|
||||
p.addLine(to: CGPoint(x: rect.width - CORNER_RADIUS - OFFSET_X, y: 0))
|
||||
// Draw top left round corner
|
||||
p.addQuadCurve(
|
||||
to: start,
|
||||
control: CGPoint(x: rect.width - OFFSET_X, y: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack {
|
||||
ChatBubble(
|
||||
direction: .left,
|
||||
stroke_content: Color.accentColor.opacity(0),
|
||||
stroke_style: .init(lineWidth: 4),
|
||||
background_style: Color.accentColor
|
||||
) {
|
||||
Text(verbatim: "Hello there")
|
||||
.padding()
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
|
||||
ChatBubble(
|
||||
direction: .right,
|
||||
stroke_content: Color.accentColor.opacity(0),
|
||||
stroke_style: .init(lineWidth: 4),
|
||||
background_style: Color.accentColor
|
||||
) {
|
||||
Text(verbatim: "Hello there")
|
||||
.padding()
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
//
|
||||
// ChatView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-04-19.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import EmojiKit
|
||||
import EmojiPicker
|
||||
import SwipeActions
|
||||
|
||||
fileprivate let CORNER_RADIUS: CGFloat = 10
|
||||
|
||||
struct ChatEventView: View {
|
||||
// MARK: Parameters
|
||||
let event: NostrEvent
|
||||
let selected_event: NostrEvent
|
||||
let prev_ev: NostrEvent?
|
||||
let next_ev: NostrEvent?
|
||||
let damus_state: DamusState
|
||||
var thread: ThreadModel
|
||||
let scroll_to_event: ((_ id: NoteId) -> Void)?
|
||||
let focus_event: (() -> Void)?
|
||||
let highlight_bubble: Bool
|
||||
|
||||
// MARK: long-press reaction control objects
|
||||
/// Whether the user is actively pressing the view
|
||||
@State var is_pressing = false
|
||||
@State var popover_state: PopoverState = .closed {
|
||||
didSet {
|
||||
let generator = UIImpactFeedbackGenerator(style: popover_state.some_sheet_open() ? .heavy : .light)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
}
|
||||
@State var selected_emoji: Emoji?
|
||||
|
||||
@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 {
|
||||
return prev_ev == nil || prev_ev!.pubkey != event.pubkey
|
||||
}
|
||||
|
||||
func next_replies_to_this() -> Bool {
|
||||
guard let next = next_ev else {
|
||||
return false
|
||||
}
|
||||
|
||||
return damus_state.events.replies.lookup(next.id) != nil
|
||||
}
|
||||
|
||||
func is_reply_to_prev(ref_id: NoteId) -> Bool {
|
||||
guard let prev = prev_ev else {
|
||||
return true
|
||||
}
|
||||
|
||||
if let rep = damus_state.events.replies.lookup(event.id) {
|
||||
return rep.contains(prev.id)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var disable_animation: Bool {
|
||||
self.damus_state.settings.disable_animation
|
||||
}
|
||||
|
||||
var reply_quote_options: EventViewOptions {
|
||||
return [.no_previews, .no_action_bar, .truncate_content_very_short, .no_show_more, .no_translate, .no_media]
|
||||
}
|
||||
|
||||
var profile_picture_view: some View {
|
||||
VStack {
|
||||
ProfilePicView(pubkey: event.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation)
|
||||
.onTapGesture {
|
||||
show_profile_action_sheet_if_enabled(damus_state: damus_state, pubkey: event.pubkey)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 32)
|
||||
}
|
||||
|
||||
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,
|
||||
stroke_content: Color.accentColor.opacity(highlight_bubble ? 1 : 0),
|
||||
stroke_style: .init(lineWidth: 4),
|
||||
background_style: by_other_user ? DamusColors.adaptableGrey : DamusColors.adaptablePurpleBackground
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if by_other_user {
|
||||
HStack {
|
||||
ProfileName(pubkey: event.pubkey, damus: damus_state)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
if let replying_to = event.direct_replies(),
|
||||
replying_to != selected_event.id {
|
||||
ReplyQuoteView(keypair: damus_state.keypair, quoter: event, event_id: replying_to, state: damus_state, thread: thread, options: reply_quote_options)
|
||||
.background(is_ours ? DamusColors.adaptablePurpleBackground2 : DamusColors.adaptableGrey2)
|
||||
.foregroundColor(is_ours ? Color.damusAdaptablePurpleForeground : Color.damusAdaptableBlack)
|
||||
.cornerRadius(5)
|
||||
.onTapGesture {
|
||||
self.scroll_to_event?(replying_to)
|
||||
}
|
||||
}
|
||||
|
||||
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: [.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: 5, alignment: is_ours ? .trailing : .leading)
|
||||
.padding(10)
|
||||
}
|
||||
.tint(Color.accentColor)
|
||||
.overlay(
|
||||
ZStack(alignment: is_ours ? .bottomLeading : .bottomTrailing) {
|
||||
VStack {
|
||||
Spacer()
|
||||
self.action_bar
|
||||
.padding(.horizontal, 5)
|
||||
}
|
||||
}
|
||||
)
|
||||
.onTapGesture {
|
||||
if popover_state == .closed {
|
||||
focus_event?()
|
||||
}
|
||||
else {
|
||||
popover_state = .closed
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var event_bubble_with_long_press_interaction: some View {
|
||||
ZStack(alignment: is_ours ? .bottomLeading : .bottomTrailing) {
|
||||
self.event_bubble
|
||||
.sheet(isPresented: Binding(get: { popover_state == .open_emoji_selector }, set: { new_state in
|
||||
withAnimation(new_state == true ? .easeIn(duration: 0.5) : .easeOut(duration: 0.1)) {
|
||||
popover_state = new_state == true ? .open_emoji_selector : .closed
|
||||
}
|
||||
})) {
|
||||
NavigationView {
|
||||
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)
|
||||
popover_state = .closed
|
||||
}
|
||||
}
|
||||
}
|
||||
.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: {
|
||||
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
|
||||
}
|
||||
})
|
||||
.onChange(of: swipeViewGroupSelection.wrappedValue) { newValue in
|
||||
self.is_pressing = false
|
||||
}
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
EmptyView()
|
||||
.onAppear {
|
||||
let eventActionBarY = geometry.frame(in: .global).midY
|
||||
let screenMidY = UIScreen.main.bounds.midY
|
||||
self.isOnTopHalfOfScreen = eventActionBarY > screenMidY
|
||||
}
|
||||
.onChange(of: geometry.frame(in: .global).midY) { newY in
|
||||
let screenMidY = UIScreen.main.bounds.midY
|
||||
self.isOnTopHalfOfScreen = newY > screenMidY
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func send_like(emoji: String) {
|
||||
guard let keypair = damus_state.keypair.to_full(),
|
||||
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
|
||||
return
|
||||
}
|
||||
|
||||
self.bar.our_like = like_ev
|
||||
|
||||
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||
generator.impactOccurred()
|
||||
|
||||
damus_state.postbox.send(like_ev)
|
||||
}
|
||||
|
||||
var action_bar: some View {
|
||||
return Group {
|
||||
if !bar.is_empty {
|
||||
HStack {
|
||||
if by_other_user {
|
||||
Spacer()
|
||||
}
|
||||
EventActionBar(damus_state: damus_state, event: event, bar: bar, options: [.no_spread, .hide_items_without_activity])
|
||||
.padding(10)
|
||||
.background(DamusColors.adaptableLighterGrey)
|
||||
.disabled(true)
|
||||
.cornerRadius(100)
|
||||
.overlay(RoundedRectangle(cornerSize: CGSize(width: 100, height: 100)).stroke(DamusColors.adaptableWhite, lineWidth: 1))
|
||||
.shadow(color: Color.black.opacity(0.05),radius: 3, y: 3)
|
||||
.scaleEffect(0.7, anchor: is_ours ? .leading : .trailing)
|
||||
if !by_other_user {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, -20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var event_bubble_with_long_press_and_swipe_interactions: some View {
|
||||
Group {
|
||||
SwipeView {
|
||||
self.event_bubble_with_long_press_interaction
|
||||
} leadingActions: { 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
|
||||
)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
return VStack {
|
||||
HStack(alignment: .bottom, spacing: 4) {
|
||||
if by_other_user {
|
||||
self.profile_picture_view
|
||||
}
|
||||
else {
|
||||
Spacer()
|
||||
}
|
||||
|
||||
self.event_bubble_with_long_press_and_swipe_interactions
|
||||
|
||||
if !by_other_user {
|
||||
self.profile_picture_view
|
||||
}
|
||||
else {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.id(event.id)
|
||||
.padding([.bottom], bar.is_empty ? 6 : 16)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if [.boost, .zap, .longform].contains(where: { event.known_kind == $0 }) {
|
||||
EmptyView()
|
||||
} else {
|
||||
self.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static var toggle_thread_view: Notification.Name {
|
||||
return Notification.Name("convert_to_thread")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||
return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
|
||||
}
|
||||
|
||||
#Preview {
|
||||
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: false, bar: bar)
|
||||
}
|
||||
|
||||
#Preview {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//
|
||||
// ChatroomView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-04-19.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwipeActions
|
||||
|
||||
struct ChatroomThreadView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var once: Bool = false
|
||||
let damus: DamusState
|
||||
@ObservedObject var thread: ThreadModel
|
||||
@State var selected_note_id: NoteId? = nil
|
||||
@State var user_just_posted_flag: Bool = false
|
||||
@Namespace private var animation
|
||||
|
||||
@State var parent_events: [NostrEvent] = []
|
||||
@State var sorted_child_events: [NostrEvent] = []
|
||||
|
||||
func compute_events(selected_event: NostrEvent? = nil) {
|
||||
let selected_event = selected_event ?? thread.event
|
||||
self.parent_events = damus.events.parent_events(event: selected_event, keypair: damus.keypair)
|
||||
let all_recursive_child_events = self.recursive_child_events(event: selected_event)
|
||||
self.sorted_child_events = all_recursive_child_events.filter({
|
||||
should_show_event(event: $0, damus_state: damus) // Hide muted events from chatroom conversation
|
||||
}).sorted(by: { a, b in
|
||||
return a.created_at < b.created_at
|
||||
})
|
||||
}
|
||||
|
||||
func recursive_child_events(event: NdbNote) -> [NdbNote] {
|
||||
let immediate_children = damus.events.child_events(event: event)
|
||||
var indirect_children: [NdbNote] = []
|
||||
for immediate_child in immediate_children {
|
||||
indirect_children.append(contentsOf: self.recursive_child_events(event: immediate_child))
|
||||
}
|
||||
return immediate_children + indirect_children
|
||||
}
|
||||
|
||||
func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) {
|
||||
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: .top)
|
||||
selected_note_id = note_id
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
|
||||
withAnimation {
|
||||
selected_note_id = nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func set_active_event(scroller: ScrollViewProxy, ev: NdbNote) {
|
||||
withAnimation {
|
||||
self.compute_events(selected_event: ev)
|
||||
thread.set_active_event(ev, keypair: self.damus.keypair)
|
||||
self.go_to_event(scroller: scroller, note_id: ev.id)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { scroller in
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
// MARK: - Parents events view
|
||||
ForEach(parent_events, id: \.id) { parent_event in
|
||||
EventMutingContainerView(damus_state: damus, event: parent_event) {
|
||||
EventView(damus: damus, event: parent_event)
|
||||
.matchedGeometryEffect(id: parent_event.id.hex(), in: animation, anchor: .center)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.onTapGesture {
|
||||
self.set_active_event(scroller: scroller, ev: parent_event)
|
||||
}
|
||||
.id(parent_event.id)
|
||||
|
||||
Divider()
|
||||
.padding(.top, 4)
|
||||
.padding(.leading, 25 * 2)
|
||||
|
||||
}.background(GeometryReader { geometry in
|
||||
// get the height and width of the EventView view
|
||||
let eventHeight = geometry.frame(in: .global).height
|
||||
// let eventWidth = geometry.frame(in: .global).width
|
||||
|
||||
// vertical gray line in the background
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.25))
|
||||
.frame(width: 2, height: eventHeight)
|
||||
.offset(x: 40, y: 40)
|
||||
})
|
||||
|
||||
// MARK: - Actual event view
|
||||
EventMutingContainerView(
|
||||
damus_state: damus,
|
||||
event: self.thread.event,
|
||||
muteBox: { event_shown, muted_reason in
|
||||
AnyView(
|
||||
EventMutedBoxView(shown: event_shown, reason: muted_reason)
|
||||
.padding(5)
|
||||
)
|
||||
}
|
||||
) {
|
||||
SelectedEventView(damus: damus, event: self.thread.event, size: .selected)
|
||||
.matchedGeometryEffect(id: self.thread.event.id.hex(), in: animation, anchor: .center)
|
||||
}
|
||||
.id(self.thread.event.id)
|
||||
|
||||
|
||||
// MARK: - Children view
|
||||
let events = sorted_child_events
|
||||
let count = events.count
|
||||
SwipeViewGroup {
|
||||
ForEach(Array(zip(events, events.indices)), id: \.0.id) { (ev, ind) in
|
||||
ChatEventView(event: events[ind],
|
||||
selected_event: self.thread.event,
|
||||
prev_ev: ind > 0 ? events[ind-1] : nil,
|
||||
next_ev: ind == count-1 ? nil : events[ind+1],
|
||||
damus_state: damus,
|
||||
thread: thread,
|
||||
scroll_to_event: { note_id in
|
||||
self.go_to_event(scroller: scroller, note_id: note_id)
|
||||
},
|
||||
focus_event: {
|
||||
self.set_active_event(scroller: scroller, ev: ev)
|
||||
},
|
||||
highlight_bubble: selected_note_id == ev.id,
|
||||
bar: make_actionbar_model(ev: ev.id, damus: damus)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.id(ev.id)
|
||||
.matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
EndBlock()
|
||||
|
||||
HStack {}
|
||||
.frame(height: tabHeight + getSafeAreaBottom())
|
||||
}
|
||||
.onReceive(handle_notify(.post), perform: { notify in
|
||||
switch notify {
|
||||
case .post(_):
|
||||
user_just_posted_flag = true
|
||||
case .cancel:
|
||||
return
|
||||
}
|
||||
})
|
||||
.onReceive(thread.objectWillChange) {
|
||||
self.compute_events()
|
||||
if let last_event = thread.events().last, last_event.pubkey == damus.pubkey, user_just_posted_flag {
|
||||
self.go_to_event(scroller: scroller, note_id: last_event.id)
|
||||
user_just_posted_flag = false
|
||||
}
|
||||
}
|
||||
.onAppear() {
|
||||
thread.subscribe()
|
||||
self.compute_events()
|
||||
scroll_to_event(scroller: scroller, id: thread.event.id, delay: 0.1, animate: false)
|
||||
}
|
||||
.onDisappear() {
|
||||
thread.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggle_thread_view() {
|
||||
NotificationCenter.default.post(name: .toggle_thread_view, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
struct ChatroomView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
ChatroomThreadView(damus: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state))
|
||||
.previewDisplayName("Test note")
|
||||
|
||||
let test_thread = ThreadModel(event: test_thread_note_1, damus_state: test_damus_state)
|
||||
ChatroomThreadView(damus: test_damus_state, thread: test_thread)
|
||||
.onAppear {
|
||||
test_thread.add_event(test_thread_note_2, keypair: test_keypair)
|
||||
test_thread.add_event(test_thread_note_3, keypair: test_keypair)
|
||||
test_thread.add_event(test_thread_note_4, keypair: test_keypair)
|
||||
test_thread.add_event(test_thread_note_5, keypair: test_keypair)
|
||||
test_thread.add_event(test_thread_note_6, keypair: test_keypair)
|
||||
test_thread.add_event(test_thread_note_7, keypair: test_keypair)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scroll_after_load(thread: ThreadModel, proxy: ScrollViewProxy) {
|
||||
scroll_to_event(scroller: proxy, id: thread.event.id, delay: 0.1, animate: false)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// ReplyQuoteView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-04-19.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ReplyQuoteView: View {
|
||||
let keypair: Keypair
|
||||
let quoter: NostrEvent
|
||||
let event_id: NoteId
|
||||
let state: DamusState
|
||||
@ObservedObject var thread: ThreadModel
|
||||
let options: EventViewOptions
|
||||
|
||||
func content(event: NdbNote) -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .center) {
|
||||
if should_show_event(event: event, damus_state: state) {
|
||||
ProfilePicView(pubkey: event.pubkey, size: 14, highlight: .reply, profiles: state.profiles, disable_animation: false)
|
||||
let blur_images = should_blur_images(settings: state.settings, contacts: state.contacts, ev: event, our_pubkey: state.pubkey)
|
||||
NoteContentView(damus_state: state, event: event, blur_images: blur_images, size: .small, options: options)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
.padding(.bottom, -7)
|
||||
.padding(.top, -5)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: 20)
|
||||
.clipped()
|
||||
}
|
||||
else {
|
||||
Text("Note you've muted", comment: "Label indicating note has been muted")
|
||||
.italic()
|
||||
.font(.caption)
|
||||
.opacity(0.5)
|
||||
.padding(.bottom, -7)
|
||||
.padding(.top, -5)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: 20)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(5)
|
||||
.padding(.leading, 5+3)
|
||||
Rectangle()
|
||||
.foregroundStyle(.accent)
|
||||
.frame(width: 3)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let event = state.events.lookup(event_id) {
|
||||
self.content(event: event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ReplyQuoteView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let s = test_damus_state
|
||||
let quoter = test_note
|
||||
ReplyQuoteView(keypair: s.keypair, quoter: quoter, event_id: test_note.id, state: s, thread: ThreadModel(event: quoter, damus_state: s), options: [.no_previews, .no_action_bar, .truncate_content_very_short, .no_show_more, .no_translate])
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -25,68 +25,44 @@ struct CreateAccountView: View {
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
VStack {
|
||||
Spacer()
|
||||
VStack(alignment: .center) {
|
||||
EditPictureControl(uploader: .nostrBuild, pubkey: account.pubkey, image_url: $account.profile_image , uploadObserver: profileUploadObserver, callback: uploadedProfilePicture)
|
||||
|
||||
Text("Public Key", comment: "Label to indicate the public key of the account.")
|
||||
|
||||
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)
|
||||
|
||||
Text("Add Photo", comment: "Label to indicate user can add a photo.")
|
||||
.bold()
|
||||
.padding()
|
||||
.onTapGesture {
|
||||
regen_key()
|
||||
}
|
||||
|
||||
KeyText($account.pubkey)
|
||||
.padding(.horizontal, 20)
|
||||
.onTapGesture {
|
||||
regen_key()
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 300, maxWidth: .infinity, minHeight: 250, alignment: .center)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(DamusColors.adaptableGrey, strokeBorder: .gray.opacity(0.5), lineWidth: 1)
|
||||
.foregroundColor(DamusColors.neutral6)
|
||||
}
|
||||
|
||||
SignupForm {
|
||||
FormLabel(NSLocalizedString("Display name", comment: "Label to prompt display name entry."), optional: true)
|
||||
FormTextInput(NSLocalizedString("Satoshi Nakamoto", comment: "Name of Bitcoin creator(s)."), text: $account.real_name)
|
||||
FormLabel(NSLocalizedString("Name", comment: "Label to prompt name entry."), optional: false)
|
||||
.foregroundColor(DamusColors.neutral6)
|
||||
FormTextInput(NSLocalizedString("Satoshi Nakamoto", comment: "Name of Bitcoin creator(s)."), text: $account.name)
|
||||
.textInputAutocapitalization(.words)
|
||||
|
||||
FormLabel(NSLocalizedString("About", comment: "Label to prompt for about text entry for user to describe about themself."), optional: true)
|
||||
FormTextInput(NSLocalizedString("Creator(s) of Bitcoin. Absolute legend.", comment: "Example description about Bitcoin creator(s), Satoshi Nakamoto."), text: $account.about)
|
||||
FormLabel(NSLocalizedString("Bio", comment: "Label to prompt bio entry for user to describe themself."), optional: true)
|
||||
.foregroundColor(DamusColors.neutral6)
|
||||
FormTextInput(NSLocalizedString("Absolute legend.", comment: "Example Bio"), text: $account.about)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
.padding(.top, 25)
|
||||
|
||||
Button(action: {
|
||||
nav.push(route: Route.SaveKeys(account: account))
|
||||
}) {
|
||||
HStack {
|
||||
Text("Create account now", comment: "Button to create account.")
|
||||
Text("Next", comment: "Button to continue with account creation.")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: 12, alignment: .center)
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
.disabled(profileUploadObserver.isLoading)
|
||||
.opacity(profileUploadObserver.isLoading ? 0.5 : 1)
|
||||
.disabled(profileUploadObserver.isLoading || account.name.isEmpty)
|
||||
.opacity(profileUploadObserver.isLoading || account.name.isEmpty ? 0.5 : 1)
|
||||
.padding(.top, 20)
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Text("By signing up, you agree to our ", comment: "Ask the user if they already have an account on Nostr")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color("DamusMediumGrey"))
|
||||
|
||||
Button(action: {
|
||||
nav.push(route: Route.EULA)
|
||||
}, label: {
|
||||
Text("EULA")
|
||||
.font(.subheadline)
|
||||
})
|
||||
.padding(.vertical, 5)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
LoginPrompt()
|
||||
.padding(.top)
|
||||
|
||||
@@ -94,8 +70,8 @@ struct CreateAccountView: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(DamusBackground(maxHeight: UIScreen.main.bounds.size.height/2), alignment: .top)
|
||||
.dismissKeyboardOnTap()
|
||||
.navigationTitle("Create account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.navigationBarItems(leading: BackNav())
|
||||
@@ -111,7 +87,7 @@ struct LoginPrompt: View {
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text("Already on Nostr?", comment: "Ask the user if they already have an account on Nostr")
|
||||
.foregroundColor(Color("DamusMediumGrey"))
|
||||
.foregroundColor(DamusColors.neutral6)
|
||||
|
||||
Button(NSLocalizedString("Login", comment: "Button to navigate to login view.")) {
|
||||
self.dismiss()
|
||||
@@ -127,8 +103,8 @@ struct BackNav: View {
|
||||
var body: some View {
|
||||
Image("chevron-left")
|
||||
.foregroundColor(DamusColors.adaptableBlack)
|
||||
.onTapGesture {
|
||||
self.dismiss()
|
||||
.onTapGesture {
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,20 +124,11 @@ extension View {
|
||||
|
||||
struct CreateAccountView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let model = CreateAccountModel(real: "", nick: "jb55", about: "")
|
||||
let model = CreateAccountModel(display_name: "", name: "jb55", about: "")
|
||||
return CreateAccountView(account: model, nav: .init())
|
||||
}
|
||||
}
|
||||
|
||||
func KeyText(_ pubkey: Binding<Pubkey>) -> some View {
|
||||
let bechkey = bech32_encode(hrp: PUBKEY_HRP, pubkey.wrappedValue.bytes)
|
||||
return Text(bechkey)
|
||||
.textSelection(.enabled)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.callout.monospaced())
|
||||
.foregroundStyle(DamusLogoGradient.gradient)
|
||||
}
|
||||
|
||||
func FormTextInput(_ title: String, text: Binding<String>) -> some View {
|
||||
return TextField("", text: text)
|
||||
.placeholder(when: text.wrappedValue.isEmpty) {
|
||||
@@ -171,6 +138,10 @@ func FormTextInput(_ title: String, text: Binding<String>) -> some View {
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(.gray.opacity(0.5), lineWidth: 1)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.foregroundColor(.damusAdaptableWhite)
|
||||
}
|
||||
}
|
||||
.font(.body.bold())
|
||||
}
|
||||
@@ -183,6 +154,10 @@ func FormLabel(_ title: String, optional: Bool = false) -> some View {
|
||||
Text("optional", comment: "Label indicating that a form input is optional.")
|
||||
.font(.callout)
|
||||
.foregroundColor(DamusColors.mediumGrey)
|
||||
} else {
|
||||
Text("required", comment: "Label indicating that a form input is required.")
|
||||
.font(.callout)
|
||||
.foregroundColor(DamusColors.mediumGrey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user