Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e6bf6a6b8a
|
|||
| 91fc0039eb | |||
| 8eb013f1f7 | |||
| 4f459d128a | |||
| 58e53631c6 | |||
| 7b2e178f5b | |||
| da99130b78 | |||
| b79d361016 | |||
| 4889c0a7d9 | |||
| 61c9732acd | |||
| ee6c080af8 | |||
| a18ba86157 | |||
| 03931ef70e | |||
| 3284832eb0 | |||
| c679be9644 | |||
| 9f701a7d44 | |||
| cb11087034 | |||
| 49b7aee74e | |||
| 5b97906138 | |||
| c74d3e4938 | |||
| df6911f9cb | |||
| 1ca0519e25 | |||
| c87f19b479 | |||
| 68ed3d7796 | |||
| 5c885b0fd4 | |||
| 8589fe9aee | |||
| 2aee84c65f | |||
| 76c6ac0f0b | |||
| be08083b88 | |||
| c2325a5e39 | |||
| 3eb544e40d | |||
| 9d209f485c | |||
| 1394122542 | |||
| e89c025d9d | |||
| 0970c364b6 | |||
| d16192e845 | |||
| 3b50f82094 | |||
| 46b53e1326 | |||
| 225a028f3e | |||
| d074d092a2 | |||
| 633fcd69a8 | |||
| 67869394cb | |||
| 22876b5c28 | |||
| 6f7d6d1933 | |||
| fddd86b207 | |||
| 95f1127b74 | |||
| 4c82176466 | |||
| e2d55ddae4 | |||
| 8fa80b7921 | |||
| 732b484faf | |||
| cd9c705221 | |||
| ba5a062829 | |||
| 687d1c9a3e | |||
| adef207018 | |||
| a050a5b729 | |||
| 6bced24430 | |||
| 60cddf2a15 | |||
| c9568fe7ac | |||
| 5c0e4599ad | |||
| e3519c51a5 | |||
| ed1aa246c4 | |||
| 532647d273 | |||
| dab8f7ca61 | |||
| 390eb342f7 | |||
| f1a7c0eded | |||
| ed058afc3b | |||
| 0e94c48e26 | |||
| 6ac68b5a73 | |||
| 2048e68d67 | |||
| 624d9662d7 | |||
| 88db9de4ea | |||
| d667a9d8f7 | |||
| 65f3c76eca | |||
| aed1e543d3 | |||
| 65576424fd | |||
| f99ad8fffa | |||
| 987d173529 | |||
| eab7a91f01 | |||
| 2d045f4dfb | |||
| 835e5a438f | |||
| ca4e91564a | |||
|
8733cbd42c
|
|||
| f5cdd4a159 | |||
| c4f41220e5 | |||
| 0f119d34e6 | |||
| ea90fb0429 | |||
| 8227be1873 | |||
| cbd92539a6 | |||
| 7940e6fd32 | |||
| 07f8ad75dc | |||
| 018bb4c33b | |||
| c81b403817 | |||
| e54ce88a3b | |||
| 84f4f1c71c | |||
| e934c2bb11 | |||
| fd8ad494e9 | |||
| f14ba7cce4 | |||
| 055b13c1cd | |||
| 357e8adf86 | |||
| a82a78c7df | |||
| 084c86eb0e | |||
| 10d9d23b7b | |||
| 50ecff0ec6 | |||
| 5ae96ec80a | |||
| abfd48ca20 | |||
| 67326e2003 | |||
| 306c3fe75c | |||
| 08c2056290 | |||
| bc5ee7cd51 | |||
| b2d1ad2537 | |||
| b9f37697d7 | |||
| 58e88262b0 | |||
| 26e28dd3dd | |||
| 6ac6ea3cd7 | |||
| 2de75968fb | |||
| 37b99983d3 | |||
| f62dc9348a | |||
| 6aab705399 | |||
| f7da481c68 | |||
| e5b629742a | |||
| a0e6aa060b | |||
| 6394f96ac0 | |||
| b6d6af12b8 | |||
| df84c4a64b | |||
| 71b333a18a | |||
| 29936f7b06 | |||
| 1cc1bfbbef | |||
| 2aa39e775e | |||
| 8a33243c98 | |||
| d198e69dc9 | |||
| c26b30f3c0 | |||
| c2479df213 | |||
|
30d045b1c5
|
|||
|
d76f7564ef
|
|||
|
bab72b215d
|
|||
|
d8cd81deb8
|
|||
|
92dfdacf97
|
|||
|
ead6e96613
|
|||
|
7312ee3884
|
|||
|
45099c59db
|
|||
|
3440c828e3
|
+54
-1
@@ -1,3 +1,57 @@
|
||||
## [1.4.3-15] - 2023-04-29
|
||||
|
||||
### Added
|
||||
|
||||
- Add q tag to quoted renotes (William Casarin)
|
||||
- Add confirmation alert when clearing all bookmarks (Swift)
|
||||
- Show blurhash placeholders from image metadata (William Casarin)
|
||||
- Add image metadata to image uploads (William Casarin)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Load zaps instantly on events (William Casarin)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix thread incompatibility for clients that add more than one reply tag (amethyst, plebstr)
|
||||
- Preserve order of bookmarks when saving (William Casarin)
|
||||
- Fix crash when you have invalid relays in your relay list (William Casarin)
|
||||
|
||||
|
||||
|
||||
[1.4.3-14]: https://github.com/damus-io/damus/releases/tag/v1.4.3-14
|
||||
|
||||
## [1.4.3-10] - 2023-04-25
|
||||
|
||||
### Added
|
||||
|
||||
- Add paste button to login (Suhail Saqan)
|
||||
- Add nokyctranslate translation option (symbsrcool)
|
||||
- You can now change the default zap type (William Casarin)
|
||||
- Add partial support for different repost variants (William Casarin)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Change 500 custom zap to 420 (William Casarin)
|
||||
- New looks to the custom zaps view (ericholguin)
|
||||
- Adjust attachment images placement when posting (Swift)
|
||||
- Only show friends, not friend-of-friend in friend filter (William Casarin)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix reposts on macos and ipad (William Casarin)
|
||||
- Fix slow reconnection issues (Bryan Montz)
|
||||
- Fix issue where uploaded images were from someone else (Swift)
|
||||
- Fix crash with LibreTranslate server setting selection and remove delisted vern server (Terry Yiu)
|
||||
- Fix buggy zap amounts and wallet selector settings (William Casarin)
|
||||
|
||||
|
||||
[1.4.3-10]: https://github.com/damus-io/damus/releases/tag/v1.4.3-10
|
||||
|
||||
## [1.4.3-2] - 2023-04-17
|
||||
|
||||
### Added
|
||||
@@ -1038,4 +1092,3 @@
|
||||
|
||||
|
||||
[0.1.2]: https://github.com/damus-io/damus/releases/tag/v0.1.2
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
dependencies: [
|
||||
.Package(url: "https://github.com/daltoniam/Starscream.git", majorVersion: 4),
|
||||
.Package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main")
|
||||
]
|
||||
|
||||
@@ -92,7 +92,7 @@ damus implements the following [Nostr Implementation Possibilities][nips]
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributors welcome!
|
||||
Contributors welcome! Start by examining known issues: https://github.com/damus-io/damus/issues.
|
||||
|
||||
### Code
|
||||
|
||||
@@ -100,6 +100,11 @@ Contributors welcome!
|
||||
|
||||
[git-send-email]: http://git-send-email.io
|
||||
|
||||
### Privacy
|
||||
Your internet protocol (IP) address is exposed to the relays you connect to, and third party media hosters (e.g. nostr.build, imgur.com, giphy.com, youtube.com etc.) that render on Damus. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
|
||||
|
||||
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
|
||||
|
||||
### Translations
|
||||
|
||||
Translators welcome! Join the [Transifex][transifex] project.
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
4C0A3F8F280F640A000448DE /* ThreadModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F8E280F640A000448DE /* ThreadModel.swift */; };
|
||||
4C0A3F91280F6528000448DE /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F90280F6528000448DE /* ChatView.swift */; };
|
||||
4C0A3F93280F66F5000448DE /* ReplyMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F92280F66F5000448DE /* ReplyMap.swift */; };
|
||||
4C198DEF29F88C6B004C165C /* BlurHashEncode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEB29F88C6B004C165C /* BlurHashEncode.swift */; };
|
||||
4C198DF029F88C6B004C165C /* Readme.md in Resources */ = {isa = PBXBuildFile; fileRef = 4C198DEC29F88C6B004C165C /* Readme.md */; };
|
||||
4C198DF129F88C6B004C165C /* License.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4C198DED29F88C6B004C165C /* License.txt */; };
|
||||
4C198DF229F88C6B004C165C /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */; };
|
||||
4C198DF529F88D2E004C165C /* ImageMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DF429F88D2E004C165C /* ImageMetadata.swift */; };
|
||||
4C198DF829F89323004C165C /* BinaryParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DF729F89323004C165C /* BinaryParser.swift */; };
|
||||
4C1A9A1A29DCA17E00516EAC /* ReplyCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A1929DCA17E00516EAC /* ReplyCounter.swift */; };
|
||||
4C1A9A1D29DDCF9B00516EAC /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A1C29DDCF9B00516EAC /* NotificationSettingsView.swift */; };
|
||||
4C1A9A1F29DDD24B00516EAC /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A1E29DDD24B00516EAC /* AppearanceSettingsView.swift */; };
|
||||
@@ -121,7 +127,6 @@
|
||||
4C5F9118283D88E40052CD1C /* FollowingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5F9117283D88E40052CD1C /* FollowingModel.swift */; };
|
||||
4C633350283D40E500B1C9C3 /* HomeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C63334F283D40E500B1C9C3 /* HomeModel.swift */; };
|
||||
4C633352283D419F00B1C9C3 /* SignalModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C633351283D419F00B1C9C3 /* SignalModel.swift */; };
|
||||
4C649844285A952100EAE2B3 /* LocalUserConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C649843285A952100EAE2B3 /* LocalUserConfig.swift */; };
|
||||
4C64987C286D03E000EAE2B3 /* DirectMessagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C64987B286D03E000EAE2B3 /* DirectMessagesView.swift */; };
|
||||
4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C64987D286D082C00EAE2B3 /* DirectMessagesModel.swift */; };
|
||||
4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 4C649880286E0EE300EAE2B3 /* secp256k1 */; };
|
||||
@@ -155,6 +160,7 @@
|
||||
4C9F18E229AA9B6C008C55EC /* CustomizeZapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C9F18E129AA9B6C008C55EC /* CustomizeZapView.swift */; };
|
||||
4C9F18E429ABDE6D008C55EC /* MaybeAnonPfpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C9F18E329ABDE6D008C55EC /* MaybeAnonPfpView.swift */; };
|
||||
4CA2EFA0280E37AC0044ACD8 /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA2EF9F280E37AC0044ACD8 /* TimelineView.swift */; };
|
||||
4CA3FA1029F593D000FDB3C3 /* ZapTypePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA3FA0F29F593D000FDB3C3 /* ZapTypePicker.swift */; };
|
||||
4CA5588329F33F5B00DC6A45 /* StringCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA5588229F33F5B00DC6A45 /* StringCodable.swift */; };
|
||||
4CAAD8AD298851D000060CEA /* AccountDeletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CAAD8AC298851D000060CEA /* AccountDeletion.swift */; };
|
||||
4CAAD8B029888AD200060CEA /* RelayConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CAAD8AF29888AD200060CEA /* RelayConfigView.swift */; };
|
||||
@@ -198,6 +204,9 @@
|
||||
4CE0E2AF29A2E82100DB4CA2 /* EventHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE0E2AE29A2E82100DB4CA2 /* EventHolder.swift */; };
|
||||
4CE0E2B229A3DF6900DB4CA2 /* LoadMoreButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE0E2B129A3DF6900DB4CA2 /* LoadMoreButton.swift */; };
|
||||
4CE0E2B629A3ED5500DB4CA2 /* InnerTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE0E2B529A3ED5500DB4CA2 /* InnerTimelineView.swift */; };
|
||||
4CE1399029F0661A00AC6A0B /* RepostAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE1398F29F0661A00AC6A0B /* RepostAction.swift */; };
|
||||
4CE1399229F0666100AC6A0B /* ShareActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE1399129F0666100AC6A0B /* ShareActionButton.swift */; };
|
||||
4CE1399429F0669900AC6A0B /* BigButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE1399329F0669900AC6A0B /* BigButton.swift */; };
|
||||
4CE4F0F229D4FCFA005914DB /* DebouncedOnChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F0F129D4FCFA005914DB /* DebouncedOnChange.swift */; };
|
||||
4CE4F0F429D779B5005914DB /* PostBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F0F329D779B5005914DB /* PostBox.swift */; };
|
||||
4CE4F0F829DB7399005914DB /* ThiccDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F0F729DB7399005914DB /* ThiccDivider.swift */; };
|
||||
@@ -212,7 +221,6 @@
|
||||
4CE6DEF827F7A08200C66700 /* damusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE6DEF727F7A08200C66700 /* damusTests.swift */; };
|
||||
4CE6DF0227F7A08200C66700 /* damusUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE6DF0127F7A08200C66700 /* damusUITests.swift */; };
|
||||
4CE6DF0427F7A08200C66700 /* damusUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE6DF0327F7A08200C66700 /* damusUITestsLaunchTests.swift */; };
|
||||
4CE6DF1227F7A2B300C66700 /* Starscream in Frameworks */ = {isa = PBXBuildFile; productRef = 4CE6DF1127F7A2B300C66700 /* Starscream */; };
|
||||
4CE6DF1627F8DEBF00C66700 /* RelayConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE6DF1527F8DEBF00C66700 /* RelayConnection.swift */; };
|
||||
4CE8794829941DA700F758CC /* RelayFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE8794729941DA700F758CC /* RelayFilters.swift */; };
|
||||
4CE8794C2995B59E00F758CC /* RelayMetadatas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE8794B2995B59E00F758CC /* RelayMetadatas.swift */; };
|
||||
@@ -249,7 +257,9 @@
|
||||
4CFF8F6B29CD0079008DB934 /* RepostedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6A29CD0079008DB934 /* RepostedEvent.swift */; };
|
||||
4CFF8F6D29CD022E008DB934 /* WideEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6C29CD022E008DB934 /* WideEventView.swift */; };
|
||||
4FE60CDD295E1C5E00105A1F /* Wallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FE60CDC295E1C5E00105A1F /* Wallet.swift */; };
|
||||
50088DA129E8271A008A1FDF /* WebSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50088DA029E8271A008A1FDF /* WebSocket.swift */; };
|
||||
50A50A8D29A09E1C00C01BE7 /* RequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */; };
|
||||
50B5685329F97CB400A23243 /* CredentialHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B5685229F97CB400A23243 /* CredentialHandler.swift */; };
|
||||
5C42E78C29DB76D90086AAC1 /* EmptyUserSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */; };
|
||||
5C513FBA297F72980072348F /* CustomPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C513FB9297F72980072348F /* CustomPicker.swift */; };
|
||||
5C513FCC2984ACA60072348F /* QRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C513FCB2984ACA60072348F /* QRCodeView.swift */; };
|
||||
@@ -413,6 +423,12 @@
|
||||
4C0A3F8E280F640A000448DE /* ThreadModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadModel.swift; sourceTree = "<group>"; };
|
||||
4C0A3F90280F6528000448DE /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
4C0A3F92280F66F5000448DE /* ReplyMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyMap.swift; sourceTree = "<group>"; };
|
||||
4C198DEB29F88C6B004C165C /* BlurHashEncode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = "<group>"; };
|
||||
4C198DEC29F88C6B004C165C /* Readme.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = Readme.md; sourceTree = "<group>"; };
|
||||
4C198DED29F88C6B004C165C /* License.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = License.txt; sourceTree = "<group>"; };
|
||||
4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
|
||||
4C198DF429F88D2E004C165C /* ImageMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageMetadata.swift; sourceTree = "<group>"; };
|
||||
4C198DF729F89323004C165C /* BinaryParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryParser.swift; sourceTree = "<group>"; };
|
||||
4C1A9A1929DCA17E00516EAC /* ReplyCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyCounter.swift; sourceTree = "<group>"; };
|
||||
4C1A9A1C29DDCF9B00516EAC /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = "<group>"; };
|
||||
4C1A9A1E29DDD24B00516EAC /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = "<group>"; };
|
||||
@@ -526,7 +542,6 @@
|
||||
4C5F9117283D88E40052CD1C /* FollowingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingModel.swift; sourceTree = "<group>"; };
|
||||
4C63334F283D40E500B1C9C3 /* HomeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeModel.swift; sourceTree = "<group>"; };
|
||||
4C633351283D419F00B1C9C3 /* SignalModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalModel.swift; sourceTree = "<group>"; };
|
||||
4C649843285A952100EAE2B3 /* LocalUserConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalUserConfig.swift; sourceTree = "<group>"; };
|
||||
4C64987B286D03E000EAE2B3 /* DirectMessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessagesView.swift; sourceTree = "<group>"; };
|
||||
4C64987D286D082C00EAE2B3 /* DirectMessagesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessagesModel.swift; sourceTree = "<group>"; };
|
||||
4C75EFA327FA577B0006080F /* PostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostView.swift; sourceTree = "<group>"; };
|
||||
@@ -564,6 +579,7 @@
|
||||
4C9F18E129AA9B6C008C55EC /* CustomizeZapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeZapView.swift; sourceTree = "<group>"; };
|
||||
4C9F18E329ABDE6D008C55EC /* MaybeAnonPfpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaybeAnonPfpView.swift; sourceTree = "<group>"; };
|
||||
4CA2EF9F280E37AC0044ACD8 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = "<group>"; };
|
||||
4CA3FA0F29F593D000FDB3C3 /* ZapTypePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapTypePicker.swift; sourceTree = "<group>"; };
|
||||
4CA5588229F33F5B00DC6A45 /* StringCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringCodable.swift; sourceTree = "<group>"; };
|
||||
4CAAD8AC298851D000060CEA /* AccountDeletion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletion.swift; sourceTree = "<group>"; };
|
||||
4CAAD8AF29888AD200060CEA /* RelayConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayConfigView.swift; sourceTree = "<group>"; };
|
||||
@@ -607,6 +623,9 @@
|
||||
4CE0E2AE29A2E82100DB4CA2 /* EventHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventHolder.swift; sourceTree = "<group>"; };
|
||||
4CE0E2B129A3DF6900DB4CA2 /* LoadMoreButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreButton.swift; sourceTree = "<group>"; };
|
||||
4CE0E2B529A3ED5500DB4CA2 /* InnerTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InnerTimelineView.swift; sourceTree = "<group>"; };
|
||||
4CE1398F29F0661A00AC6A0B /* RepostAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostAction.swift; sourceTree = "<group>"; };
|
||||
4CE1399129F0666100AC6A0B /* ShareActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareActionButton.swift; sourceTree = "<group>"; };
|
||||
4CE1399329F0669900AC6A0B /* BigButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigButton.swift; sourceTree = "<group>"; };
|
||||
4CE4F0F129D4FCFA005914DB /* DebouncedOnChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebouncedOnChange.swift; sourceTree = "<group>"; };
|
||||
4CE4F0F329D779B5005914DB /* PostBox.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostBox.swift; sourceTree = "<group>"; };
|
||||
4CE4F0F729DB7399005914DB /* ThiccDivider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThiccDivider.swift; sourceTree = "<group>"; };
|
||||
@@ -661,7 +680,9 @@
|
||||
4CFF8F6A29CD0079008DB934 /* RepostedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepostedEvent.swift; sourceTree = "<group>"; };
|
||||
4CFF8F6C29CD022E008DB934 /* WideEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WideEventView.swift; sourceTree = "<group>"; };
|
||||
4FE60CDC295E1C5E00105A1F /* Wallet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Wallet.swift; sourceTree = "<group>"; };
|
||||
50088DA029E8271A008A1FDF /* WebSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocket.swift; sourceTree = "<group>"; };
|
||||
50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestTests.swift; sourceTree = "<group>"; };
|
||||
50B5685229F97CB400A23243 /* CredentialHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialHandler.swift; sourceTree = "<group>"; };
|
||||
5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyUserSearchView.swift; sourceTree = "<group>"; };
|
||||
5C513FB9297F72980072348F /* CustomPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPicker.swift; sourceTree = "<group>"; };
|
||||
5C513FCB2984ACA60072348F /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = "<group>"; };
|
||||
@@ -700,7 +721,6 @@
|
||||
files = (
|
||||
4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */,
|
||||
6C7DE41F2955169800E66263 /* Vault in Frameworks */,
|
||||
4CE6DF1227F7A2B300C66700 /* Starscream in Frameworks */,
|
||||
4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -829,7 +849,6 @@
|
||||
4C5F9117283D88E40052CD1C /* FollowingModel.swift */,
|
||||
4C987B56283FD07F0042CE38 /* FollowersModel.swift */,
|
||||
4C5C7E67284ED36500A22DF5 /* SearchHomeModel.swift */,
|
||||
4C649843285A952100EAE2B3 /* LocalUserConfig.swift */,
|
||||
4C64987D286D082C00EAE2B3 /* DirectMessagesModel.swift */,
|
||||
4C216F372871EDE300040376 /* DirectMessageModel.swift */,
|
||||
4C99737A28C92A9200E53835 /* ChatroomMetadata.swift */,
|
||||
@@ -850,6 +869,33 @@
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4C198DEA29F88C6B004C165C /* BlurHash */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4C198DEB29F88C6B004C165C /* BlurHashEncode.swift */,
|
||||
4C198DEC29F88C6B004C165C /* Readme.md */,
|
||||
4C198DED29F88C6B004C165C /* License.txt */,
|
||||
4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */,
|
||||
);
|
||||
path = BlurHash;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4C198DF329F88D23004C165C /* Images */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4C198DF429F88D2E004C165C /* ImageMetadata.swift */,
|
||||
);
|
||||
path = Images;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4C198DF629F89317004C165C /* Parser */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4C198DF729F89323004C165C /* BinaryParser.swift */,
|
||||
);
|
||||
path = Parser;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4C1A9A1B29DDCF8B00516EAC /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -965,6 +1011,7 @@
|
||||
4CACA9DB280C38C000D9BBE8 /* Profiles.swift */,
|
||||
4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */,
|
||||
4C363A8F28247A1D006E126D /* NostrLink.swift */,
|
||||
50088DA029E8271A008A1FDF /* WebSocket.swift */,
|
||||
);
|
||||
path = Nostr;
|
||||
sourceTree = "<group>";
|
||||
@@ -972,6 +1019,9 @@
|
||||
4C7FF7D628233637009601DB /* Util */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4C198DF629F89317004C165C /* Parser */,
|
||||
4C198DF329F88D23004C165C /* Images */,
|
||||
4C198DEA29F88C6B004C165C /* BlurHash */,
|
||||
4CE4F0F329D779B5005914DB /* PostBox.swift */,
|
||||
7C0F392D29B57C8F0039859C /* Extensions */,
|
||||
4CE879492995B58700F758CC /* Relays */,
|
||||
@@ -1011,6 +1061,7 @@
|
||||
4C8D00CB29DF92DF0036AF10 /* Hashtags.swift */,
|
||||
4CDA128B29EB19C40006FA5A /* LocalNotification.swift */,
|
||||
4CA5588229F33F5B00DC6A45 /* StringCodable.swift */,
|
||||
50B5685229F97CB400A23243 /* CredentialHandler.swift */,
|
||||
);
|
||||
path = Util;
|
||||
sourceTree = "<group>";
|
||||
@@ -1045,6 +1096,9 @@
|
||||
4CEE2B01280B39E800AB5EEF /* EventActionBar.swift */,
|
||||
4CB88388296AF99A00DC99E7 /* EventDetailBar.swift */,
|
||||
5CF72FC129B9142F00124A13 /* ShareAction.swift */,
|
||||
4CE1398F29F0661A00AC6A0B /* RepostAction.swift */,
|
||||
4CE1399129F0666100AC6A0B /* ShareActionButton.swift */,
|
||||
4CE1399329F0669900AC6A0B /* BigButton.swift */,
|
||||
);
|
||||
path = ActionBar;
|
||||
sourceTree = "<group>";
|
||||
@@ -1253,6 +1307,7 @@
|
||||
children = (
|
||||
4CE879572996C45300F758CC /* ZapsView.swift */,
|
||||
4C9F18E129AA9B6C008C55EC /* CustomizeZapView.swift */,
|
||||
4CA3FA0F29F593D000FDB3C3 /* ZapTypePicker.swift */,
|
||||
);
|
||||
path = Zaps;
|
||||
sourceTree = "<group>";
|
||||
@@ -1336,7 +1391,6 @@
|
||||
);
|
||||
name = damus;
|
||||
packageProductDependencies = (
|
||||
4CE6DF1127F7A2B300C66700 /* Starscream */,
|
||||
4C649880286E0EE300EAE2B3 /* secp256k1 */,
|
||||
4C06670328FC7EC500038D2A /* Kingfisher */,
|
||||
6C7DE41E2955169800E66263 /* Vault */,
|
||||
@@ -1442,7 +1496,6 @@
|
||||
);
|
||||
mainGroup = 4CE6DEDA27F7A08100C66700;
|
||||
packageReferences = (
|
||||
4CE6DF1027F7A2B300C66700 /* XCRemoteSwiftPackageReference "Starscream" */,
|
||||
4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */,
|
||||
4C06670228FC7EC500038D2A /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||
6C7DE41D2955169800E66263 /* XCRemoteSwiftPackageReference "Vault" */,
|
||||
@@ -1467,6 +1520,8 @@
|
||||
4CE6DEEE27F7A08200C66700 /* Preview Assets.xcassets in Resources */,
|
||||
3ACB685C297633BC00C46468 /* InfoPlist.strings in Resources */,
|
||||
4CE6DEEB27F7A08200C66700 /* Assets.xcassets in Resources */,
|
||||
4C198DF129F88C6B004C165C /* License.txt in Resources */,
|
||||
4C198DF029F88C6B004C165C /* Readme.md in Resources */,
|
||||
3A4325A82961E11400BFCD9D /* Localizable.stringsdict in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -1531,6 +1586,7 @@
|
||||
4C75EFB92804A2740006080F /* EventView.swift in Sources */,
|
||||
4C8D00C829DF791C0036AF10 /* CompatibleAttribute.swift in Sources */,
|
||||
3AA247FD297E3CFF0090C62D /* RepostsModel.swift in Sources */,
|
||||
4C198DF229F88C6B004C165C /* BlurHashDecode.swift in Sources */,
|
||||
F75BA12F29A18EF500E10810 /* BookmarksView.swift in Sources */,
|
||||
4CB883B6297730E400DC99E7 /* LNUrls.swift in Sources */,
|
||||
4C7FF7D52823313F009601DB /* Mentions.swift in Sources */,
|
||||
@@ -1561,7 +1617,6 @@
|
||||
4C8D00CA29DF80350036AF10 /* TruncatedText.swift in Sources */,
|
||||
4C9BB83429C12D9900FC4E37 /* EventProfileName.swift in Sources */,
|
||||
4CB8838F296F781C00DC99E7 /* ReactionsView.swift in Sources */,
|
||||
4C649844285A952100EAE2B3 /* LocalUserConfig.swift in Sources */,
|
||||
4C75EFB328049D640006080F /* NostrEvent.swift in Sources */,
|
||||
4CA2EFA0280E37AC0044ACD8 /* TimelineView.swift in Sources */,
|
||||
4C30AC7629A5770900E2BD5A /* NotificationItemView.swift in Sources */,
|
||||
@@ -1573,6 +1628,7 @@
|
||||
4CFF8F6729CC9E3A008DB934 /* ImageView.swift in Sources */,
|
||||
4C90BD18283A9EE5008EE7EF /* LoginView.swift in Sources */,
|
||||
4CB8838B296F6E1E00DC99E7 /* NIP05Badge.swift in Sources */,
|
||||
4CA3FA1029F593D000FDB3C3 /* ZapTypePicker.swift in Sources */,
|
||||
4C3EA66828FF5F9900C48A62 /* hex.c in Sources */,
|
||||
E9E4ED0B295867B900DD7078 /* ThreadView.swift in Sources */,
|
||||
4CD348EF29C3659D00497EB2 /* ImageUploadModel.swift in Sources */,
|
||||
@@ -1593,6 +1649,7 @@
|
||||
4CE8794829941DA700F758CC /* RelayFilters.swift in Sources */,
|
||||
4CEE2B02280B39E800AB5EEF /* EventActionBar.swift in Sources */,
|
||||
4C3BEFE0281DE1ED00B3DE84 /* DamusState.swift in Sources */,
|
||||
4C198DF529F88D2E004C165C /* ImageMetadata.swift in Sources */,
|
||||
4CCEB7AE29B53D260078AA28 /* SearchingEventView.swift in Sources */,
|
||||
4CF0ABE929844AF100D66079 /* AnyCodable.swift in Sources */,
|
||||
4C0A3F8F280F640A000448DE /* ThreadModel.swift in Sources */,
|
||||
@@ -1636,16 +1693,21 @@
|
||||
4C06670628FCB08600038D2A /* ImageCarousel.swift in Sources */,
|
||||
F79C7FAD29D5E9620000F946 /* EditProfilePictureControl.swift in Sources */,
|
||||
4C9F18E229AA9B6C008C55EC /* CustomizeZapView.swift in Sources */,
|
||||
4C198DF829F89323004C165C /* BinaryParser.swift in Sources */,
|
||||
4C75EFAF28049D350006080F /* NostrFilter.swift in Sources */,
|
||||
4C3EA64C28FF59AC00C48A62 /* bech32_util.c in Sources */,
|
||||
4CE1399029F0661A00AC6A0B /* RepostAction.swift in Sources */,
|
||||
4CE1399229F0666100AC6A0B /* ShareActionButton.swift in Sources */,
|
||||
4C42812C298C848200DBF26F /* TranslateView.swift in Sources */,
|
||||
4C363A9C282838B9006E126D /* EventRef.swift in Sources */,
|
||||
3AA24802297E3DC20090C62D /* RepostView.swift in Sources */,
|
||||
4CD7641B28A1641400B6928F /* EndBlock.swift in Sources */,
|
||||
4C3EA66528FF5F6800C48A62 /* mem.c in Sources */,
|
||||
4C198DEF29F88C6B004C165C /* BlurHashEncode.swift in Sources */,
|
||||
4CF0ABE52981EE0C00D66079 /* EULAView.swift in Sources */,
|
||||
4CBCA930297DB57F00EC6B2F /* WebsiteLink.swift in Sources */,
|
||||
4CAAD8B029888AD200060CEA /* RelayConfigView.swift in Sources */,
|
||||
50088DA129E8271A008A1FDF /* WebSocket.swift in Sources */,
|
||||
4C3EA64128FF553900C48A62 /* hash_u5.c in Sources */,
|
||||
5C513FCC2984ACA60072348F /* QRCodeView.swift in Sources */,
|
||||
4C3EA64F28FF59F200C48A62 /* tal.c in Sources */,
|
||||
@@ -1662,6 +1724,7 @@
|
||||
4C30AC8029A6A53F00E2BD5A /* ProfilePicturesView.swift in Sources */,
|
||||
4C8EC52529D1FA6C0085D9A8 /* DamusColors.swift in Sources */,
|
||||
4C5C7E6A284EDE2E00A22DF5 /* SearchResultsView.swift in Sources */,
|
||||
4CE1399429F0669900AC6A0B /* BigButton.swift in Sources */,
|
||||
7C60CAEF298471A1009C80D6 /* CoreSVG.swift in Sources */,
|
||||
6439E014296790CF0020672B /* ProfilePicImageView.swift in Sources */,
|
||||
4CE6DF1627F8DEBF00C66700 /* RelayConnection.swift in Sources */,
|
||||
@@ -1701,6 +1764,7 @@
|
||||
4CF0ABEC29844B4700D66079 /* AnyDecodable.swift in Sources */,
|
||||
4C5F9118283D88E40052CD1C /* FollowingModel.swift in Sources */,
|
||||
4C1A9A1A29DCA17E00516EAC /* ReplyCounter.swift in Sources */,
|
||||
50B5685329F97CB400A23243 /* CredentialHandler.swift in Sources */,
|
||||
643EA5C8296B764E005081BB /* RelayFilterView.swift in Sources */,
|
||||
4C3EA67D28FFBBA300C48A62 /* InvoicesView.swift in Sources */,
|
||||
4C363A8E28236FE4006E126D /* NoteContentView.swift in Sources */,
|
||||
@@ -2024,7 +2088,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = XK7H4JAB3D;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -2071,7 +2135,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = XK7H4JAB3D;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -2243,14 +2307,6 @@
|
||||
revision = 40b4b38b3b1c83f7088c76189a742870e0ca06a9;
|
||||
};
|
||||
};
|
||||
4CE6DF1027F7A2B300C66700 /* XCRemoteSwiftPackageReference "Starscream" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/daltoniam/Starscream";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 4.0.0;
|
||||
};
|
||||
};
|
||||
6C7DE41D2955169800E66263 /* XCRemoteSwiftPackageReference "Vault" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/SparrowTek/Vault";
|
||||
@@ -2272,11 +2328,6 @@
|
||||
package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */;
|
||||
productName = secp256k1;
|
||||
};
|
||||
4CE6DF1127F7A2B300C66700 /* Starscream */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 4CE6DF1027F7A2B300C66700 /* XCRemoteSwiftPackageReference "Starscream" */;
|
||||
productName = Starscream;
|
||||
};
|
||||
6C7DE41E2955169800E66263 /* Vault */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 6C7DE41D2955169800E66263 /* XCRemoteSwiftPackageReference "Vault" */;
|
||||
|
||||
@@ -17,15 +17,6 @@
|
||||
"revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "starscream",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/daltoniam/Starscream",
|
||||
"state" : {
|
||||
"revision" : "df8d82047f6654d8e4b655d1b1525c64e1059d21",
|
||||
"version" : "4.0.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "vault",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -37,28 +37,53 @@ enum ImageShape {
|
||||
case landscape
|
||||
case portrait
|
||||
case unknown
|
||||
|
||||
static func determine_image_shape(_ size: CGSize) -> ImageShape {
|
||||
guard size.height > 0 else {
|
||||
return .unknown
|
||||
}
|
||||
let imageRatio = size.width / size.height
|
||||
switch imageRatio {
|
||||
case 1.0: return .square
|
||||
case ..<1.0: return .portrait
|
||||
case 1.0...: return .landscape
|
||||
default: return .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try either calculated imagefill from the real image or from metadata hints in tags
|
||||
func lookup_imgmeta_size_hint(events: EventCache, url: URL?) -> CGSize? {
|
||||
guard let url,
|
||||
let meta = events.lookup_img_metadata(url: url),
|
||||
let img_size = meta.meta.dim?.size else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return img_size
|
||||
}
|
||||
|
||||
struct ImageCarousel: View {
|
||||
var urls: [URL]
|
||||
|
||||
let evid: String
|
||||
let previews: PreviewCache
|
||||
|
||||
let state: DamusState
|
||||
|
||||
@State private var open_sheet: Bool = false
|
||||
@State private var current_url: URL? = nil
|
||||
@State private var image_fill: ImageFill? = nil
|
||||
@State private var fillHeight: CGFloat = 350
|
||||
@State private var maxHeight: CGFloat = UIScreen.main.bounds.height * 0.85
|
||||
|
||||
init(previews: PreviewCache, evid: String, urls: [URL]) {
|
||||
let fillHeight: CGFloat = 350
|
||||
let maxHeight: CGFloat = UIScreen.main.bounds.height * 1.2
|
||||
|
||||
init(state: DamusState, evid: String, urls: [URL]) {
|
||||
_open_sheet = State(initialValue: false)
|
||||
_current_url = State(initialValue: nil)
|
||||
_image_fill = State(initialValue: previews.lookup_image_meta(evid))
|
||||
_image_fill = State(initialValue: state.previews.lookup_image_meta(evid))
|
||||
self.urls = urls
|
||||
self.evid = evid
|
||||
self.previews = previews
|
||||
self.state = state
|
||||
}
|
||||
|
||||
var filling: Bool {
|
||||
@@ -66,41 +91,70 @@ struct ImageCarousel: View {
|
||||
}
|
||||
|
||||
var height: CGFloat {
|
||||
image_fill?.height ?? 100
|
||||
image_fill?.height ?? fillHeight
|
||||
}
|
||||
|
||||
func Placeholder(url: URL, geo_size: CGSize) -> some View {
|
||||
Group {
|
||||
if let meta = state.events.lookup_img_metadata(url: url),
|
||||
case .processed(let blurhash) = meta.state {
|
||||
Image(uiImage: blurhash)
|
||||
.resizable()
|
||||
.frame(width: geo_size.width * UIScreen.main.scale, height: self.height * UIScreen.main.scale)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if self.image_fill == nil,
|
||||
let meta = state.events.lookup_img_metadata(url: url),
|
||||
let size = meta.meta.dim?.size
|
||||
{
|
||||
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: size, maxHeight: maxHeight, fillHeight: fillHeight)
|
||||
self.image_fill = fill
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
ForEach(urls, id: \.absoluteString) { url in
|
||||
Rectangle()
|
||||
.foregroundColor(Color.clear)
|
||||
.overlay {
|
||||
GeometryReader { geo in
|
||||
KFAnimatedImage(url)
|
||||
.callbackQueue(.dispatch(.global(qos:.background)))
|
||||
.backgroundDecode(true)
|
||||
.imageContext(.note)
|
||||
.cancelOnDisappear(true)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.imageFill(for: geo.size, max: maxHeight, fill: fillHeight) { fill in
|
||||
previews.cache_image_meta(evid: evid, image_fill: fill)
|
||||
image_fill = fill
|
||||
}
|
||||
.aspectRatio(contentMode: filling ? .fill : .fit)
|
||||
.tabItem {
|
||||
Text(url.absoluteString)
|
||||
}
|
||||
.id(url.absoluteString)
|
||||
GeometryReader { geo in
|
||||
KFAnimatedImage(url)
|
||||
.callbackQueue(.dispatch(.global(qos:.background)))
|
||||
.backgroundDecode(true)
|
||||
.imageContext(.note, disable_animation: state.settings.disable_animation)
|
||||
.image_fade(duration: 0.25)
|
||||
.cancelOnDisappear(true)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
}
|
||||
.imageFill(for: geo.size, max: maxHeight, fill: fillHeight) { fill in
|
||||
state.previews.cache_image_meta(evid: evid, image_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
|
||||
}
|
||||
image_fill = fill
|
||||
}
|
||||
.background {
|
||||
Placeholder(url: url, geo_size: geo.size)
|
||||
}
|
||||
.aspectRatio(contentMode: filling ? .fill : .fit)
|
||||
.tabItem {
|
||||
Text(url.absoluteString)
|
||||
}
|
||||
.id(url.absoluteString)
|
||||
}
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $open_sheet) {
|
||||
ImageView(urls: urls)
|
||||
ImageView(urls: urls, disable_animation: state.settings.disable_animation)
|
||||
}
|
||||
.frame(height: height)
|
||||
.frame(height: self.height)
|
||||
.onTapGesture {
|
||||
open_sheet = true
|
||||
}
|
||||
@@ -134,25 +188,14 @@ public struct ImageFill {
|
||||
let filling: Bool?
|
||||
let height: CGFloat
|
||||
|
||||
|
||||
static func determine_image_shape(_ size: CGSize) -> ImageShape {
|
||||
guard size.height > 0 else {
|
||||
return .unknown
|
||||
}
|
||||
let imageRatio = size.width / size.height
|
||||
switch imageRatio {
|
||||
case 1.0: return .square
|
||||
case ..<1.0: return .portrait
|
||||
case 1.0...: return .landscape
|
||||
default: return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
static func calculate_image_fill(geo_size: CGSize, img_size: CGSize, maxHeight: CGFloat, fillHeight: CGFloat) -> ImageFill {
|
||||
let shape = determine_image_shape(img_size)
|
||||
let shape = ImageShape.determine_image_shape(img_size)
|
||||
|
||||
let xfactor = geo_size.width / img_size.width
|
||||
let scaled = img_size.height * xfactor
|
||||
|
||||
//print("calc_img_fill \(img_size.width)x\(img_size.height) xfactor:\(xfactor) scaled:\(scaled)")
|
||||
|
||||
// calculate scaled image height
|
||||
// set scale factor and constrain images to minimum 150
|
||||
// and animations to scaled factor for dynamic size adjustment
|
||||
@@ -169,7 +212,7 @@ public struct ImageFill {
|
||||
|
||||
struct ImageCarousel_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ImageCarousel(previews: test_damus_state().previews, evid: "evid", urls: [URL(string: "https://jb55.com/red-me.jpg")!,URL(string: "https://jb55.com/red-me.jpg")!])
|
||||
ImageCarousel(state: test_damus_state(), evid: "evid", urls: [URL(string: "https://jb55.com/red-me.jpg")!,URL(string: "https://jb55.com/red-me.jpg")!])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ struct InvoiceView: View {
|
||||
let invoice: Invoice
|
||||
@State var showing_select_wallet: Bool = false
|
||||
@State var copied = false
|
||||
let settings: UserSettingsStore
|
||||
|
||||
var CopyButton: some View {
|
||||
Button {
|
||||
@@ -36,10 +37,10 @@ struct InvoiceView: View {
|
||||
|
||||
var PayButton: some View {
|
||||
Button {
|
||||
if should_show_wallet_selector(our_pubkey) {
|
||||
if settings.show_wallet_selector {
|
||||
showing_select_wallet = true
|
||||
} else {
|
||||
open_with_wallet(wallet: get_default_wallet(our_pubkey).model, invoice: invoice.string)
|
||||
open_with_wallet(wallet: settings.default_wallet.model, invoice: invoice.string)
|
||||
}
|
||||
} label: {
|
||||
RoundedRectangle(cornerRadius: 20, style: .circular)
|
||||
@@ -80,7 +81,7 @@ struct InvoiceView: View {
|
||||
.padding(30)
|
||||
}
|
||||
.sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) {
|
||||
SelectWalletView(showingSelectWallet: $showing_select_wallet, our_pubkey: our_pubkey, invoice: invoice.string)
|
||||
SelectWalletView(default_wallet: settings.default_wallet, showingSelectWallet: $showing_select_wallet, our_pubkey: our_pubkey, invoice: invoice.string)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,7 +112,8 @@ let test_invoice = Invoice(description: .description("this is a description"), a
|
||||
|
||||
struct InvoiceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
InvoiceView(our_pubkey: "", invoice: test_invoice)
|
||||
InvoiceView(our_pubkey: "", invoice: test_invoice, settings: test_damus_state().settings)
|
||||
.frame(width: 300, height: 200)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import SwiftUI
|
||||
struct InvoicesView: View {
|
||||
let our_pubkey: String
|
||||
var invoices: [Invoice]
|
||||
let settings: UserSettingsStore
|
||||
|
||||
@State var open_sheet: Bool = false
|
||||
@State var current_invoice: Invoice? = nil
|
||||
@@ -17,7 +18,7 @@ struct InvoicesView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
ForEach(invoices, id: \.string) { invoice in
|
||||
InvoiceView(our_pubkey: our_pubkey, invoice: invoice)
|
||||
InvoiceView(our_pubkey: our_pubkey, invoice: invoice, settings: settings)
|
||||
.tabItem {
|
||||
Text(invoice.string)
|
||||
}
|
||||
@@ -31,7 +32,7 @@ struct InvoicesView: View {
|
||||
|
||||
struct InvoicesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
InvoicesView(our_pubkey: "", invoices: [Invoice.init(description: .description("description"), amount: .specific(10000), string: "invstr", expiry: 100000, payment_hash: Data(), created_at: 1000000)])
|
||||
InvoicesView(our_pubkey: "", invoices: [Invoice.init(description: .description("description"), amount: .specific(10000), string: "invstr", expiry: 100000, payment_hash: Data(), created_at: 1000000)], settings: test_damus_state().settings)
|
||||
.frame(width: 300)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ struct Translated: Equatable {
|
||||
|
||||
enum TranslateStatus: Equatable {
|
||||
case havent_tried
|
||||
case trying
|
||||
case translating
|
||||
case translated(Translated)
|
||||
case not_needed
|
||||
@@ -26,40 +25,19 @@ struct TranslateView: View {
|
||||
let damus_state: DamusState
|
||||
let event: NostrEvent
|
||||
let size: EventViewKind
|
||||
let currentLanguage: String
|
||||
|
||||
@State var translated: TranslateStatus
|
||||
@ObservedObject var translations_model: TranslationModel
|
||||
|
||||
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind) {
|
||||
self.damus_state = damus_state
|
||||
self.event = event
|
||||
self.size = size
|
||||
|
||||
if #available(iOS 16, *) {
|
||||
self.currentLanguage = Locale.current.language.languageCode?.identifier ?? "en"
|
||||
} else {
|
||||
self.currentLanguage = Locale.current.languageCode ?? "en"
|
||||
}
|
||||
|
||||
if damus_state.pubkey == event.pubkey && damus_state.is_privkey_user {
|
||||
// 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.
|
||||
// Offering a translation in this case is definitely incorrect so let's avoid it altogether.
|
||||
self._translated = State(initialValue: .not_needed)
|
||||
} else if let cached = damus_state.events.lookup_translated_artifacts(evid: event.id) {
|
||||
self._translated = State(initialValue: cached)
|
||||
} else {
|
||||
let initval: TranslateStatus = self.damus_state.settings.auto_translate ? .trying : .havent_tried
|
||||
self._translated = State(initialValue: initval)
|
||||
}
|
||||
self._translations_model = ObservedObject(wrappedValue: damus_state.events.get_cache_data(event.id).translations_model)
|
||||
}
|
||||
|
||||
let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
|
||||
|
||||
var TranslateButton: some View {
|
||||
Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) {
|
||||
self.translated = .trying
|
||||
translate()
|
||||
}
|
||||
.translate_button_style()
|
||||
}
|
||||
@@ -80,78 +58,43 @@ struct TranslateView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func failed_attempt() {
|
||||
DispatchQueue.main.async {
|
||||
self.translated = .not_needed
|
||||
damus_state.events.store_translation_artifacts(evid: event.id, translated: .not_needed)
|
||||
func translate() {
|
||||
Task {
|
||||
guard let note_language = translations_model.note_language else {
|
||||
return
|
||||
}
|
||||
let res = await translate_note(profiles: damus_state.profiles, privkey: damus_state.keypair.privkey, event: event, settings: damus_state.settings, note_lang: note_language)
|
||||
DispatchQueue.main.async {
|
||||
self.translations_model.state = res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attempt_translation() async {
|
||||
guard case .trying = translated else {
|
||||
func attempt_translation() {
|
||||
guard should_translate(event: event, our_keypair: damus_state.keypair, settings: damus_state.settings, note_lang: self.translations_model.note_language), damus_state.settings.auto_translate else {
|
||||
return
|
||||
}
|
||||
|
||||
guard damus_state.settings.can_translate(damus_state.pubkey) else {
|
||||
return
|
||||
}
|
||||
|
||||
let note_lang = event.note_language(damus_state.keypair.privkey) ?? currentLanguage
|
||||
|
||||
// Don't translate if its in our preferred languages
|
||||
guard !preferredLanguages.contains(note_lang) else {
|
||||
failed_attempt()
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.translated = .translating
|
||||
}
|
||||
|
||||
// If the note language is different from our preferred languages, send a translation request.
|
||||
let translator = Translator(damus_state.settings)
|
||||
let originalContent = event.get_content(damus_state.keypair.privkey)
|
||||
let translated_note = try? await translator.translate(originalContent, from: note_lang, to: currentLanguage)
|
||||
|
||||
guard let translated_note else {
|
||||
// if its the same, give up and don't retry
|
||||
failed_attempt()
|
||||
return
|
||||
}
|
||||
|
||||
guard originalContent != translated_note else {
|
||||
// if its the same, give up and don't retry
|
||||
failed_attempt()
|
||||
return
|
||||
}
|
||||
|
||||
// Render translated note
|
||||
let translated_blocks = event.get_blocks(content: translated_note)
|
||||
let artifacts = render_blocks(blocks: translated_blocks, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey)
|
||||
|
||||
// and cache it
|
||||
DispatchQueue.main.async {
|
||||
self.translated = .translated(Translated(artifacts: artifacts, language: note_lang))
|
||||
damus_state.events.store_translation_artifacts(evid: event.id, translated: self.translated)
|
||||
}
|
||||
translate()
|
||||
}
|
||||
|
||||
func should_transl(_ note_lang: String) -> Bool {
|
||||
should_translate(event: event, our_keypair: damus_state.keypair, settings: damus_state.settings, note_lang: note_lang)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch translated {
|
||||
switch self.translations_model.state {
|
||||
case .havent_tried:
|
||||
if damus_state.settings.auto_translate {
|
||||
Text("")
|
||||
} else if let note_lang = translations_model.note_language, should_transl(note_lang) {
|
||||
TranslateButton
|
||||
} else {
|
||||
TranslateButton
|
||||
Text("")
|
||||
}
|
||||
case .trying:
|
||||
Text("")
|
||||
case .translating:
|
||||
Text("Translating...", comment: "Text to display when waiting for the translation of a note to finish processing before showing it.")
|
||||
.foregroundColor(.gray)
|
||||
.font(.footnote)
|
||||
.padding([.top, .bottom], 10)
|
||||
Text("")
|
||||
case .translated(let translated):
|
||||
let languageName = Locale.current.localizedString(forLanguageCode: translated.language)
|
||||
TranslatedView(lang: languageName, artifacts: translated.artifacts)
|
||||
@@ -159,17 +102,8 @@ struct TranslateView: View {
|
||||
Text("")
|
||||
}
|
||||
}
|
||||
.onChange(of: translated) { val in
|
||||
guard case .trying = translated else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await attempt_translation()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await attempt_translation()
|
||||
attempt_translation()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,3 +123,37 @@ struct TranslateView_Previews: PreviewProvider {
|
||||
TranslateView(damus_state: ds, event: test_event, size: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
func translate_note(profiles: Profiles, privkey: String?, event: NostrEvent, settings: UserSettingsStore, note_lang: String) async -> TranslateStatus {
|
||||
|
||||
// If the note language is different from our preferred languages, send a translation request.
|
||||
let translator = Translator(settings)
|
||||
let originalContent = event.get_content(privkey)
|
||||
let translated_note = try? await translator.translate(originalContent, from: note_lang, to: current_language())
|
||||
|
||||
guard let translated_note else {
|
||||
// if its the same, give up and don't retry
|
||||
return .not_needed
|
||||
}
|
||||
|
||||
guard originalContent != translated_note else {
|
||||
// if its the same, give up and don't retry
|
||||
return .not_needed
|
||||
}
|
||||
|
||||
// Render translated note
|
||||
let translated_blocks = event.get_blocks(content: translated_note)
|
||||
let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles, privkey: privkey)
|
||||
|
||||
// and cache it
|
||||
return .translated(Translated(artifacts: artifacts, language: note_lang))
|
||||
}
|
||||
|
||||
func current_language() -> String {
|
||||
if #available(iOS 16, *) {
|
||||
return Locale.current.language.languageCode?.identifier ?? "en"
|
||||
} else {
|
||||
return Locale.current.languageCode ?? "en"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ struct UserView: View {
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
ProfilePicView(pubkey: pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles)
|
||||
ProfilePicView(pubkey: pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
let profile = damus_state.profiles.lookup(id: pubkey)
|
||||
|
||||
@@ -47,7 +47,7 @@ struct ZapButton: View {
|
||||
return "bolt"
|
||||
}
|
||||
|
||||
return "bolt.horizontal.fill"
|
||||
return "bolt.fill"
|
||||
}
|
||||
|
||||
var zap_color: Color? {
|
||||
@@ -86,7 +86,7 @@ struct ZapButton: View {
|
||||
return
|
||||
}
|
||||
|
||||
send_zap(damus_state: damus_state, event: event, lnurl: lnurl, is_custom: false, comment: nil, amount_sats: nil, zap_type: ZapType.pub)
|
||||
send_zap(damus_state: damus_state, event: event, lnurl: lnurl, is_custom: false, comment: nil, amount_sats: nil, zap_type: damus_state.settings.default_zap_type)
|
||||
self.zapping = true
|
||||
})
|
||||
.accessibilityLabel(NSLocalizedString("Zap", comment: "Accessibility label for zap button"))
|
||||
@@ -101,7 +101,7 @@ struct ZapButton: View {
|
||||
CustomizeZapView(state: damus_state, event: event, lnurl: lnurl)
|
||||
}
|
||||
.sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) {
|
||||
SelectWalletView(showingSelectWallet: $showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: invoice)
|
||||
SelectWalletView(default_wallet: damus_state.settings.default_wallet, showingSelectWallet: $showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: invoice)
|
||||
}
|
||||
.onReceive(handle_notify(.zapping)) { notif in
|
||||
let zap_ev = notif.object as! ZappingEvent
|
||||
@@ -118,11 +118,12 @@ struct ZapButton: View {
|
||||
case .failed:
|
||||
break
|
||||
case .got_zap_invoice(let inv):
|
||||
if should_show_wallet_selector(damus_state.pubkey) {
|
||||
if damus_state.settings.show_wallet_selector {
|
||||
self.invoice = inv
|
||||
self.showing_select_wallet = true
|
||||
} else {
|
||||
open_with_wallet(wallet: get_default_wallet(damus_state.pubkey).model, invoice: inv)
|
||||
let wallet = damus_state.settings.default_wallet.model
|
||||
open_with_wallet(wallet: wallet, invoice: inv)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +174,7 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust
|
||||
damus_state.lnurls.endpoints[target.pubkey] = payreq
|
||||
}
|
||||
|
||||
let zap_amount = amount_sats ?? get_default_zap_amount(pubkey: damus_state.pubkey)
|
||||
let zap_amount = amount_sats ?? damus_state.settings.default_zap_amount
|
||||
|
||||
guard let inv = await fetch_zap_invoice(payreq, zapreq: zapreq, sats: zap_amount, zap_type: zap_type, comment: comment) else {
|
||||
DispatchQueue.main.async {
|
||||
|
||||
+9
-80
@@ -6,7 +6,6 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Starscream
|
||||
|
||||
struct TimestampedProfile {
|
||||
let profile: Profile
|
||||
@@ -63,7 +62,7 @@ struct ContentView: View {
|
||||
@State var status: String = "Not connected"
|
||||
@State var active_sheet: Sheets? = nil
|
||||
@State var damus_state: DamusState? = nil
|
||||
@State var selected_timeline: Timeline? = .home
|
||||
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home
|
||||
@State var is_deleted_account: Bool = false
|
||||
@State var is_profile_open: Bool = false
|
||||
@State var event: NostrEvent? = nil
|
||||
@@ -77,11 +76,9 @@ struct ContentView: View {
|
||||
@State var confirm_mute: Bool = false
|
||||
@State var user_muted_confirm: Bool = false
|
||||
@State var confirm_overwrite_mutelist: Bool = false
|
||||
@State var current_boost: NostrEvent? = nil
|
||||
@State var filter_state : FilterState = .posts_and_replies
|
||||
@SceneStorage("ContentView.filter_state") var filter_state : FilterState = .posts_and_replies
|
||||
@State private var isSideBarOpened = false
|
||||
@StateObject var home: HomeModel = HomeModel()
|
||||
@State var shouldShowBoostAlert = false
|
||||
|
||||
// connect retry timer
|
||||
let timer = Timer.publish(every: 4, on: .main, in: .common).autoconnect()
|
||||
@@ -183,9 +180,6 @@ struct ContentView: View {
|
||||
|
||||
case .dms:
|
||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
|
||||
|
||||
case .none:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.navigationBarTitle(timeline_name(selected_timeline), displayMode: .inline)
|
||||
@@ -212,7 +206,7 @@ struct ContentView: View {
|
||||
var MaybeSearchView: some View {
|
||||
Group {
|
||||
if let search = self.active_search {
|
||||
SearchView(appstate: damus_state!, search: SearchModel(contacts: damus_state!.contacts, pool: damus_state!.pool, search: search))
|
||||
SearchView(appstate: damus_state!, search: SearchModel(state: damus_state!, search: search))
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
@@ -261,7 +255,7 @@ struct ContentView: View {
|
||||
Button {
|
||||
isSideBarOpened.toggle()
|
||||
} label: {
|
||||
ProfilePicView(pubkey: damus_state!.pubkey, size: 32, highlight: .none, profiles: damus_state!.profiles)
|
||||
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)
|
||||
}
|
||||
@@ -314,7 +308,7 @@ struct ContentView: View {
|
||||
case .event:
|
||||
EventDetailView()
|
||||
case .filter:
|
||||
let timeline = selected_timeline ?? .home
|
||||
let timeline = selected_timeline
|
||||
if #available(iOS 16.0, *) {
|
||||
RelayFilterView(state: damus_state!, timeline: timeline)
|
||||
.presentationDetents([.height(550)])
|
||||
@@ -349,19 +343,9 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
}
|
||||
.onReceive(handle_notify(.boost)) { notif in
|
||||
guard let ev = notif.object as? NostrEvent else {
|
||||
return
|
||||
}
|
||||
|
||||
current_boost = ev
|
||||
shouldShowBoostAlert = true
|
||||
}
|
||||
.onReceive(handle_notify(.reply)) { notif in
|
||||
let ev = notif.object as! NostrEvent
|
||||
self.active_sheet = .post(.replying_to(ev))
|
||||
}
|
||||
.onReceive(handle_notify(.like)) { like in
|
||||
.onReceive(handle_notify(.compose)) { notif in
|
||||
let action = notif.object as! PostAction
|
||||
self.active_sheet = .post(action)
|
||||
}
|
||||
.onReceive(handle_notify(.deleted_account)) { notif in
|
||||
self.is_deleted_account = true
|
||||
@@ -605,36 +589,6 @@ struct ContentView: View {
|
||||
Text("Could not find user to mute...", comment: "Alert message to indicate that the muted user could not be found.")
|
||||
}
|
||||
})
|
||||
.confirmationDialog("Repost", isPresented: $shouldShowBoostAlert) {
|
||||
Button(NSLocalizedString("Repost", comment: "Title of alert for confirming to repost a post.")) {
|
||||
guard let current_boost else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let privkey = self.damus_state?.keypair.privkey else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let damus_state else {
|
||||
return
|
||||
}
|
||||
|
||||
let boost = make_boost_event(pubkey: damus_state.keypair.pubkey, privkey: privkey, boosted: current_boost)
|
||||
damus_state.postbox.send(boost)
|
||||
}
|
||||
|
||||
Button(NSLocalizedString("Quote", comment: "Title of alert for confirming to make a quoted post.")) {
|
||||
guard let current_boost else {
|
||||
return
|
||||
}
|
||||
self.active_sheet = .post(.quoting(current_boost))
|
||||
}
|
||||
}
|
||||
.onChange(of: shouldShowBoostAlert) { v in
|
||||
if v == false {
|
||||
self.current_boost = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func switch_timeline(_ timeline: Timeline) {
|
||||
@@ -672,7 +626,7 @@ struct ContentView: View {
|
||||
|
||||
let new_relay_filters = load_relay_filters(pubkey) == nil
|
||||
for relay in bootstrap_relays {
|
||||
if let url = URL(string: relay) {
|
||||
if let url = RelayURL(relay) {
|
||||
add_new_relay(relay_filters: relay_filters, metadatas: metadatas, pool: pool, url: url, info: .rw, new_relay_filters: new_relay_filters)
|
||||
}
|
||||
}
|
||||
@@ -728,31 +682,6 @@ func get_since_time(last_event: NostrEvent?) -> Int64? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ws_nostr_event(relay: String, ev: WebSocketEvent) -> NostrEvent? {
|
||||
switch ev {
|
||||
case .binary(let dat):
|
||||
return NostrEvent(content: "binary data? \(dat.count) bytes", pubkey: relay)
|
||||
case .cancelled:
|
||||
return NostrEvent(content: "cancelled", pubkey: relay)
|
||||
case .connected:
|
||||
return NostrEvent(content: "connected", pubkey: relay)
|
||||
case .disconnected:
|
||||
return NostrEvent(content: "disconnected", pubkey: relay)
|
||||
case .error(let err):
|
||||
return NostrEvent(content: "error \(err.debugDescription)", pubkey: relay)
|
||||
case .text(let txt):
|
||||
return NostrEvent(content: "text \(txt)", pubkey: relay)
|
||||
case .pong:
|
||||
return NostrEvent(content: "pong", pubkey: relay)
|
||||
case .ping:
|
||||
return NostrEvent(content: "ping", pubkey: relay)
|
||||
case .viabilityChanged(let b):
|
||||
return NostrEvent(content: "viabilityChanged \(b)", pubkey: relay)
|
||||
case .reconnectSuggested(let b):
|
||||
return NostrEvent(content: "reconnectSuggested \(b)", pubkey: relay)
|
||||
}
|
||||
}
|
||||
|
||||
func is_notification(ev: NostrEvent, pubkey: String) -> Bool {
|
||||
if ev.pubkey == pubkey {
|
||||
return false
|
||||
|
||||
@@ -23,6 +23,18 @@ class ActionBarModel: ObservableObject {
|
||||
return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil)
|
||||
}
|
||||
|
||||
init() {
|
||||
self.our_like = nil
|
||||
self.our_boost = nil
|
||||
self.our_reply = nil
|
||||
self.our_zap = nil
|
||||
self.likes = 0
|
||||
self.boosts = 0
|
||||
self.zaps = 0
|
||||
self.zap_total = 0
|
||||
self.replies = 0
|
||||
}
|
||||
|
||||
init(likes: Int, boosts: Int, zaps: Int, zap_total: Int64, replies: Int, our_like: NostrEvent?, our_boost: NostrEvent?, our_zap: Zap?, our_reply: NostrEvent?) {
|
||||
self.likes = likes
|
||||
self.boosts = boosts
|
||||
|
||||
@@ -19,7 +19,7 @@ func load_bookmarks(pubkey: String) -> [NostrEvent] {
|
||||
}
|
||||
|
||||
func save_bookmarks(pubkey: String, current_value: [NostrEvent], value: [NostrEvent]) -> Bool {
|
||||
let uniq_bookmarks = Array(Set(value))
|
||||
let uniq_bookmarks = uniq(value)
|
||||
|
||||
if uniq_bookmarks != current_value {
|
||||
let encoded = uniq_bookmarks.map(event_to_json)
|
||||
|
||||
@@ -242,7 +242,7 @@ func follow_with_existing_contacts(our_pubkey: String, our_contacts: NostrEvent,
|
||||
|
||||
func make_contact_relays(_ relays: [RelayDescriptor]) -> [String: RelayInfo] {
|
||||
return relays.reduce(into: [:]) { acc, relay in
|
||||
acc[relay.url.absoluteString] = relay.info
|
||||
acc[relay.url.url.absoluteString] = relay.info
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,14 @@ struct DamusState {
|
||||
let replies: ReplyCounter
|
||||
let muted_threads: MutedThreadsManager
|
||||
|
||||
@discardableResult
|
||||
func add_zap(zap: Zap) -> Bool {
|
||||
// store generic zap mapping
|
||||
self.zaps.add_zap(zap: zap)
|
||||
// associate with events as well
|
||||
return self.events.store_zap(zap: zap)
|
||||
}
|
||||
|
||||
var pubkey: String {
|
||||
return keypair.pubkey
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ class HomeModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
damus_state.zaps.add_zap(zap: zap)
|
||||
damus_state.add_zap(zap: zap)
|
||||
|
||||
guard zap.target.pubkey == our_keypair.pubkey else {
|
||||
return
|
||||
@@ -229,16 +229,22 @@ class HomeModel: ObservableObject {
|
||||
func handle_boost_event(sub_id: String, _ ev: NostrEvent) {
|
||||
var boost_ev_id = ev.last_refid()?.ref_id
|
||||
|
||||
if let inner_ev = ev.inner_event {
|
||||
if let inner_ev = ev.get_inner_event(cache: damus_state.events) {
|
||||
boost_ev_id = inner_ev.id
|
||||
|
||||
guard validate_event(ev: inner_ev) == .ok else {
|
||||
return
|
||||
|
||||
Task.init {
|
||||
guard validate_event(ev: inner_ev) == .ok else {
|
||||
return
|
||||
}
|
||||
|
||||
if inner_ev.is_textlike {
|
||||
DispatchQueue.main.async {
|
||||
self.handle_text_event(sub_id: sub_id, ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if inner_ev.is_textlike {
|
||||
handle_text_event(sub_id: sub_id, ev)
|
||||
}
|
||||
}
|
||||
|
||||
guard let e = boost_ev_id else {
|
||||
@@ -271,8 +277,8 @@ class HomeModel: ObservableObject {
|
||||
case .success(let n):
|
||||
handle_notification(ev: ev)
|
||||
let liked = Counted(event: ev, id: e.ref_id, total: n)
|
||||
notify(.liked, liked)
|
||||
notify(.update_stats, e.ref_id)
|
||||
//notify(.liked, liked)
|
||||
//notify(.update_stats, e.ref_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,17 +296,12 @@ class HomeModel: ObservableObject {
|
||||
send_home_filters(relay_id: relay_id)
|
||||
}
|
||||
case .error(let merr):
|
||||
let desc = merr.debugDescription
|
||||
let desc = String(describing: merr)
|
||||
if desc.contains("Software caused connection abort") {
|
||||
pool.reconnect(to: [relay_id])
|
||||
}
|
||||
case .disconnected: fallthrough
|
||||
case .cancelled:
|
||||
case .disconnected:
|
||||
pool.reconnect(to: [relay_id])
|
||||
case .reconnectSuggested(let t):
|
||||
if t {
|
||||
pool.reconnect(to: [relay_id])
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -310,11 +311,13 @@ class HomeModel: ObservableObject {
|
||||
switch ev {
|
||||
case .event(let sub_id, let ev):
|
||||
// globally handle likes
|
||||
/*
|
||||
let always_process = sub_id == notifications_subid || sub_id == contacts_subid || sub_id == home_subid || sub_id == dms_subid || sub_id == init_subid || ev.known_kind == .like || ev.known_kind == .boost || ev.known_kind == .zap || ev.known_kind == .contacts || ev.known_kind == .metadata
|
||||
if !always_process {
|
||||
// TODO: other views like threads might have their own sub ids, so ignore those events... or should we?
|
||||
return
|
||||
}
|
||||
*/
|
||||
|
||||
self.process_event(sub_id: sub_id, relay_id: relay_id, ev: ev)
|
||||
case .notice(let msg):
|
||||
@@ -488,7 +491,7 @@ class HomeModel: ObservableObject {
|
||||
|
||||
damus_state.events.insert(ev)
|
||||
|
||||
if let inner_ev = ev.inner_event {
|
||||
if let inner_ev = ev.get_inner_event(cache: damus_state.events) {
|
||||
damus_state.events.insert(inner_ev)
|
||||
}
|
||||
|
||||
@@ -524,6 +527,8 @@ class HomeModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: will we need to process this in other places like zap request contents, etc?
|
||||
process_image_metadata(cache: damus_state.events, ev: ev)
|
||||
damus_state.replies.count_replies(ev)
|
||||
damus_state.events.insert(ev)
|
||||
|
||||
@@ -690,6 +695,7 @@ func process_metadata_profile(our_pubkey: String, profiles: Profiles, profile: P
|
||||
profiles.add(id: ev.pubkey, profile: tprof)
|
||||
|
||||
if let nip05 = profile.nip05, old_nip05 != profile.nip05 {
|
||||
|
||||
Task.detached(priority: .background) {
|
||||
let validated = await validate_nip05(pubkey: ev.pubkey, nip05_str: nip05)
|
||||
if validated != nil {
|
||||
@@ -705,17 +711,22 @@ func process_metadata_profile(our_pubkey: String, profiles: Profiles, profile: P
|
||||
}
|
||||
|
||||
// load pfps asap
|
||||
|
||||
var changed = false
|
||||
|
||||
let picture = tprof.profile.picture ?? robohash(ev.pubkey)
|
||||
if URL(string: picture) != nil {
|
||||
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
|
||||
changed = true
|
||||
}
|
||||
|
||||
let banner = tprof.profile.banner ?? ""
|
||||
if URL(string: banner) != nil {
|
||||
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
|
||||
changed = true
|
||||
}
|
||||
|
||||
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
|
||||
if changed {
|
||||
notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
|
||||
}
|
||||
}
|
||||
|
||||
func guard_valid_event(events: EventCache, ev: NostrEvent, callback: @escaping () -> Void) {
|
||||
@@ -727,7 +738,7 @@ func guard_valid_event(events: EventCache, ev: NostrEvent, callback: @escaping (
|
||||
let result = validate_event(ev: ev)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
events.validation[ev.id] = result
|
||||
events.store_event_validation(evid: ev.id, validated: result)
|
||||
guard result == .ok else {
|
||||
return
|
||||
}
|
||||
@@ -751,6 +762,8 @@ func process_metadata_event(events: EventCache, our_pubkey: String, profiles: Pr
|
||||
return
|
||||
}
|
||||
|
||||
profile.cache_lnurl()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
process_metadata_profile(our_pubkey: our_pubkey, profiles: profiles, profile: profile, ev: ev)
|
||||
}
|
||||
@@ -814,7 +827,7 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
|
||||
for d in diff {
|
||||
changed = true
|
||||
if new.contains(d) {
|
||||
if let url = URL(string: d) {
|
||||
if let url = RelayURL(d) {
|
||||
add_new_relay(relay_filters: state.relay_filters, metadatas: state.relay_metadata, pool: state.pool, url: url, info: decoded[d] ?? .rw, new_relay_filters: new_relay_filters)
|
||||
}
|
||||
} else {
|
||||
@@ -828,10 +841,10 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
func add_new_relay(relay_filters: RelayFilters, metadatas: RelayMetadatas, pool: RelayPool, url: URL, info: RelayInfo, new_relay_filters: Bool) {
|
||||
func add_new_relay(relay_filters: RelayFilters, metadatas: RelayMetadatas, pool: RelayPool, url: RelayURL, info: RelayInfo, new_relay_filters: Bool) {
|
||||
try? pool.add_relay(url, info: info)
|
||||
|
||||
let relay_id = url.absoluteString
|
||||
let relay_id = url.id
|
||||
guard metadatas.lookup(relay_id: relay_id) == nil else {
|
||||
return
|
||||
}
|
||||
@@ -937,8 +950,13 @@ func handle_incoming_dms(prev_events: NewEventsBits, dms: DirectMessagesModel, o
|
||||
}
|
||||
|
||||
if inserted {
|
||||
dms.dms = dms.dms.filter({ $0.events.count > 0 }).sorted { a, b in
|
||||
return a.events.last!.created_at > b.events.last!.created_at
|
||||
Task.init {
|
||||
let new_dms = Array(dms.dms.filter({ $0.events.count > 0 })).sorted { a, b in
|
||||
return a.events.last!.created_at > b.events.last!.created_at
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
dms.dms = new_dms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1104,11 +1122,11 @@ func process_local_notification(damus_state: DamusState, event ev: NostrEvent) {
|
||||
let notify = LocalNotification(type: .mention, event: ev, target: ev, content: content)
|
||||
create_local_notification(profiles: damus_state.profiles, notify: notify )
|
||||
}
|
||||
} else if type == .boost && damus_state.settings.repost_notification, let inner_ev = ev.inner_event {
|
||||
} else if type == .boost && damus_state.settings.repost_notification, let inner_ev = ev.get_inner_event(cache: damus_state.events) {
|
||||
let notify = LocalNotification(type: .repost, event: ev, target: inner_ev, content: inner_ev.content)
|
||||
create_local_notification(profiles: damus_state.profiles, notify: notify)
|
||||
} else if type == .like && damus_state.settings.like_notification,
|
||||
let evid = ev.referenced_ids.first?.ref_id,
|
||||
let evid = ev.referenced_ids.last?.ref_id,
|
||||
let liked_event = damus_state.events.lookup(evid)
|
||||
{
|
||||
let notify = LocalNotification(type: .like, event: ev, target: liked_event, content: liked_event.content)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
//
|
||||
// LocalUserConfig.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-06-15.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
struct LocalUserConfig: Codable {
|
||||
let relays: [RelayDescriptor]
|
||||
}
|
||||
|
||||
@@ -682,7 +682,7 @@ func make_post_tags(post_blocks: [PostBlock], tags: [[String]], silent_mentions:
|
||||
}
|
||||
|
||||
func post_to_event(post: NostrPost, privkey: String, pubkey: String) -> NostrEvent {
|
||||
let tags = post.references.map(refid_to_tag)
|
||||
let tags = post.references.map(refid_to_tag) + post.tags
|
||||
let post_blocks = parse_post_blocks(content: post.content)
|
||||
let post_tags = make_post_tags(post_blocks: post_blocks, tags: tags, silent_mentions: false)
|
||||
let content = render_blocks(blocks: post_tags.blocks)
|
||||
|
||||
@@ -110,6 +110,7 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
||||
var reposts: [String: EventGroup]
|
||||
var replies: [NostrEvent]
|
||||
var has_reply: Set<String>
|
||||
var has_ev: Set<String>
|
||||
|
||||
@Published var notifications: [NotificationItem]
|
||||
|
||||
@@ -124,6 +125,7 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
||||
self.incoming_events = []
|
||||
self.profile_zaps = ZapGroup()
|
||||
self.notifications = []
|
||||
self.has_ev = Set()
|
||||
}
|
||||
|
||||
func set_should_queue(_ val: Bool) {
|
||||
@@ -192,8 +194,8 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
||||
}
|
||||
|
||||
|
||||
private func insert_repost(_ ev: NostrEvent) -> Bool {
|
||||
guard let reposted_ev = ev.inner_event else {
|
||||
private func insert_repost(_ ev: NostrEvent, cache: EventCache) -> Bool {
|
||||
guard let reposted_ev = ev.get_inner_event(cache: cache) else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -235,9 +237,9 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
||||
}
|
||||
}
|
||||
|
||||
private func insert_event_immediate(_ ev: NostrEvent) -> Bool {
|
||||
private func insert_event_immediate(_ ev: NostrEvent, cache: EventCache) -> Bool {
|
||||
if ev.known_kind == .boost {
|
||||
return insert_repost(ev)
|
||||
return insert_repost(ev, cache: cache)
|
||||
} else if ev.known_kind == .like {
|
||||
return insert_reaction(ev)
|
||||
} else if ev.known_kind == .text {
|
||||
@@ -265,11 +267,17 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
||||
}
|
||||
|
||||
func insert_event(_ ev: NostrEvent, damus_state: DamusState) -> Bool {
|
||||
if should_queue {
|
||||
return insert_uniq_sorted_event_created(events: &incoming_events, new_ev: ev)
|
||||
if has_ev.contains(ev.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
if insert_event_immediate(ev) {
|
||||
if should_queue {
|
||||
incoming_events.append(ev)
|
||||
has_ev.insert(ev.id)
|
||||
return true
|
||||
}
|
||||
|
||||
if insert_event_immediate(ev, cache: damus_state.events) {
|
||||
self.notifications = build_notifications()
|
||||
return true
|
||||
}
|
||||
@@ -339,7 +347,7 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
||||
}
|
||||
|
||||
for event in incoming_events {
|
||||
inserted = insert_event_immediate(event) || inserted
|
||||
inserted = insert_event_immediate(event, cache: damus_state.events) || inserted
|
||||
}
|
||||
|
||||
if inserted {
|
||||
|
||||
@@ -11,17 +11,17 @@ struct NostrPost {
|
||||
let kind: NostrKind
|
||||
let content: String
|
||||
let references: [ReferencedId]
|
||||
let tags: [[String]]
|
||||
|
||||
init (content: String, references: [ReferencedId]) {
|
||||
self.content = content
|
||||
self.references = references
|
||||
self.kind = .text
|
||||
}
|
||||
|
||||
init (content: String, references: [ReferencedId], kind: NostrKind) {
|
||||
init (content: String, references: [ReferencedId], kind: NostrKind = .text, tags: [[String]] = []) {
|
||||
self.content = content
|
||||
self.references = references
|
||||
self.kind = kind
|
||||
self.tags = tags
|
||||
}
|
||||
|
||||
func to_event(keypair: FullKeypair) -> NostrEvent {
|
||||
return post_to_event(post: self, privkey: keypair.privkey, pubkey: keypair.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,10 +101,10 @@ func find_profiles_to_fetch_pk(profiles: Profiles, event_pubkeys: [String]) -> [
|
||||
return Array(pubkeys)
|
||||
}
|
||||
|
||||
func find_profiles_to_fetch(profiles: Profiles, load: PubkeysToLoad) -> [String] {
|
||||
func find_profiles_to_fetch(profiles: Profiles, load: PubkeysToLoad, cache: EventCache) -> [String] {
|
||||
switch load {
|
||||
case .from_events(let events):
|
||||
return find_profiles_to_fetch_from_events(profiles: profiles, events: events)
|
||||
return find_profiles_to_fetch_from_events(profiles: profiles, events: events, cache: cache)
|
||||
case .from_keys(let pks):
|
||||
return find_profiles_to_fetch_from_keys(profiles: profiles, pks: pks)
|
||||
}
|
||||
@@ -124,12 +124,12 @@ func find_profiles_to_fetch_from_keys(profiles: Profiles, pks: [String]) -> [Str
|
||||
return Array(pubkeys)
|
||||
}
|
||||
|
||||
func find_profiles_to_fetch_from_events(profiles: Profiles, events: [NostrEvent]) -> [String] {
|
||||
func find_profiles_to_fetch_from_events(profiles: Profiles, events: [NostrEvent], cache: EventCache) -> [String] {
|
||||
var pubkeys = Set<String>()
|
||||
|
||||
for ev in events {
|
||||
// lookup profiles from boosted events
|
||||
if ev.known_kind == .boost, let bev = ev.inner_event, profiles.lookup(id: bev.pubkey) == nil {
|
||||
if ev.known_kind == .boost, let bev = ev.get_inner_event(cache: cache), profiles.lookup(id: bev.pubkey) == nil {
|
||||
pubkeys.insert(bev.pubkey)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ enum PubkeysToLoad {
|
||||
|
||||
func load_profiles(profiles_subid: String, relay_id: String, load: PubkeysToLoad, damus_state: DamusState) {
|
||||
var filter = NostrFilter.filter_profiles
|
||||
let authors = find_profiles_to_fetch(profiles: damus_state.profiles, load: load)
|
||||
let authors = find_profiles_to_fetch(profiles: damus_state.profiles, load: load, cache: damus_state.events)
|
||||
filter.authors = authors
|
||||
|
||||
guard !authors.isEmpty else {
|
||||
|
||||
@@ -9,24 +9,23 @@ import Foundation
|
||||
|
||||
|
||||
class SearchModel: ObservableObject {
|
||||
let state: DamusState
|
||||
var events: EventHolder = EventHolder()
|
||||
@Published var loading: Bool = false
|
||||
@Published var channel_name: String? = nil
|
||||
|
||||
let pool: RelayPool
|
||||
var search: NostrFilter
|
||||
let contacts: Contacts
|
||||
let sub_id = UUID().description
|
||||
let profiles_subid = UUID().description
|
||||
let limit: UInt32 = 500
|
||||
|
||||
init(contacts: Contacts, pool: RelayPool, search: NostrFilter) {
|
||||
self.contacts = contacts
|
||||
self.pool = pool
|
||||
init(state: DamusState, search: NostrFilter) {
|
||||
self.state = state
|
||||
self.search = search
|
||||
}
|
||||
|
||||
func filter_muted() {
|
||||
self.events.filter { should_show_event(contacts: contacts, ev: $0) }
|
||||
self.events.filter { should_show_event(contacts: state.contacts, ev: $0) }
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
|
||||
@@ -38,13 +37,13 @@ class SearchModel: ObservableObject {
|
||||
//likes_filter.ids = ref_events.referenced_ids!
|
||||
|
||||
print("subscribing to search '\(search)' with sub_id \(sub_id)")
|
||||
pool.register_handler(sub_id: sub_id, handler: handle_event)
|
||||
state.pool.register_handler(sub_id: sub_id, handler: handle_event)
|
||||
loading = true
|
||||
pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
|
||||
state.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
self.pool.unsubscribe(sub_id: sub_id)
|
||||
state.pool.unsubscribe(sub_id: sub_id)
|
||||
loading = false
|
||||
print("unsubscribing from search '\(search)' with sub_id \(sub_id)")
|
||||
}
|
||||
@@ -54,7 +53,7 @@ class SearchModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
guard should_show_event(contacts: contacts, ev: ev) else {
|
||||
guard should_show_event(contacts: state.contacts, ev: ev) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -74,7 +73,7 @@ class SearchModel: ObservableObject {
|
||||
}
|
||||
|
||||
func handle_event(relay_id: String, ev: NostrConnectionEvent) {
|
||||
let (_, done) = handle_subid_event(pool: pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||
let (sub_id, done) = handle_subid_event(pool: state.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||
if ev.is_textlike && ev.should_show_event {
|
||||
self.add_event(ev)
|
||||
} else if ev.known_kind == .channel_create {
|
||||
@@ -84,8 +83,14 @@ class SearchModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
if done {
|
||||
loading = false
|
||||
guard done else {
|
||||
return
|
||||
}
|
||||
|
||||
self.loading = false
|
||||
|
||||
if sub_id == self.sub_id {
|
||||
load_profiles(profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(self.events.all_events), damus_state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable {
|
||||
case none
|
||||
case libretranslate
|
||||
case deepl
|
||||
case nokyctranslate
|
||||
|
||||
var model: Model {
|
||||
switch self {
|
||||
@@ -40,6 +41,8 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable {
|
||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("LibreTranslate (Open Source)", comment: "Dropdown option for selecting LibreTranslate as the translation service."))
|
||||
case .deepl:
|
||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("DeepL (Proprietary, Higher Accuracy)", comment: "Dropdown option for selecting DeepL as the translation service."))
|
||||
case .nokyctranslate:
|
||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("NoKYCTranslate.com (Prepay with BTC)", comment: "Dropdown option for selecting NoKYCTranslate.com as the translation service."))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import Foundation
|
||||
import Vault
|
||||
import UIKit
|
||||
|
||||
let fallback_zap_amount = 1000
|
||||
|
||||
@propertyWrapper struct Setting<T: Equatable> {
|
||||
private let key: String
|
||||
private var value: T
|
||||
@@ -92,8 +94,14 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "zap_notification", default_value: true)
|
||||
var zap_notification: Bool
|
||||
|
||||
@Setting(key: "default_zap_amount", default_value: fallback_zap_amount)
|
||||
var default_zap_amount: Int
|
||||
|
||||
@Setting(key: "mention_notification", default_value: true)
|
||||
var mention_notification: Bool
|
||||
|
||||
@StringSetting(key: "zap_type", default_value: ZapType.pub)
|
||||
var default_zap_type: ZapType
|
||||
|
||||
@Setting(key: "repost_notification", default_value: true)
|
||||
var repost_notification: Bool
|
||||
@@ -130,12 +138,20 @@ class UserSettingsStore: ObservableObject {
|
||||
|
||||
@Setting(key: "disable_animation", default_value: UIAccessibility.isReduceMotionEnabled)
|
||||
var disable_animation: Bool
|
||||
|
||||
// Helper for inverse of disable_animation.
|
||||
// disable_animation was introduced as a setting first, but it's more natural for the settings UI to show the inverse.
|
||||
var enable_animation: Bool {
|
||||
get {
|
||||
!disable_animation
|
||||
}
|
||||
set {
|
||||
disable_animation = !newValue
|
||||
}
|
||||
}
|
||||
|
||||
@StringSetting(key: "friend_filter", default_value: .all)
|
||||
var friend_filter: FriendFilter
|
||||
|
||||
@StringSetting(key: "notification_state", default_value: .all)
|
||||
var notification_state: NotificationFilterState
|
||||
|
||||
@StringSetting(key: "translation_service", default_value: .none)
|
||||
var translation_service: TranslationService
|
||||
@@ -177,6 +193,20 @@ class UserSettingsStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var nokyctranslate_api_key: String {
|
||||
didSet {
|
||||
do {
|
||||
if nokyctranslate_api_key == "" {
|
||||
try clearNoKYCTranslateApiKey()
|
||||
} else {
|
||||
try saveNoKYCTranslateApiKey(nokyctranslate_api_key)
|
||||
}
|
||||
} catch {
|
||||
// No-op.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
do {
|
||||
@@ -184,6 +214,13 @@ class UserSettingsStore: ObservableObject {
|
||||
} catch {
|
||||
deepl_api_key = ""
|
||||
}
|
||||
|
||||
do {
|
||||
nokyctranslate_api_key = try Vault.getPrivateKey(keychainConfiguration: DamusNoKYCTranslateKeychainConfiguration())
|
||||
} catch {
|
||||
nokyctranslate_api_key = ""
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func saveLibreTranslateApiKey(_ apiKey: String) throws {
|
||||
@@ -194,6 +231,14 @@ class UserSettingsStore: ObservableObject {
|
||||
try Vault.deletePrivateKey(keychainConfiguration: DamusLibreTranslateKeychainConfiguration())
|
||||
}
|
||||
|
||||
private func saveNoKYCTranslateApiKey(_ apiKey: String) throws {
|
||||
try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusNoKYCTranslateKeychainConfiguration())
|
||||
}
|
||||
|
||||
private func clearNoKYCTranslateApiKey() throws {
|
||||
try Vault.deletePrivateKey(keychainConfiguration: DamusNoKYCTranslateKeychainConfiguration())
|
||||
}
|
||||
|
||||
private func saveDeepLApiKey(_ apiKey: String) throws {
|
||||
try Vault.savePrivateKey(apiKey, keychainConfiguration: DamusDeepLKeychainConfiguration())
|
||||
}
|
||||
@@ -202,7 +247,7 @@ class UserSettingsStore: ObservableObject {
|
||||
try Vault.deletePrivateKey(keychainConfiguration: DamusDeepLKeychainConfiguration())
|
||||
}
|
||||
|
||||
func can_translate(_ pubkey: String) -> Bool {
|
||||
var can_translate: Bool {
|
||||
switch translation_service {
|
||||
case .none:
|
||||
return false
|
||||
@@ -210,6 +255,8 @@ class UserSettingsStore: ObservableObject {
|
||||
return URLComponents(string: libretranslate_url) != nil
|
||||
case .deepl:
|
||||
return deepl_api_key != ""
|
||||
case .nokyctranslate:
|
||||
return nokyctranslate_api_key != ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,78 +273,12 @@ struct DamusDeepLKeychainConfiguration: KeychainConfiguration {
|
||||
var accountName = "deepl_apikey"
|
||||
}
|
||||
|
||||
func should_show_wallet_selector(_ pubkey: String) -> Bool {
|
||||
return UserDefaults.standard.object(forKey: "show_wallet_selector") as? Bool ?? true
|
||||
struct DamusNoKYCTranslateKeychainConfiguration: KeychainConfiguration {
|
||||
var serviceName = "damus"
|
||||
var accessGroup: String? = nil
|
||||
var accountName = "nokyctranslate_apikey"
|
||||
}
|
||||
|
||||
func pk_setting_key(_ pubkey: String, key: String) -> String {
|
||||
return "\(pubkey)_\(key)"
|
||||
}
|
||||
|
||||
func default_zap_setting_key(pubkey: String) -> String {
|
||||
return pk_setting_key(pubkey, key: "default_zap_amount")
|
||||
}
|
||||
|
||||
func set_default_zap_amount(pubkey: String, amount: Int) {
|
||||
let key = default_zap_setting_key(pubkey: pubkey)
|
||||
UserDefaults.standard.setValue(amount, forKey: key)
|
||||
}
|
||||
|
||||
let fallback_zap_amount = 1000
|
||||
|
||||
func get_default_zap_amount(pubkey: String) -> Int {
|
||||
let key = default_zap_setting_key(pubkey: pubkey)
|
||||
let amt = UserDefaults.standard.integer(forKey: key)
|
||||
if amt == 0 {
|
||||
return fallback_zap_amount
|
||||
}
|
||||
return amt
|
||||
}
|
||||
|
||||
func should_disable_image_animation() -> Bool {
|
||||
return (UserDefaults.standard.object(forKey: "disable_animation") as? Bool)
|
||||
?? UIAccessibility.isReduceMotionEnabled
|
||||
}
|
||||
|
||||
func get_default_wallet(_ pubkey: String) -> Wallet {
|
||||
if let defaultWalletName = UserDefaults.standard.string(forKey: "default_wallet"),
|
||||
let default_wallet = Wallet(rawValue: defaultWalletName)
|
||||
{
|
||||
return default_wallet
|
||||
} else {
|
||||
return .system_default_wallet
|
||||
}
|
||||
}
|
||||
|
||||
func get_media_uploader(_ pubkey: String) -> MediaUploader {
|
||||
if let defaultMediaUploader = UserDefaults.standard.string(forKey: "default_media_uploader"),
|
||||
let defaultMediaUploader = MediaUploader(rawValue: defaultMediaUploader) {
|
||||
return defaultMediaUploader
|
||||
} else {
|
||||
return .nostrBuild
|
||||
}
|
||||
}
|
||||
|
||||
private func get_translation_service(_ pubkey: String) -> TranslationService? {
|
||||
guard let translation_service = UserDefaults.standard.string(forKey: "translation_service") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return TranslationService(rawValue: translation_service)
|
||||
}
|
||||
|
||||
private func get_libretranslate_url(_ pubkey: String, server: LibreTranslateServer) -> String? {
|
||||
if let url = server.model.url {
|
||||
return url
|
||||
}
|
||||
|
||||
return UserDefaults.standard.object(forKey: "libretranslate_url") as? String
|
||||
}
|
||||
|
||||
private func get_libretranslate_server(_ pubkey: String) -> LibreTranslateServer? {
|
||||
guard let server_name = UserDefaults.standard.string(forKey: "libretranslate_server") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return LibreTranslateServer(rawValue: server_name)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import Foundation
|
||||
class ZapsModel: ObservableObject {
|
||||
let state: DamusState
|
||||
let target: ZapTarget
|
||||
var zaps: [Zap]
|
||||
|
||||
let zaps_subid = UUID().description
|
||||
let profiles_subid = UUID().description
|
||||
@@ -18,7 +17,10 @@ class ZapsModel: ObservableObject {
|
||||
init(state: DamusState, target: ZapTarget) {
|
||||
self.state = state
|
||||
self.target = target
|
||||
self.zaps = []
|
||||
}
|
||||
|
||||
var zaps: [Zap] {
|
||||
return state.events.lookup_zaps(target: target)
|
||||
}
|
||||
|
||||
func subscribe() {
|
||||
@@ -51,7 +53,7 @@ class ZapsModel: ObservableObject {
|
||||
case .notice:
|
||||
break
|
||||
case .eose:
|
||||
let events = self.zaps.map { $0.request.ev }
|
||||
let events = state.events.lookup_zaps(target: target).map { $0.request_ev }
|
||||
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events), damus_state: state)
|
||||
case .event(_, let ev):
|
||||
guard ev.kind == 9735 else {
|
||||
@@ -59,7 +61,7 @@ class ZapsModel: ObservableObject {
|
||||
}
|
||||
|
||||
if let zap = state.zaps.zaps[ev.id] {
|
||||
if insert_uniq_sorted_zap_by_amount(zaps: &zaps, new_zap: zap) {
|
||||
if state.events.store_zap(zap: zap) {
|
||||
objectWillChange.send()
|
||||
}
|
||||
} else {
|
||||
@@ -71,9 +73,7 @@ class ZapsModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
state.zaps.add_zap(zap: zap)
|
||||
|
||||
if insert_uniq_sorted_zap_by_amount(zaps: &zaps, new_zap: zap) {
|
||||
if self.state.add_zap(zap: zap) {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,18 @@ class Profile: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
func cache_lnurl() {
|
||||
guard self._lnurl == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let addr = lud16 ?? lud06 else {
|
||||
return
|
||||
}
|
||||
|
||||
self._lnurl = lnaddress_to_lnurl(addr)
|
||||
}
|
||||
|
||||
private var _lnurl: String? = nil
|
||||
var lnurl: String? {
|
||||
if let _lnurl {
|
||||
|
||||
@@ -42,6 +42,18 @@ struct ReferencedId: Identifiable, Hashable, Equatable {
|
||||
var id: String {
|
||||
return ref_id
|
||||
}
|
||||
|
||||
static func q(_ id: String, relay_id: String? = nil) -> ReferencedId {
|
||||
return ReferencedId(ref_id: id, relay_id: relay_id, key: "q")
|
||||
}
|
||||
|
||||
static func e(_ id: String, relay_id: String? = nil) -> ReferencedId {
|
||||
return ReferencedId(ref_id: id, relay_id: relay_id, key: "e")
|
||||
}
|
||||
|
||||
static func p(_ id: String, relay_id: String? = nil) -> ReferencedId {
|
||||
return ReferencedId(ref_id: id, relay_id: relay_id, key: "p")
|
||||
}
|
||||
}
|
||||
|
||||
struct EventId: Identifiable, CustomStringConvertible {
|
||||
@@ -111,14 +123,22 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has
|
||||
return parse_mentions(content: content, tags: self.tags)
|
||||
}
|
||||
|
||||
lazy var inner_event: NostrEvent? = {
|
||||
// don't try to deserialize an inner event if we know there won't be one
|
||||
if self.known_kind == .boost {
|
||||
return event_from_json(dat: self.content)
|
||||
}
|
||||
return nil
|
||||
private lazy var inner_event: NostrEvent? = {
|
||||
return event_from_json(dat: self.content)
|
||||
}()
|
||||
|
||||
func get_inner_event(cache: EventCache) -> NostrEvent? {
|
||||
guard self.known_kind == .boost else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if self.content == "", let ref = self.referenced_ids.first {
|
||||
return cache.lookup(ref.ref_id)
|
||||
}
|
||||
|
||||
return self.inner_event
|
||||
}
|
||||
|
||||
private var _event_refs: [EventRef]? = nil
|
||||
func event_refs(_ privkey: String?) -> [EventRef] {
|
||||
if let rs = _event_refs {
|
||||
@@ -686,7 +706,7 @@ func generate_private_keypair(our_privkey: String, id: String, created_at: Int64
|
||||
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> NostrEvent? {
|
||||
var tags = zap_target_to_tags(target)
|
||||
var relay_tag = ["relays"]
|
||||
relay_tag.append(contentsOf: relays.map { $0.url.absoluteString })
|
||||
relay_tag.append(contentsOf: relays.map { $0.url.id })
|
||||
tags.append(relay_tag)
|
||||
|
||||
var kp = keypair
|
||||
@@ -738,7 +758,7 @@ func uniq<T: Hashable>(_ xs: [T]) -> [T] {
|
||||
func gather_reply_ids(our_pubkey: String, from: NostrEvent) -> [ReferencedId] {
|
||||
var ids = get_referenced_ids(tags: from.tags, key: "e").first.map { [$0] } ?? []
|
||||
|
||||
ids.append(ReferencedId(ref_id: from.id, relay_id: nil, key: "e"))
|
||||
ids.append(.e(from.id))
|
||||
ids.append(contentsOf: uniq(from.referenced_pubkeys.filter { $0.ref_id != our_pubkey }))
|
||||
if from.pubkey != our_pubkey {
|
||||
ids.append(ReferencedId(ref_id: from.pubkey, relay_id: nil, key: "p"))
|
||||
@@ -747,7 +767,7 @@ func gather_reply_ids(our_pubkey: String, from: NostrEvent) -> [ReferencedId] {
|
||||
}
|
||||
|
||||
func gather_quote_ids(our_pubkey: String, from: NostrEvent) -> [ReferencedId] {
|
||||
var ids: [ReferencedId] = []
|
||||
var ids: [ReferencedId] = [.q(from.id)]
|
||||
if from.pubkey != our_pubkey {
|
||||
ids.append(ReferencedId(ref_id: from.pubkey, relay_id: nil, key: "p"))
|
||||
}
|
||||
@@ -998,8 +1018,8 @@ func last_etag(tags: [[String]]) -> String? {
|
||||
return e
|
||||
}
|
||||
|
||||
func inner_event_or_self(ev: NostrEvent) -> NostrEvent {
|
||||
guard let inner_ev = ev.inner_event else {
|
||||
func inner_event_or_self(ev: NostrEvent, cache: EventCache) -> NostrEvent {
|
||||
guard let inner_ev = ev.get_inner_event(cache: cache) else {
|
||||
return ev
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ public struct RelayInfo: Codable {
|
||||
static let rw = RelayInfo(read: true, write: true)
|
||||
}
|
||||
|
||||
public struct RelayDescriptor: Codable {
|
||||
public let url: URL
|
||||
public struct RelayDescriptor {
|
||||
public let url: RelayURL
|
||||
public let info: RelayInfo
|
||||
}
|
||||
|
||||
@@ -52,14 +52,12 @@ class Relay: Identifiable {
|
||||
let descriptor: RelayDescriptor
|
||||
let connection: RelayConnection
|
||||
|
||||
var last_pong: UInt32
|
||||
var flags: Int
|
||||
|
||||
init(descriptor: RelayDescriptor, connection: RelayConnection) {
|
||||
self.flags = 0
|
||||
self.descriptor = descriptor
|
||||
self.connection = connection
|
||||
self.last_pong = 0
|
||||
}
|
||||
|
||||
func mark_broken() {
|
||||
@@ -81,6 +79,6 @@ enum RelayError: Error {
|
||||
case RelayNotFound
|
||||
}
|
||||
|
||||
func get_relay_id(_ url: URL) -> String {
|
||||
return url.absoluteString
|
||||
func get_relay_id(_ url: RelayURL) -> String {
|
||||
return url.url.absoluteString
|
||||
}
|
||||
|
||||
@@ -5,42 +5,52 @@
|
||||
// Created by William Casarin on 2022-04-02.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Starscream
|
||||
|
||||
enum NostrConnectionEvent {
|
||||
case ws_event(WebSocketEvent)
|
||||
case nostr_event(NostrResponse)
|
||||
}
|
||||
|
||||
final class RelayConnection: WebSocketDelegate {
|
||||
private(set) var isConnected = false
|
||||
private(set) var isConnecting = false
|
||||
private(set) var isReconnecting = false
|
||||
public struct RelayURL: Hashable {
|
||||
private(set) var url: URL
|
||||
|
||||
private(set) var last_connection_attempt: TimeInterval = 0
|
||||
private lazy var socket = {
|
||||
let req = URLRequest(url: url)
|
||||
let socket = WebSocket(request: req, compressionHandler: .none)
|
||||
socket.delegate = self
|
||||
return socket
|
||||
}()
|
||||
private var handleEvent: (NostrConnectionEvent) -> ()
|
||||
private let url: URL
|
||||
|
||||
init(url: URL, handleEvent: @escaping (NostrConnectionEvent) -> ()) {
|
||||
self.url = url
|
||||
self.handleEvent = handleEvent
|
||||
var id: String {
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
func reconnect() {
|
||||
if isConnected {
|
||||
isReconnecting = true
|
||||
disconnect()
|
||||
} else {
|
||||
// we're already disconnected, so just connect
|
||||
connect(force: true)
|
||||
init?(_ str: String) {
|
||||
guard let url = URL(string: str) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let scheme = url.scheme else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard scheme == "ws" || scheme == "wss" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
|
||||
final class RelayConnection {
|
||||
private(set) var isConnected = false
|
||||
private(set) var isConnecting = false
|
||||
|
||||
private(set) var last_connection_attempt: TimeInterval = 0
|
||||
private lazy var socket = WebSocket(url.url)
|
||||
private var subscriptionToken: AnyCancellable?
|
||||
|
||||
private var handleEvent: (NostrConnectionEvent) -> ()
|
||||
private let url: RelayURL
|
||||
|
||||
init(url: RelayURL, handleEvent: @escaping (NostrConnectionEvent) -> ()) {
|
||||
self.url = url
|
||||
self.handleEvent = handleEvent
|
||||
}
|
||||
|
||||
func connect(force: Bool = false) {
|
||||
@@ -50,11 +60,27 @@ final class RelayConnection: WebSocketDelegate {
|
||||
|
||||
isConnecting = true
|
||||
last_connection_attempt = Date().timeIntervalSince1970
|
||||
|
||||
subscriptionToken = socket.subject
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] completion in
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
self?.receive(event: .error(error))
|
||||
case .finished:
|
||||
self?.receive(event: .disconnected(.normalClosure, nil))
|
||||
}
|
||||
} receiveValue: { [weak self] event in
|
||||
self?.receive(event: event)
|
||||
}
|
||||
|
||||
socket.connect()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
socket.disconnect()
|
||||
subscriptionToken = nil
|
||||
|
||||
isConnected = false
|
||||
isConnecting = false
|
||||
}
|
||||
@@ -64,53 +90,58 @@ final class RelayConnection: WebSocketDelegate {
|
||||
print("failed to encode nostr req: \(req)")
|
||||
return
|
||||
}
|
||||
|
||||
socket.write(string: req)
|
||||
socket.send(.string(req))
|
||||
}
|
||||
|
||||
// MARK: - WebSocketDelegate
|
||||
|
||||
func didReceive(event: WebSocketEvent, client: WebSocket) {
|
||||
private func receive(event: WebSocketEvent) {
|
||||
switch event {
|
||||
case .connected:
|
||||
self.isConnected = true
|
||||
self.isConnecting = false
|
||||
|
||||
case .disconnected:
|
||||
self.isConnecting = false
|
||||
self.isConnected = false
|
||||
if self.isReconnecting {
|
||||
self.isReconnecting = false
|
||||
self.connect()
|
||||
case .message(let message):
|
||||
self.receive(message: message)
|
||||
case .disconnected(let closeCode, let reason):
|
||||
if closeCode != .normalClosure {
|
||||
print("⚠️ Warning: RelayConnection (\(self.url)) closed with code \(closeCode), reason: \(String(describing: reason))")
|
||||
}
|
||||
|
||||
case .cancelled, .error:
|
||||
self.isConnecting = false
|
||||
self.isConnected = false
|
||||
|
||||
case .text(let txt):
|
||||
if txt.utf8.count > 2000 {
|
||||
DispatchQueue.global(qos: .default).async {
|
||||
if let ev = decode_nostr_event(txt: txt) {
|
||||
DispatchQueue.main.async {
|
||||
self.handleEvent(.nostr_event(ev))
|
||||
}
|
||||
return
|
||||
isConnected = false
|
||||
isConnecting = false
|
||||
reconnect()
|
||||
case .error(let error):
|
||||
print("⚠️ Warning: RelayConnection (\(self.url)) error: \(error)")
|
||||
isConnected = false
|
||||
isConnecting = false
|
||||
reconnect()
|
||||
}
|
||||
self.handleEvent(.ws_event(event))
|
||||
}
|
||||
|
||||
func reconnect() {
|
||||
guard !isConnecting else {
|
||||
return // we're already trying to connect
|
||||
}
|
||||
disconnect()
|
||||
connect()
|
||||
}
|
||||
|
||||
private func receive(message: URLSessionWebSocketTask.Message) {
|
||||
switch message {
|
||||
case .string(let messageString):
|
||||
DispatchQueue.global(qos: .default).async {
|
||||
if let ev = decode_nostr_event(txt: messageString) {
|
||||
DispatchQueue.main.async {
|
||||
self.handleEvent(.nostr_event(ev))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let ev = decode_nostr_event(txt: txt) {
|
||||
handleEvent(.nostr_event(ev))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
default:
|
||||
break
|
||||
case .data(let messageData):
|
||||
if let messageString = String(data: messageData, encoding: .utf8) {
|
||||
receive(message: .string(messageString))
|
||||
}
|
||||
@unknown default:
|
||||
print("An unexpected URLSessionWebSocketTask.Message was received.")
|
||||
}
|
||||
|
||||
handleEvent(.ws_event(event))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+26
-20
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
struct SubscriptionId: Identifiable, CustomStringConvertible {
|
||||
let id: String
|
||||
@@ -44,7 +45,24 @@ class RelayPool {
|
||||
var request_queue: [QueuedRequest] = []
|
||||
var seen: Set<String> = Set()
|
||||
var counts: [String: UInt64] = [:]
|
||||
|
||||
private let network_monitor = NWPathMonitor()
|
||||
private let network_monitor_queue = DispatchQueue(label: "io.damus.network_monitor")
|
||||
private var last_network_status: NWPath.Status = .unsatisfied
|
||||
|
||||
init() {
|
||||
network_monitor.pathUpdateHandler = { [weak self] path in
|
||||
if (path.status == .satisfied || path.status == .requiresConnection) && self?.last_network_status != path.status {
|
||||
DispatchQueue.main.async {
|
||||
self?.connect_to_disconnected()
|
||||
}
|
||||
}
|
||||
|
||||
self?.last_network_status = path.status
|
||||
}
|
||||
network_monitor.start(queue: network_monitor_queue)
|
||||
}
|
||||
|
||||
var descriptors: [RelayDescriptor] {
|
||||
relays.map { $0.descriptor }
|
||||
}
|
||||
@@ -88,7 +106,7 @@ class RelayPool {
|
||||
}
|
||||
}
|
||||
|
||||
func add_relay(_ url: URL, info: RelayInfo) throws {
|
||||
func add_relay(_ url: RelayURL, info: RelayInfo) throws {
|
||||
let relay_id = get_relay_id(url)
|
||||
if get_relay(relay_id) != nil {
|
||||
throw RelayError.RelayAlreadyExists
|
||||
@@ -106,11 +124,11 @@ class RelayPool {
|
||||
for relay in relays {
|
||||
let c = relay.connection
|
||||
|
||||
let is_connecting = c.isReconnecting || c.isConnecting
|
||||
let is_connecting = c.isConnecting
|
||||
|
||||
if is_connecting && (Date.now.timeIntervalSince1970 - c.last_connection_attempt) > 5 {
|
||||
print("stale connection detected (\(relay.descriptor.url.absoluteString)). retrying...")
|
||||
relay.connection.connect(force: true)
|
||||
print("stale connection detected (\(relay.descriptor.url.url.absoluteString)). retrying...")
|
||||
relay.connection.reconnect()
|
||||
} else if relay.is_broken || is_connecting || c.isConnected {
|
||||
continue
|
||||
} else {
|
||||
@@ -208,19 +226,6 @@ class RelayPool {
|
||||
relays.first(where: { $0.id == id })
|
||||
}
|
||||
|
||||
func record_last_pong(relay_id: String, event: NostrConnectionEvent) {
|
||||
if case .ws_event(let ws_event) = event {
|
||||
if case .pong = ws_event {
|
||||
for relay in relays {
|
||||
if relay.id == relay_id {
|
||||
relay.last_pong = UInt32(Date.now.timeIntervalSince1970)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func run_queue(_ relay_id: String) {
|
||||
self.request_queue = request_queue.reduce(into: Array<QueuedRequest>()) { (q, req) in
|
||||
guard req.relay == relay_id else {
|
||||
@@ -250,7 +255,6 @@ class RelayPool {
|
||||
}
|
||||
|
||||
func handle_event(relay_id: String, event: NostrConnectionEvent) {
|
||||
record_last_pong(relay_id: relay_id, event: event)
|
||||
record_seen(relay_id: relay_id, event: event)
|
||||
|
||||
// run req queue when we reconnect
|
||||
@@ -267,8 +271,10 @@ class RelayPool {
|
||||
}
|
||||
|
||||
func add_rw_relay(_ pool: RelayPool, _ url: String) {
|
||||
let url_ = URL(string: url)!
|
||||
try? pool.add_relay(url_, info: RelayInfo.rw)
|
||||
guard let url = RelayURL(url) else {
|
||||
return
|
||||
}
|
||||
try? pool.add_relay(url, info: RelayInfo.rw)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// WebSocket.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Bryan Montz on 4/13/23.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum WebSocketEvent {
|
||||
case connected
|
||||
case message(URLSessionWebSocketTask.Message)
|
||||
case disconnected(URLSessionWebSocketTask.CloseCode, String?)
|
||||
case error(Error)
|
||||
}
|
||||
|
||||
final class WebSocket: NSObject, URLSessionWebSocketDelegate {
|
||||
|
||||
private let url: URL
|
||||
private let session: URLSession
|
||||
private lazy var webSocketTask: URLSessionWebSocketTask = {
|
||||
let task = session.webSocketTask(with: url)
|
||||
task.delegate = self
|
||||
return task
|
||||
}()
|
||||
|
||||
let subject = PassthroughSubject<WebSocketEvent, Never>()
|
||||
|
||||
init(_ url: URL, session: URLSession = .shared) {
|
||||
self.url = url
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func connect() {
|
||||
resume()
|
||||
}
|
||||
|
||||
func disconnect(closeCode: URLSessionWebSocketTask.CloseCode = .normalClosure, reason: Data? = nil) {
|
||||
webSocketTask.cancel(with: closeCode, reason: reason)
|
||||
|
||||
// reset after disconnecting to be ready for reconnecting
|
||||
let task = session.webSocketTask(with: url)
|
||||
task.delegate = self
|
||||
webSocketTask = task
|
||||
|
||||
let reason_str: String?
|
||||
if let reason {
|
||||
reason_str = String(data: reason, encoding: .utf8)
|
||||
} else {
|
||||
reason_str = nil
|
||||
}
|
||||
subject.send(.disconnected(closeCode, reason_str))
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message) {
|
||||
webSocketTask.send(message) { [weak self] error in
|
||||
if let error {
|
||||
self?.subject.send(.error(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resume() {
|
||||
webSocketTask.receive { [weak self] result in
|
||||
switch result {
|
||||
case .success(let message):
|
||||
self?.subject.send(.message(message))
|
||||
self?.resume()
|
||||
case .failure(let error):
|
||||
self?.subject.send(.error(error))
|
||||
}
|
||||
}
|
||||
|
||||
webSocketTask.resume()
|
||||
}
|
||||
|
||||
// MARK: - URLSessionWebSocketDelegate
|
||||
|
||||
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol theProtocol: String?) {
|
||||
subject.send(.connected)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
disconnect(closeCode: closeCode, reason: reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import UIKit
|
||||
|
||||
extension UIImage {
|
||||
public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
|
||||
guard blurHash.count >= 6 else { return nil }
|
||||
|
||||
let sizeFlag = String(blurHash[0]).decode83()
|
||||
let numY = (sizeFlag / 9) + 1
|
||||
let numX = (sizeFlag % 9) + 1
|
||||
|
||||
let quantisedMaximumValue = String(blurHash[1]).decode83()
|
||||
let maximumValue = Float(quantisedMaximumValue + 1) / 166
|
||||
|
||||
guard blurHash.count == 4 + 2 * numX * numY else { return nil }
|
||||
|
||||
let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in
|
||||
if i == 0 {
|
||||
let value = String(blurHash[2 ..< 6]).decode83()
|
||||
return decodeDC(value)
|
||||
} else {
|
||||
let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83()
|
||||
return decodeAC(value, maximumValue: maximumValue * punch)
|
||||
}
|
||||
}
|
||||
|
||||
let width = Int(size.width)
|
||||
let height = Int(size.height)
|
||||
let bytesPerRow = width * 3
|
||||
guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil }
|
||||
CFDataSetLength(data, bytesPerRow * height)
|
||||
guard let pixels = CFDataGetMutableBytePtr(data) else { return nil }
|
||||
|
||||
for y in 0 ..< height {
|
||||
for x in 0 ..< width {
|
||||
var r: Float = 0
|
||||
var g: Float = 0
|
||||
var b: Float = 0
|
||||
|
||||
for j in 0 ..< numY {
|
||||
for i in 0 ..< numX {
|
||||
let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height))
|
||||
let colour = colours[i + j * numX]
|
||||
r += colour.0 * basis
|
||||
g += colour.1 * basis
|
||||
b += colour.2 * basis
|
||||
}
|
||||
}
|
||||
|
||||
let intR = UInt8(linearTosRGB(r))
|
||||
let intG = UInt8(linearTosRGB(g))
|
||||
let intB = UInt8(linearTosRGB(b))
|
||||
|
||||
pixels[3 * x + 0 + y * bytesPerRow] = intR
|
||||
pixels[3 * x + 1 + y * bytesPerRow] = intG
|
||||
pixels[3 * x + 2 + y * bytesPerRow] = intB
|
||||
}
|
||||
}
|
||||
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
|
||||
|
||||
guard let provider = CGDataProvider(data: data) else { return nil }
|
||||
guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow,
|
||||
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { return nil }
|
||||
|
||||
self.init(cgImage: cgImage)
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeDC(_ value: Int) -> (Float, Float, Float) {
|
||||
let intR = value >> 16
|
||||
let intG = (value >> 8) & 255
|
||||
let intB = value & 255
|
||||
return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB))
|
||||
}
|
||||
|
||||
private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
|
||||
let quantR = value / (19 * 19)
|
||||
let quantG = (value / 19) % 19
|
||||
let quantB = value % 19
|
||||
|
||||
let rgb = (
|
||||
signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
|
||||
signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
|
||||
signPow((Float(quantB) - 9) / 9, 2) * maximumValue
|
||||
)
|
||||
|
||||
return rgb
|
||||
}
|
||||
|
||||
private func signPow(_ value: Float, _ exp: Float) -> Float {
|
||||
return copysign(pow(abs(value), exp), value)
|
||||
}
|
||||
|
||||
private func linearTosRGB(_ value: Float) -> Int {
|
||||
let v = max(0, min(1, value))
|
||||
if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
|
||||
else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
|
||||
}
|
||||
|
||||
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
|
||||
let v = Float(Int64(value)) / 255
|
||||
if v <= 0.04045 { return v / 12.92 }
|
||||
else { return pow((v + 0.055) / 1.055, 2.4) }
|
||||
}
|
||||
|
||||
private let encodeCharacters: [String] = {
|
||||
return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
|
||||
}()
|
||||
|
||||
private let decodeCharacters: [String: Int] = {
|
||||
var dict: [String: Int] = [:]
|
||||
for (index, character) in encodeCharacters.enumerated() {
|
||||
dict[character] = index
|
||||
}
|
||||
return dict
|
||||
}()
|
||||
|
||||
extension String {
|
||||
func decode83() -> Int {
|
||||
var value: Int = 0
|
||||
for character in self {
|
||||
if let digit = decodeCharacters[String(character)] {
|
||||
value = value * 83 + digit
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
subscript (offset: Int) -> Character {
|
||||
return self[index(startIndex, offsetBy: offset)]
|
||||
}
|
||||
|
||||
subscript (bounds: CountableClosedRange<Int>) -> Substring {
|
||||
let start = index(startIndex, offsetBy: bounds.lowerBound)
|
||||
let end = index(startIndex, offsetBy: bounds.upperBound)
|
||||
return self[start...end]
|
||||
}
|
||||
|
||||
subscript (bounds: CountableRange<Int>) -> Substring {
|
||||
let start = index(startIndex, offsetBy: bounds.lowerBound)
|
||||
let end = index(startIndex, offsetBy: bounds.upperBound)
|
||||
return self[start..<end]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import UIKit
|
||||
|
||||
extension UIImage {
|
||||
public func blurHash(numberOfComponents components: (Int, Int)) -> String? {
|
||||
let pixelWidth = Int(round(size.width * scale))
|
||||
let pixelHeight = Int(round(size.height * scale))
|
||||
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: pixelWidth,
|
||||
height: pixelHeight,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: pixelWidth * 4,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB)!,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
)!
|
||||
context.scaleBy(x: scale, y: -scale)
|
||||
context.translateBy(x: 0, y: -size.height)
|
||||
|
||||
UIGraphicsPushContext(context)
|
||||
draw(at: .zero)
|
||||
UIGraphicsPopContext()
|
||||
|
||||
guard let cgImage = context.makeImage(),
|
||||
let dataProvider = cgImage.dataProvider,
|
||||
let data = dataProvider.data,
|
||||
let pixels = CFDataGetBytePtr(data) else {
|
||||
assertionFailure("Unexpected error!")
|
||||
return nil
|
||||
}
|
||||
|
||||
let width = cgImage.width
|
||||
let height = cgImage.height
|
||||
let bytesPerRow = cgImage.bytesPerRow
|
||||
|
||||
var factors: [(Float, Float, Float)] = []
|
||||
for y in 0 ..< components.1 {
|
||||
for x in 0 ..< components.0 {
|
||||
let normalisation: Float = (x == 0 && y == 0) ? 1 : 2
|
||||
let factor = multiplyBasisFunction(pixels: pixels, width: width, height: height, bytesPerRow: bytesPerRow, bytesPerPixel: cgImage.bitsPerPixel / 8, pixelOffset: 0) {
|
||||
normalisation * cos(Float.pi * Float(x) * $0 / Float(width)) as Float * cos(Float.pi * Float(y) * $1 / Float(height)) as Float
|
||||
}
|
||||
factors.append(factor)
|
||||
}
|
||||
}
|
||||
|
||||
let dc = factors.first!
|
||||
let ac = factors.dropFirst()
|
||||
|
||||
var hash = ""
|
||||
|
||||
let sizeFlag = (components.0 - 1) + (components.1 - 1) * 9
|
||||
hash += sizeFlag.encode83(length: 1)
|
||||
|
||||
let maximumValue: Float
|
||||
if ac.count > 0 {
|
||||
let actualMaximumValue = ac.map({ max(abs($0.0), abs($0.1), abs($0.2)) }).max()!
|
||||
let quantisedMaximumValue = Int(max(0, min(82, floor(actualMaximumValue * 166 - 0.5))))
|
||||
maximumValue = Float(quantisedMaximumValue + 1) / 166
|
||||
hash += quantisedMaximumValue.encode83(length: 1)
|
||||
} else {
|
||||
maximumValue = 1
|
||||
hash += 0.encode83(length: 1)
|
||||
}
|
||||
|
||||
hash += encodeDC(dc).encode83(length: 4)
|
||||
|
||||
for factor in ac {
|
||||
hash += encodeAC(factor, maximumValue: maximumValue).encode83(length: 2)
|
||||
}
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
private func multiplyBasisFunction(pixels: UnsafePointer<UInt8>, width: Int, height: Int, bytesPerRow: Int, bytesPerPixel: Int, pixelOffset: Int, basisFunction: (Float, Float) -> Float) -> (Float, Float, Float) {
|
||||
var r: Float = 0
|
||||
var g: Float = 0
|
||||
var b: Float = 0
|
||||
|
||||
let buffer = UnsafeBufferPointer(start: pixels, count: height * bytesPerRow)
|
||||
|
||||
for x in 0 ..< width {
|
||||
for y in 0 ..< height {
|
||||
let basis = basisFunction(Float(x), Float(y))
|
||||
r += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 0 + y * bytesPerRow])
|
||||
g += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 1 + y * bytesPerRow])
|
||||
b += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 2 + y * bytesPerRow])
|
||||
}
|
||||
}
|
||||
|
||||
let scale = 1 / Float(width * height)
|
||||
|
||||
return (r * scale, g * scale, b * scale)
|
||||
}
|
||||
}
|
||||
|
||||
private func encodeDC(_ value: (Float, Float, Float)) -> Int {
|
||||
let roundedR = linearTosRGB(value.0)
|
||||
let roundedG = linearTosRGB(value.1)
|
||||
let roundedB = linearTosRGB(value.2)
|
||||
return (roundedR << 16) + (roundedG << 8) + roundedB
|
||||
}
|
||||
|
||||
private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int {
|
||||
let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5))))
|
||||
let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5))))
|
||||
let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5))))
|
||||
|
||||
return quantR * 19 * 19 + quantG * 19 + quantB
|
||||
}
|
||||
|
||||
private func signPow(_ value: Float, _ exp: Float) -> Float {
|
||||
return copysign(pow(abs(value), exp), value)
|
||||
}
|
||||
|
||||
private func linearTosRGB(_ value: Float) -> Int {
|
||||
let v = max(0, min(1, value))
|
||||
if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) }
|
||||
else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
|
||||
}
|
||||
|
||||
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
|
||||
let v = Float(Int64(value)) / 255
|
||||
if v <= 0.04045 { return v / 12.92 }
|
||||
else { return pow((v + 0.055) / 1.055, 2.4) }
|
||||
}
|
||||
|
||||
private let encodeCharacters: [String] = {
|
||||
return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
|
||||
}()
|
||||
|
||||
extension BinaryInteger {
|
||||
func encode83(length: Int) -> String {
|
||||
var result = ""
|
||||
for i in 1 ... length {
|
||||
let digit = (Int(self) / pow(83, length - i)) % 83
|
||||
result += encodeCharacters[Int(digit)]
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private func pow(_ base: Int, _ exponent: Int) -> Int {
|
||||
return (0 ..< exponent).reduce(1) { value, _ in value * base }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2018 Wolt Enterprises
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,45 @@
|
||||
# BlurHash for iOS, in Swift
|
||||
|
||||
## Standalone decoder and encoder
|
||||
|
||||
[BlurHashDecode.swift](BlurHashDecode.swift) and [BlurHashEncode.swift](BlurHashEncode.swift) contain a decoder
|
||||
and encoder for BlurHash to and from `UIImage`. Both files are completeiy standalone, and can simply be copied into your
|
||||
project directly.
|
||||
|
||||
### Decoding
|
||||
|
||||
[BlurHashDecode.swift](BlurHashDecode.swift) implements the following extension on `UIImage`:
|
||||
|
||||
public convenience init?(blurHash: String, size: CGSize, punch: Float = 1)
|
||||
|
||||
This creates a UIImage containing the placeholder image decoded from the BlurHash string, or returns nil if decoding failed.
|
||||
The parameters are:
|
||||
|
||||
* `blurHash` - A string containing the BlurHash.
|
||||
* `size` - The requested output size. You should keep this small, and let UIKit scale it up for you. 32 pixels wide is plenty.
|
||||
* `punch` - Adjusts the contrast of the output image. Tweak it if you want a different look for your placeholders.
|
||||
|
||||
### Encoding
|
||||
|
||||
[BlurHashEncode.swift](BlurHashEncode.swift) implements the following extension on `UIImage`:
|
||||
|
||||
public func blurHash(numberOfComponents components: (Int, Int)) -> String?
|
||||
|
||||
This returns a string containing the BlurHash for the image, or nil if the image was in a weird format that is not supported.
|
||||
The parameters are:
|
||||
|
||||
* `numberOfComponents` - a Tuple of integers specifying the number of components in the X and Y directions. Both must be
|
||||
between 1 and 9 inclusive, or the function will return nil. 3 to 5 is usually a good range.
|
||||
|
||||
## BlurHashKit
|
||||
|
||||
This is a more advanced library, currently in development. It will let you do more advanced operations using BlurHashes,
|
||||
such testing whether various parts of an image are dark and light, or generating BlurHashes as gradients from corner colours.
|
||||
|
||||
It is currently not documented or finalised, but feel free to look into the different files and what they implement, or look at
|
||||
how it is used by the test app.
|
||||
|
||||
## BlurHashTest.app
|
||||
|
||||
This is a simple test app that shows how to use the various pieces of BlurHash functionality, and lets you play with the
|
||||
algorithm.
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// CredentialHandler.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Bryan Montz on 4/26/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
|
||||
final class CredentialHandler: NSObject, ASAuthorizationControllerDelegate {
|
||||
|
||||
func check_credentials() {
|
||||
let requests: [ASAuthorizationRequest] = [ASAuthorizationPasswordProvider().createRequest()]
|
||||
let authorizationController = ASAuthorizationController(authorizationRequests: requests)
|
||||
authorizationController.delegate = self
|
||||
authorizationController.performRequests()
|
||||
}
|
||||
|
||||
func save_credential(pubkey: String, privkey: String) {
|
||||
SecAddSharedWebCredential("damus.io" as CFString, pubkey as CFString, privkey as CFString, { error in
|
||||
if let error {
|
||||
print("⚠️ An error occurred while saving credentials: \(error)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - ASAuthorizationControllerDelegate
|
||||
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
|
||||
guard let cred = authorization.credential as? ASPasswordCredential,
|
||||
let parsedKey = parse_key(cred.password) else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
switch parsedKey {
|
||||
case .pub, .priv:
|
||||
try? await process_login(parsedKey, is_pubkey: false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
|
||||
print("⚠️ Warning: authentication failed with error: \(error)")
|
||||
}
|
||||
}
|
||||
+322
-15
@@ -8,14 +8,127 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
import LinkPresentation
|
||||
import Kingfisher
|
||||
|
||||
class ImageMetadataState {
|
||||
var state: ImageMetaProcessState
|
||||
var meta: ImageMetadata
|
||||
|
||||
init(state: ImageMetaProcessState, meta: ImageMetadata) {
|
||||
self.state = state
|
||||
self.meta = meta
|
||||
}
|
||||
}
|
||||
|
||||
enum ImageMetaProcessState {
|
||||
case processing
|
||||
case failed
|
||||
case processed(UIImage)
|
||||
case not_needed
|
||||
|
||||
var img: UIImage? {
|
||||
switch self {
|
||||
case .processed(let img):
|
||||
return img
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TranslationModel: ObservableObject {
|
||||
@Published var note_language: String?
|
||||
@Published var state: TranslateStatus
|
||||
|
||||
init(state: TranslateStatus) {
|
||||
self.state = state
|
||||
self.note_language = nil
|
||||
}
|
||||
}
|
||||
|
||||
class NoteArtifactsModel: ObservableObject {
|
||||
@Published var state: NoteArtifactState
|
||||
|
||||
init(state: NoteArtifactState) {
|
||||
self.state = state
|
||||
}
|
||||
}
|
||||
|
||||
class PreviewModel: ObservableObject {
|
||||
@Published var state: PreviewState
|
||||
|
||||
func store(preview: LPLinkMetadata?) {
|
||||
state = .loaded(Preview(meta: preview))
|
||||
}
|
||||
|
||||
init(state: PreviewState) {
|
||||
self.state = state
|
||||
}
|
||||
}
|
||||
|
||||
class ZapsDataModel: ObservableObject {
|
||||
@Published var zaps: [Zap]
|
||||
|
||||
init(_ zaps: [Zap]) {
|
||||
self.zaps = zaps
|
||||
}
|
||||
}
|
||||
|
||||
class RelativeTimeModel: ObservableObject {
|
||||
private(set) var last_update: Int64
|
||||
@Published var value: String {
|
||||
didSet {
|
||||
self.last_update = Int64(Date().timeIntervalSince1970)
|
||||
}
|
||||
}
|
||||
|
||||
init(value: String) {
|
||||
self.last_update = 0
|
||||
self.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
class EventData {
|
||||
var translations_model: TranslationModel
|
||||
var artifacts_model: NoteArtifactsModel
|
||||
var preview_model: PreviewModel
|
||||
var zaps_model : ZapsDataModel
|
||||
var relative_time: RelativeTimeModel
|
||||
var validated: ValidationResult
|
||||
|
||||
var translations: TranslateStatus {
|
||||
return translations_model.state
|
||||
}
|
||||
|
||||
var artifacts: NoteArtifactState {
|
||||
return artifacts_model.state
|
||||
}
|
||||
|
||||
var preview: PreviewState {
|
||||
return preview_model.state
|
||||
}
|
||||
|
||||
var zaps: [Zap] {
|
||||
return zaps_model.zaps
|
||||
}
|
||||
|
||||
init(zaps: [Zap] = []) {
|
||||
self.translations_model = .init(state: .havent_tried)
|
||||
self.artifacts_model = .init(state: .not_loaded)
|
||||
self.zaps_model = .init(zaps)
|
||||
self.validated = .unknown
|
||||
self.preview_model = .init(state: .not_loaded)
|
||||
self.relative_time = .init(value: "")
|
||||
}
|
||||
}
|
||||
|
||||
class EventCache {
|
||||
private var events: [String: NostrEvent] = [:]
|
||||
private var replies = ReplyMap()
|
||||
private var cancellable: AnyCancellable?
|
||||
private var translations: [String: TranslateStatus] = [:]
|
||||
private var artifacts: [String: NoteArtifacts] = [:]
|
||||
var validation: [String: ValidationResult] = [:]
|
||||
private var image_metadata: [String: ImageMetadataState] = [:]
|
||||
private var event_data: [String: EventData] = [:]
|
||||
|
||||
//private var thread_latest: [String: Int64]
|
||||
|
||||
@@ -27,28 +140,56 @@ class EventCache {
|
||||
}
|
||||
}
|
||||
|
||||
func is_event_valid(_ evid: String) -> ValidationResult {
|
||||
guard let result = validation[evid] else {
|
||||
return .unknown
|
||||
func get_cache_data(_ evid: String) -> EventData {
|
||||
guard let data = event_data[evid] else {
|
||||
let data = EventData()
|
||||
event_data[evid] = data
|
||||
return data
|
||||
}
|
||||
|
||||
return result
|
||||
return data
|
||||
}
|
||||
|
||||
func is_event_valid(_ evid: String) -> ValidationResult {
|
||||
return get_cache_data(evid).validated
|
||||
}
|
||||
|
||||
func store_event_validation(evid: String, validated: ValidationResult) {
|
||||
get_cache_data(evid).validated = validated
|
||||
}
|
||||
|
||||
func store_translation_artifacts(evid: String, translated: TranslateStatus) {
|
||||
self.translations[evid] = translated
|
||||
get_cache_data(evid).translations_model.state = translated
|
||||
}
|
||||
|
||||
func store_artifacts(evid: String, artifacts: NoteArtifacts) {
|
||||
self.artifacts[evid] = artifacts
|
||||
get_cache_data(evid).artifacts_model.state = .loaded(artifacts)
|
||||
}
|
||||
|
||||
func lookup_artifacts(evid: String) -> NoteArtifacts? {
|
||||
return self.artifacts[evid]
|
||||
@discardableResult
|
||||
func store_zap(zap: Zap) -> Bool {
|
||||
let data = get_cache_data(zap.target.id).zaps_model
|
||||
return insert_uniq_sorted_zap_by_amount(zaps: &data.zaps, new_zap: zap)
|
||||
}
|
||||
|
||||
func lookup_zaps(target: ZapTarget) -> [Zap] {
|
||||
return get_cache_data(target.id).zaps_model.zaps
|
||||
}
|
||||
|
||||
func store_img_metadata(url: URL, meta: ImageMetadataState) {
|
||||
self.image_metadata[url.absoluteString.lowercased()] = meta
|
||||
}
|
||||
|
||||
func lookup_artifacts(evid: String) -> NoteArtifactState {
|
||||
return get_cache_data(evid).artifacts_model.state
|
||||
}
|
||||
|
||||
func lookup_img_metadata(url: URL) -> ImageMetadataState? {
|
||||
return image_metadata[url.absoluteString.lowercased()]
|
||||
}
|
||||
|
||||
func lookup_translated_artifacts(evid: String) -> TranslateStatus? {
|
||||
return self.translations[evid]
|
||||
return get_cache_data(evid).translations_model.state
|
||||
}
|
||||
|
||||
func parent_events(event: NostrEvent) -> [NostrEvent] {
|
||||
@@ -57,7 +198,7 @@ class EventCache {
|
||||
var ev = event
|
||||
|
||||
while true {
|
||||
guard let direct_reply = ev.direct_replies(nil).first else {
|
||||
guard let direct_reply = ev.direct_replies(nil).last else {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -114,8 +255,174 @@ class EventCache {
|
||||
|
||||
private func prune() {
|
||||
events = [:]
|
||||
translations = [:]
|
||||
artifacts = [:]
|
||||
event_data = [:]
|
||||
replies.replies = [:]
|
||||
}
|
||||
}
|
||||
|
||||
func should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore, note_lang: String?) -> Bool {
|
||||
guard settings.can_translate else {
|
||||
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.
|
||||
// Offering a translation in this case is definitely incorrect so let's avoid it altogether.
|
||||
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 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
|
||||
case .translating: return false
|
||||
case .translated: return false
|
||||
case .not_needed: return false
|
||||
}
|
||||
}
|
||||
|
||||
struct PreloadPlan {
|
||||
let data: EventData
|
||||
let event: NostrEvent
|
||||
let load_artifacts: Bool
|
||||
let load_translations: Bool
|
||||
let load_preview: Bool
|
||||
}
|
||||
|
||||
func load_preview(artifacts: NoteArtifacts) async -> Preview? {
|
||||
guard let link = artifacts.links.first else {
|
||||
return nil
|
||||
}
|
||||
let meta = await Preview.fetch_metadata(for: link)
|
||||
return Preview(meta: meta)
|
||||
}
|
||||
|
||||
func get_preload_plan(cache: EventData, ev: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore) -> PreloadPlan? {
|
||||
let load_artifacts = cache.artifacts.should_preload
|
||||
if load_artifacts {
|
||||
cache.artifacts_model.state = .loading
|
||||
}
|
||||
|
||||
// Cached event might not have the note language determined yet, so determine the language here before figuring out if translations should be preloaded.
|
||||
let note_lang = cache.translations_model.note_language ?? ev.note_language(our_keypair.privkey) ?? current_language()
|
||||
|
||||
let load_translations = should_preload_translation(event: ev, our_keypair: our_keypair, current_status: cache.translations, settings: settings, note_lang: note_lang)
|
||||
if load_translations {
|
||||
cache.translations_model.state = .translating
|
||||
}
|
||||
|
||||
let load_preview = cache.preview.should_preload
|
||||
if load_preview {
|
||||
cache.preview_model.state = .loading
|
||||
}
|
||||
|
||||
if !load_artifacts && !load_translations && !load_preview {
|
||||
return nil
|
||||
}
|
||||
|
||||
return PreloadPlan(data: cache, event: ev, load_artifacts: load_artifacts, load_translations: load_translations, load_preview: load_preview)
|
||||
}
|
||||
|
||||
func preload_image(url: URL) {
|
||||
if ImageCache.default.isCached(forKey: url.absoluteString) {
|
||||
print("Preloaded image \(url.absoluteString) found in cache")
|
||||
// looks like we already have it cached. no download needed
|
||||
return
|
||||
}
|
||||
|
||||
print("Preloading image \(url.absoluteString)")
|
||||
|
||||
KingfisherManager.shared.retrieveImage(with: ImageResource(downloadURL: url)) { val in
|
||||
print("Preloaded image \(url.absoluteString)")
|
||||
}
|
||||
}
|
||||
|
||||
func preload_event(plan: PreloadPlan, profiles: Profiles, our_keypair: Keypair, settings: UserSettingsStore) async {
|
||||
var artifacts: NoteArtifacts? = plan.data.artifacts.artifacts
|
||||
|
||||
print("Preloading event \(plan.event.content)")
|
||||
|
||||
// preload pfp
|
||||
if let profile = profiles.lookup(id: plan.event.pubkey),
|
||||
let picture = profile.picture,
|
||||
let url = URL(string: picture) {
|
||||
preload_image(url: url)
|
||||
}
|
||||
|
||||
if artifacts == nil && plan.load_artifacts {
|
||||
let arts = render_note_content(ev: plan.event, profiles: profiles, privkey: our_keypair.privkey)
|
||||
artifacts = arts
|
||||
|
||||
// we need these asap
|
||||
DispatchQueue.main.async {
|
||||
plan.data.artifacts_model.state = .loaded(arts)
|
||||
}
|
||||
|
||||
for url in arts.images {
|
||||
preload_image(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
if plan.load_preview {
|
||||
let arts = artifacts ?? render_note_content(ev: plan.event, profiles: profiles, privkey: our_keypair.privkey)
|
||||
let preview = await load_preview(artifacts: arts)
|
||||
DispatchQueue.main.async {
|
||||
if let preview {
|
||||
plan.data.preview_model.state = .loaded(preview)
|
||||
} else {
|
||||
plan.data.preview_model.state = .loaded(.failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let note_language = plan.data.translations_model.note_language ?? plan.event.note_language(our_keypair.privkey) ?? current_language()
|
||||
|
||||
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
|
||||
{
|
||||
translations = await translate_note(profiles: profiles, privkey: our_keypair.privkey, event: plan.event, settings: settings, note_lang: note_language)
|
||||
}
|
||||
|
||||
let timeago = format_relative_time(plan.event.created_at)
|
||||
let ts = translations
|
||||
DispatchQueue.main.async {
|
||||
if let ts {
|
||||
plan.data.translations_model.state = ts
|
||||
}
|
||||
plan.data.relative_time.value = timeago
|
||||
plan.data.translations_model.note_language = note_language
|
||||
}
|
||||
}
|
||||
|
||||
func preload_events(event_cache: EventCache, events: [NostrEvent], profiles: Profiles, our_keypair: Keypair, settings: UserSettingsStore) {
|
||||
|
||||
let plans = events.compactMap { ev in
|
||||
get_preload_plan(cache: event_cache.get_cache_data(ev.id), ev: ev, our_keypair: our_keypair, settings: settings)
|
||||
}
|
||||
|
||||
Task.init {
|
||||
for plan in plans {
|
||||
await preload_event(plan: plan, profiles: profiles, our_keypair: our_keypair, settings: settings)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import Kingfisher
|
||||
|
||||
extension KFOptionSetter {
|
||||
|
||||
func imageContext(_ imageContext: ImageContext) -> Self {
|
||||
func imageContext(_ imageContext: ImageContext, disable_animation: Bool) -> Self {
|
||||
options.callbackQueue = .dispatch(.global(qos: .background))
|
||||
options.processingQueue = .dispatch(.global(qos: .background))
|
||||
options.downloader = CustomImageDownloader.shared
|
||||
@@ -26,7 +26,14 @@ extension KFOptionSetter {
|
||||
options.backgroundDecode = true
|
||||
options.cacheOriginalImage = true
|
||||
options.scaleFactor = UIScreen.main.scale
|
||||
options.onlyLoadFirstFrame = should_disable_image_animation()
|
||||
options.onlyLoadFirstFrame = disable_animation
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func image_fade(duration: TimeInterval) -> Self {
|
||||
options.transition = ImageTransition.fade(duration)
|
||||
options.keepCurrentImageWhileLoading = false
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//
|
||||
// ImageMetadata.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct ImageMetaDim: Equatable, StringCodable {
|
||||
init(width: Int, height: Int) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
|
||||
init?(from string: String) {
|
||||
guard let dim = parse_image_meta_dim(string) else {
|
||||
return nil
|
||||
}
|
||||
self = dim
|
||||
}
|
||||
|
||||
func to_string() -> String {
|
||||
"\(width)x\(height)"
|
||||
}
|
||||
|
||||
var size: CGSize {
|
||||
return CGSize(width: CGFloat(self.width), height: CGFloat(self.height))
|
||||
}
|
||||
|
||||
let width: Int
|
||||
let height: Int
|
||||
}
|
||||
|
||||
struct ProcessedImageMetadata {
|
||||
let blurhash: UIImage?
|
||||
let dim: ImageMetaDim?
|
||||
}
|
||||
|
||||
struct ImageMetadata: Equatable {
|
||||
let url: URL
|
||||
let blurhash: String?
|
||||
let dim: ImageMetaDim?
|
||||
|
||||
init(url: URL, blurhash: String? = nil, dim: ImageMetaDim? = nil) {
|
||||
self.url = url
|
||||
self.blurhash = blurhash
|
||||
self.dim = dim
|
||||
}
|
||||
|
||||
init?(tag: [String]) {
|
||||
guard let meta = decode_image_metadata(tag) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self = meta
|
||||
}
|
||||
|
||||
func to_tag() -> [String] {
|
||||
return image_metadata_to_tag(self)
|
||||
}
|
||||
}
|
||||
|
||||
func process_blurhash(blurhash: String, size: CGSize?) async -> UIImage? {
|
||||
let res = Task.init {
|
||||
let size = get_blurhash_size(img_size: size ?? CGSize(width: 100.0, height: 100.0))
|
||||
guard let img = UIImage.init(blurHash: blurhash, size: size) else {
|
||||
let noimg: UIImage? = nil
|
||||
return noimg
|
||||
}
|
||||
|
||||
return img
|
||||
}
|
||||
|
||||
|
||||
return await res.value
|
||||
}
|
||||
|
||||
func image_metadata_to_tag(_ meta: ImageMetadata) -> [String] {
|
||||
var tags = ["imeta", "url \(meta.url.absoluteString)"]
|
||||
if let blurhash = meta.blurhash {
|
||||
tags.append("blurhash \(blurhash)")
|
||||
}
|
||||
if let dim = meta.dim {
|
||||
tags.append("dim \(dim.to_string())")
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func decode_image_metadata(_ parts: [String]) -> ImageMetadata? {
|
||||
var url: URL? = nil
|
||||
var blurhash: String? = nil
|
||||
var dim: ImageMetaDim? = nil
|
||||
|
||||
for part in parts {
|
||||
if part == "imeta" {
|
||||
continue
|
||||
}
|
||||
|
||||
let ps = part.split(separator: " ")
|
||||
|
||||
guard ps.count == 2 else {
|
||||
return nil
|
||||
}
|
||||
let pname = ps[0]
|
||||
let pval = ps[1]
|
||||
|
||||
if pname == "blurhash" {
|
||||
blurhash = String(pval)
|
||||
} else if pname == "dim" {
|
||||
dim = parse_image_meta_dim(String(pval))
|
||||
} else if pname == "url" {
|
||||
url = URL(string: String(pval))
|
||||
}
|
||||
}
|
||||
|
||||
guard let url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ImageMetadata(url: url, blurhash: blurhash, dim: dim)
|
||||
}
|
||||
|
||||
func parse_image_meta_dim(_ pval: String) -> ImageMetaDim? {
|
||||
let parts = pval.split(separator: "x")
|
||||
guard parts.count == 2,
|
||||
let width = Int(parts[0]),
|
||||
let height = Int(parts[1]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ImageMetaDim(width: width, height: height)
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
func resized(to size: CGSize) -> UIImage {
|
||||
return UIGraphicsImageRenderer(size: size).image { _ in
|
||||
draw(in: CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func get_blurhash_size(img_size: CGSize) -> CGSize {
|
||||
return CGSize(width: 100.0, height: (100.0/img_size.width) * img_size.height)
|
||||
}
|
||||
|
||||
func calculate_blurhash(img: UIImage) async -> String? {
|
||||
guard img.size.height > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let res = Task.init {
|
||||
let bhs = get_blurhash_size(img_size: img.size)
|
||||
let smaller = img.resized(to: bhs)
|
||||
|
||||
guard let blurhash = smaller.blurHash(numberOfComponents: (5,5)) else {
|
||||
let meta: String? = nil
|
||||
return meta
|
||||
}
|
||||
|
||||
return blurhash
|
||||
}
|
||||
|
||||
return await res.value
|
||||
}
|
||||
|
||||
func calculate_image_metadata(url: URL, img: UIImage, blurhash: String) -> ImageMetadata {
|
||||
let width = Int(img.size.width)
|
||||
let height = Int(img.size.height)
|
||||
let dim = ImageMetaDim(width: width, height: height)
|
||||
|
||||
return ImageMetadata(url: url, blurhash: blurhash, dim: dim)
|
||||
}
|
||||
|
||||
|
||||
func process_image_metadata(cache: EventCache, ev: NostrEvent) {
|
||||
for tag in ev.tags {
|
||||
guard tag.count >= 2 && tag[0] == "imeta" else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let meta = ImageMetadata(tag: tag) else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard cache.lookup_img_metadata(url: meta.url) == nil else {
|
||||
continue
|
||||
}
|
||||
|
||||
let state = ImageMetadataState(state: .processing, meta: meta)
|
||||
cache.store_img_metadata(url: meta.url, meta: state)
|
||||
|
||||
if let blurhash = meta.blurhash {
|
||||
Task.init {
|
||||
let img = await process_blurhash(blurhash: blurhash, size: meta.dim?.size)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let img {
|
||||
state.state = .processed(img)
|
||||
} else {
|
||||
state.state = .failed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,6 @@ extension Notification.Name {
|
||||
static var select_quote: Notification.Name {
|
||||
return Notification.Name("select quote")
|
||||
}
|
||||
static var reply: Notification.Name {
|
||||
return Notification.Name("reply")
|
||||
}
|
||||
static var profile_updated: Notification.Name {
|
||||
return Notification.Name("profile_updated")
|
||||
}
|
||||
@@ -56,6 +53,9 @@ extension Notification.Name {
|
||||
static var post: Notification.Name {
|
||||
return Notification.Name("send post")
|
||||
}
|
||||
static var compose: Notification.Name {
|
||||
return Notification.Name("compose")
|
||||
}
|
||||
static var boost: Notification.Name {
|
||||
return Notification.Name("boost")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// BinaryParser.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class BinaryParser {
|
||||
var pos: Int
|
||||
var buf: [UInt8]
|
||||
|
||||
init(buf: [UInt8], pos: Int = 0) {
|
||||
self.pos = pos
|
||||
self.buf = buf
|
||||
}
|
||||
|
||||
func read_byte() -> UInt8? {
|
||||
guard pos < buf.count else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let v = buf[pos]
|
||||
pos += 1
|
||||
return v
|
||||
}
|
||||
|
||||
func read_bytes(_ n: Int) -> [UInt8]? {
|
||||
guard pos + n < buf.count else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let v = [UInt8](self.buf[pos...pos+n])
|
||||
return v
|
||||
}
|
||||
|
||||
func read_u16() -> UInt16? {
|
||||
let start = self.pos
|
||||
|
||||
guard let b1 = read_byte(), let b2 = read_byte() else {
|
||||
self.pos = start
|
||||
return nil
|
||||
}
|
||||
|
||||
return (UInt16(b1) << 8) | UInt16(b2)
|
||||
}
|
||||
}
|
||||
@@ -109,9 +109,7 @@ class PostBox {
|
||||
return
|
||||
}
|
||||
|
||||
let remaining = pool.descriptors.map {
|
||||
$0.url.absoluteString
|
||||
}
|
||||
let remaining = pool.descriptors.map { $0.url.id }
|
||||
|
||||
let posted_ev = PostedEvent(event: event, remaining: remaining)
|
||||
events[event.id] = posted_ev
|
||||
|
||||
@@ -21,6 +21,47 @@ class CachedMetadata {
|
||||
enum Preview {
|
||||
case value(CachedMetadata)
|
||||
case failed
|
||||
|
||||
init(meta: LPLinkMetadata?) {
|
||||
if let meta {
|
||||
self = .value(CachedMetadata(meta: meta))
|
||||
} else {
|
||||
self = .failed
|
||||
}
|
||||
}
|
||||
|
||||
static func fetch_metadata(for url: URL) async -> LPLinkMetadata? {
|
||||
// iOS 15 is crashing for some reason
|
||||
guard #available(iOS 16, *) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let provider = LPMetadataProvider()
|
||||
|
||||
do {
|
||||
return try await provider.startFetchingMetadata(for: url)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum PreviewState {
|
||||
case not_loaded
|
||||
case loading
|
||||
case loaded(Preview)
|
||||
|
||||
var should_preload: Bool {
|
||||
switch self {
|
||||
case .loaded:
|
||||
return false
|
||||
case .loading:
|
||||
return false
|
||||
case .not_loaded:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PreviewCache {
|
||||
@@ -39,15 +80,6 @@ class PreviewCache {
|
||||
self.image_meta[evid] = image_fill
|
||||
}
|
||||
|
||||
func store(evid: String, preview: LPLinkMetadata?) {
|
||||
switch preview {
|
||||
case .none:
|
||||
previews[evid] = .failed
|
||||
case .some(let meta):
|
||||
previews[evid] = .value(CachedMetadata(meta: meta))
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
self.previews = [:]
|
||||
self.image_meta = [:]
|
||||
|
||||
@@ -89,6 +89,6 @@ func load_relay_filters(_ pubkey: String) -> Set<RelayFilter>? {
|
||||
|
||||
func determine_to_relays(pool: RelayPool, filters: RelayFilters) -> [String] {
|
||||
return pool.descriptors
|
||||
.map { $0.url.absoluteString }
|
||||
.map { $0.url.url.absoluteString }
|
||||
.filter { !filters.is_filtered(timeline: .search, relay_id: $0) }
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ public struct Translator {
|
||||
switch userSettingsStore.translation_service {
|
||||
case .libretranslate:
|
||||
return try await translateWithLibreTranslate(text, from: sourceLanguage, to: targetLanguage)
|
||||
case .nokyctranslate:
|
||||
return try await translateWithNoKYCTranslate(text, from: sourceLanguage, to: targetLanguage)
|
||||
case .deepl:
|
||||
return try await translateWithDeepL(text, from: sourceLanguage, to: targetLanguage)
|
||||
case .none:
|
||||
@@ -85,6 +87,29 @@ public struct Translator {
|
||||
let response: Response = try await decodedData(for: request)
|
||||
return response.translations.map { $0.text }.joined(separator: " ")
|
||||
}
|
||||
|
||||
private func translateWithNoKYCTranslate(_ text: String, from sourceLanguage: String, to targetLanguage: String) async throws -> String? {
|
||||
let url = try makeURL("https://translate.nokyctranslate.com", path: "/translate")
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
struct RequestBody: Encodable {
|
||||
let q: String
|
||||
let source: String
|
||||
let target: String
|
||||
let api_key: String?
|
||||
}
|
||||
let body = RequestBody(q: text, source: sourceLanguage, target: targetLanguage, api_key: userSettingsStore.nokyctranslate_api_key)
|
||||
request.httpBody = try encoder.encode(body)
|
||||
|
||||
struct Response: Decodable {
|
||||
let translatedText: String
|
||||
}
|
||||
let response: Response = try await decodedData(for: request)
|
||||
return response.translatedText
|
||||
}
|
||||
|
||||
private func makeURL(_ baseUrl: String, path: String) throws -> URL {
|
||||
guard var components = URLComponents(string: baseUrl) else {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// BigButton.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-19.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct BigButton: View {
|
||||
let text: String
|
||||
let action: () -> ()
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
init(_ text: String, action: @escaping () -> ()) {
|
||||
self.text = text
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
action()
|
||||
}) {
|
||||
Text(text)
|
||||
.frame(minWidth: 300, maxWidth: .infinity, minHeight: 50, maxHeight: 50, alignment: .center)
|
||||
.foregroundColor(colorScheme == .light ? DamusColors.black : DamusColors.white)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.stroke(colorScheme == .light ? DamusColors.mediumGrey : DamusColors.white, lineWidth: 1)
|
||||
}
|
||||
.padding(EdgeInsets(top: 10, leading: 50, bottom: 25, trailing: 50))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BigButton_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
BigButton("Cancel", action: {})
|
||||
}
|
||||
}
|
||||
@@ -8,46 +8,33 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum ActionBarSheet: Identifiable {
|
||||
case reply
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .reply: return "reply"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EventActionBar: View {
|
||||
let damus_state: DamusState
|
||||
let event: NostrEvent
|
||||
let test_lnurl: String?
|
||||
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||
|
||||
// just used for previews
|
||||
@State var sheet: ActionBarSheet? = nil
|
||||
@State var show_share_sheet: Bool = false
|
||||
@State var show_share_action: Bool = false
|
||||
|
||||
@State var show_repost_action: Bool = false
|
||||
|
||||
@ObservedObject var bar: ActionBarModel
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
|
||||
init(damus_state: DamusState, event: NostrEvent, bar: ActionBarModel? = nil) {
|
||||
self.damus_state = damus_state
|
||||
self.event = event
|
||||
_bar = ObservedObject(wrappedValue: bar ?? make_actionbar_model(ev: event.id, damus: damus_state))
|
||||
}
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
init(damus_state: DamusState, event: NostrEvent, bar: ActionBarModel? = nil, test_lnurl: String? = nil) {
|
||||
self.damus_state = damus_state
|
||||
self.event = event
|
||||
self.test_lnurl = test_lnurl
|
||||
_bar = ObservedObject(wrappedValue: bar ?? make_actionbar_model(ev: event.id, damus: damus_state))
|
||||
_settings = ObservedObject(wrappedValue: damus_state.settings)
|
||||
}
|
||||
|
||||
var lnurl: String? {
|
||||
test_lnurl ?? damus_state.profiles.lookup(id: event.pubkey)?.lnurl
|
||||
damus_state.profiles.lookup(id: event.pubkey)?.lnurl
|
||||
}
|
||||
|
||||
var show_like: Bool {
|
||||
if settings.onlyzaps_mode {
|
||||
if damus_state.settings.onlyzaps_mode {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -59,7 +46,7 @@ struct EventActionBar: View {
|
||||
if damus_state.keypair.privkey != nil {
|
||||
HStack(spacing: 4) {
|
||||
EventActionButton(img: "bubble.left", col: bar.replied ? DamusColors.purple : Color.gray) {
|
||||
notify(.reply, event)
|
||||
notify(.compose, PostAction.replying_to(event))
|
||||
}
|
||||
.accessibilityLabel(NSLocalizedString("Reply", comment: "Accessibility label for reply button"))
|
||||
Text(verbatim: "\(bar.replies > 0 ? "\(bar.replies)" : "")")
|
||||
@@ -74,7 +61,7 @@ struct EventActionBar: View {
|
||||
if bar.boosted {
|
||||
notify(.delete, bar.our_boost)
|
||||
} else {
|
||||
send_boost()
|
||||
self.show_repost_action = true
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(NSLocalizedString("Boosts", comment: "Accessibility label for boosts button"))
|
||||
@@ -112,26 +99,35 @@ struct EventActionBar: View {
|
||||
}
|
||||
.accessibilityLabel(NSLocalizedString("Share", comment: "Button to share a post"))
|
||||
}
|
||||
.sheet(isPresented: $show_share_action) {
|
||||
.onAppear {
|
||||
self.bar.update(damus: damus_state, evid: self.event.id)
|
||||
}
|
||||
.sheet(isPresented: $show_share_action, onDismiss: { self.show_share_action = false }) {
|
||||
if #available(iOS 16.0, *) {
|
||||
ShareAction(event: event, bookmarks: damus_state.bookmarks, show_share_sheet: $show_share_sheet, show_share_action: $show_share_action)
|
||||
ShareAction(event: event, bookmarks: damus_state.bookmarks, show_share: $show_share_sheet)
|
||||
.presentationDetents([.height(300)])
|
||||
.presentationDragIndicator(.visible)
|
||||
} else {
|
||||
if let note_id = bech32_note_id(event.id) {
|
||||
if let url = URL(string: "https://damus.io/" + note_id) {
|
||||
ShareSheet(activityItems: [url])
|
||||
}
|
||||
}
|
||||
ShareAction(event: event, bookmarks: damus_state.bookmarks, show_share: $show_share_sheet)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $show_share_sheet) {
|
||||
.sheet(isPresented: $show_share_sheet, onDismiss: { self.show_share_sheet = false }) {
|
||||
if let note_id = bech32_note_id(event.id) {
|
||||
if let url = URL(string: "https://damus.io/" + note_id) {
|
||||
ShareSheet(activityItems: [url])
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $show_repost_action, onDismiss: { self.show_repost_action = false }) {
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
RepostAction(damus_state: self.damus_state, event: event)
|
||||
.presentationDetents([.height(300)])
|
||||
.presentationDragIndicator(.visible)
|
||||
} else {
|
||||
RepostAction(damus_state: self.damus_state, event: event)
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.update_stats)) { n in
|
||||
let target = n.object as! String
|
||||
guard target == self.event.id else { return }
|
||||
@@ -149,10 +145,6 @@ struct EventActionBar: View {
|
||||
}
|
||||
}
|
||||
|
||||
func send_boost() {
|
||||
notify(.boost, self.event)
|
||||
}
|
||||
|
||||
func send_like() {
|
||||
guard let privkey = damus_state.keypair.privkey else {
|
||||
return
|
||||
@@ -254,8 +246,6 @@ 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: zapbar, test_lnurl: "lnurl")
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// RepostAction.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-19.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct RepostAction: View {
|
||||
let damus_state: DamusState
|
||||
let event: NostrEvent
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("Repost Note", comment: "Title text to indicate that the buttons below are meant to be used to repost a note to others.")
|
||||
.padding()
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(alignment: .top, spacing: 100) {
|
||||
|
||||
ShareActionButton(img: "arrow.2.squarepath", text: NSLocalizedString("Repost", comment: "Button to repost a note")) {
|
||||
dismiss()
|
||||
|
||||
guard let privkey = self.damus_state.keypair.privkey else {
|
||||
return
|
||||
}
|
||||
|
||||
let boost = make_boost_event(pubkey: damus_state.keypair.pubkey, privkey: privkey, boosted: self.event)
|
||||
|
||||
damus_state.postbox.send(boost)
|
||||
}
|
||||
|
||||
ShareActionButton(img: "quote.opening", text: NSLocalizedString("Quote", comment: "Button to compose a quoted note")) {
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
notify(.compose, PostAction.quoting(self.event))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
BigButton(NSLocalizedString("Cancel", comment: "Button to cancel a repost.")) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RepostAction_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
RepostAction(damus_state: test_damus_state(), event: test_event)
|
||||
}
|
||||
}
|
||||
@@ -12,25 +12,22 @@ struct ShareAction: View {
|
||||
let bookmarks: BookmarksManager
|
||||
@State private var isBookmarked: Bool = false
|
||||
|
||||
@Binding var show_share_sheet: Bool
|
||||
@Binding var show_share_action: Bool
|
||||
@Binding var show_share: Bool
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
init(event: NostrEvent, bookmarks: BookmarksManager, show_share_sheet: Binding<Bool>, show_share_action: Binding<Bool>) {
|
||||
init(event: NostrEvent, bookmarks: BookmarksManager, show_share: Binding<Bool>) {
|
||||
let bookmarked = bookmarks.isBookmarked(event)
|
||||
self._isBookmarked = State(initialValue: bookmarked)
|
||||
|
||||
self.bookmarks = bookmarks
|
||||
self.event = event
|
||||
self._show_share_sheet = show_share_sheet
|
||||
self._show_share_action = show_share_action
|
||||
self._show_share = show_share
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
let col = colorScheme == .light ? DamusColors.mediumGrey : DamusColors.white
|
||||
|
||||
VStack {
|
||||
Text("Share Note", comment: "Title text to indicate that the buttons below are meant to be used to share a note with others.")
|
||||
.padding()
|
||||
@@ -40,28 +37,28 @@ struct ShareAction: View {
|
||||
|
||||
HStack(alignment: .top, spacing: 25) {
|
||||
|
||||
ShareActionButton(img: "link", text: NSLocalizedString("Copy Link", comment: "Button to copy link to note"), col: col) {
|
||||
show_share_action = false
|
||||
ShareActionButton(img: "link", text: NSLocalizedString("Copy Link", comment: "Button to copy link to note")) {
|
||||
dismiss()
|
||||
UIPasteboard.general.string = "https://damus.io/" + (bech32_note_id(event.id) ?? event.id)
|
||||
}
|
||||
|
||||
let bookmarkImg = isBookmarked ? "bookmark.slash" : "bookmark"
|
||||
let bookmarkTxt = isBookmarked ? NSLocalizedString("Remove Bookmark", comment: "Button text to remove bookmark from a note.") : NSLocalizedString("Add Bookmark", comment: "Button text to add bookmark to a note.")
|
||||
let boomarkCol = isBookmarked ? Color(.red) : col
|
||||
let boomarkCol = isBookmarked ? Color(.red) : nil
|
||||
ShareActionButton(img: bookmarkImg, text: bookmarkTxt, col: boomarkCol) {
|
||||
show_share_action = false
|
||||
dismiss()
|
||||
self.bookmarks.updateBookmark(event)
|
||||
isBookmarked = self.bookmarks.isBookmarked(event)
|
||||
}
|
||||
|
||||
ShareActionButton(img: "globe", text: NSLocalizedString("Broadcast", comment: "Button to broadcast note to all your relays"), col: col) {
|
||||
show_share_action = false
|
||||
ShareActionButton(img: "globe", text: NSLocalizedString("Broadcast", comment: "Button to broadcast note to all your relays")) {
|
||||
dismiss()
|
||||
NotificationCenter.default.post(name: .broadcast_event, object: event)
|
||||
}
|
||||
|
||||
ShareActionButton(img: "square.and.arrow.up", text: NSLocalizedString("Share Via...", comment: "Button to present iOS share sheet"), col: col) {
|
||||
show_share_action = false
|
||||
show_share_sheet = true
|
||||
ShareActionButton(img: "square.and.arrow.up", text: NSLocalizedString("Share Via...", comment: "Button to present iOS share sheet")) {
|
||||
show_share = true
|
||||
dismiss()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -69,42 +66,11 @@ struct ShareAction: View {
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
|
||||
Button(action: {
|
||||
show_share_action = false
|
||||
}) {
|
||||
Text(NSLocalizedString("Cancel", comment: "Button to cancel a repost."))
|
||||
.frame(minWidth: 300, maxWidth: .infinity, minHeight: 50, maxHeight: 50, alignment: .center)
|
||||
.foregroundColor(colorScheme == .light ? DamusColors.black : DamusColors.white)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.stroke(colorScheme == .light ? DamusColors.mediumGrey : DamusColors.white, lineWidth: 1)
|
||||
}
|
||||
.padding(EdgeInsets(top: 10, leading: 50, bottom: 25, trailing: 50))
|
||||
BigButton(NSLocalizedString("Cancel", comment: "Button to cancel a repost.")) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ShareActionButton(img: String, text: String, col: Color, action: @escaping () -> ()) -> some View {
|
||||
Button(action: action) {
|
||||
VStack() {
|
||||
Image(systemName: img)
|
||||
.foregroundColor(col)
|
||||
.font(.system(size: 23, weight: .bold))
|
||||
.overlay {
|
||||
Circle()
|
||||
.stroke(col, lineWidth: 1)
|
||||
.frame(width: 55.0, height: 55.0)
|
||||
}
|
||||
.frame(height: 25)
|
||||
Text(verbatim: text)
|
||||
.foregroundColor(col)
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// ShareActionButton.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-19.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ShareActionButton: View {
|
||||
let img: String
|
||||
let text: String
|
||||
let color: Color?
|
||||
let action: () -> ()
|
||||
|
||||
init(img: String, text: String, col: Color?, action: @escaping () -> ()) {
|
||||
self.img = img
|
||||
self.text = text
|
||||
self.color = col
|
||||
self.action = action
|
||||
}
|
||||
|
||||
init(img: String, text: String, action: @escaping () -> ()) {
|
||||
self.img = img
|
||||
self.text = text
|
||||
self.action = action
|
||||
self.color = nil
|
||||
}
|
||||
|
||||
var col: Color {
|
||||
colorScheme == .light ? DamusColors.mediumGrey : DamusColors.white
|
||||
}
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack() {
|
||||
Image(systemName: img)
|
||||
.foregroundColor(col)
|
||||
.font(.system(size: 23, weight: .bold))
|
||||
.overlay {
|
||||
Circle()
|
||||
.stroke(col, lineWidth: 1)
|
||||
.frame(width: 55.0, height: 55.0)
|
||||
}
|
||||
.frame(height: 25)
|
||||
Text(verbatim: text)
|
||||
.foregroundColor(col)
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ShareActionButton_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ShareActionButton(img: "figure.flexibility", text: "Stretch", action: {})
|
||||
}
|
||||
}
|
||||
@@ -62,13 +62,8 @@ func create_upload_request(mediaToUpload: MediaUpload, mediaUploader: MediaUploa
|
||||
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(for: request, delegate: progress)
|
||||
|
||||
guard let responseString = String(data: data, encoding: String.Encoding(rawValue: String.Encoding.utf8.rawValue)) else {
|
||||
print("Upload failed getting response string")
|
||||
return .failed(nil)
|
||||
}
|
||||
|
||||
guard let url = mediaUploader.getMediaURL(from: responseString, mediaIsImage: mediaToUpload.is_image) else {
|
||||
|
||||
guard let url = mediaUploader.getMediaURL(from: data) else {
|
||||
print("Upload failed getting media url")
|
||||
return .failed(nil)
|
||||
}
|
||||
@@ -144,28 +139,27 @@ enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
|
||||
var postAPI: String {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
return "https://nostr.build/upload.php"
|
||||
return "https://nostr.build/api/upload/ios.php"
|
||||
case .nostrImg:
|
||||
return "https://nostrimg.com/api/upload"
|
||||
}
|
||||
}
|
||||
|
||||
func getMediaURL(from responseString: String, mediaIsImage: Bool) -> String? {
|
||||
func getMediaURL(from data: Data) -> String? {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
guard let startIndex = responseString.range(of: "nostr.build_")?.lowerBound else {
|
||||
do {
|
||||
return try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? String
|
||||
} catch {
|
||||
print("Failed JSONSerialization")
|
||||
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 = mediaIsImage ? "https://nostr.build/i/\(nostrBuildImageName)" : "https://nostr.build/av/\(nostrBuildImageName)"
|
||||
return nostrBuildURL
|
||||
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct InnerBannerImageView: View {
|
||||
|
||||
let disable_animation: Bool
|
||||
let url: URL?
|
||||
let defaultImage = UIImage(named: "profile-banner") ?? UIImage()
|
||||
|
||||
@@ -19,7 +19,7 @@ struct InnerBannerImageView: View {
|
||||
|
||||
if (url != nil) {
|
||||
KFAnimatedImage(url)
|
||||
.imageContext(.banner)
|
||||
.imageContext(.banner, disable_animation: disable_animation)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
@@ -35,19 +35,21 @@ struct InnerBannerImageView: View {
|
||||
}
|
||||
|
||||
struct BannerImageView: View {
|
||||
let disable_animation: Bool
|
||||
let pubkey: String
|
||||
let profiles: Profiles
|
||||
|
||||
@State var banner: String?
|
||||
|
||||
init (pubkey: String, profiles: Profiles, banner: String? = nil) {
|
||||
init (pubkey: String, profiles: Profiles, disable_animation: Bool, banner: String? = nil) {
|
||||
self.pubkey = pubkey
|
||||
self.profiles = profiles
|
||||
self._banner = State(initialValue: banner)
|
||||
self.disable_animation = disable_animation
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
InnerBannerImageView(url: get_banner_url(banner: banner, pubkey: pubkey, profiles: profiles))
|
||||
InnerBannerImageView(disable_animation: disable_animation, url: get_banner_url(banner: banner, pubkey: pubkey, profiles: profiles))
|
||||
.onReceive(handle_notify(.profile_updated)) { notif in
|
||||
let updated = notif.object as! ProfileUpdate
|
||||
|
||||
@@ -76,7 +78,9 @@ struct BannerImageView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
BannerImageView(
|
||||
pubkey: pubkey,
|
||||
profiles: make_preview_profiles(pubkey))
|
||||
profiles: make_preview_profiles(pubkey),
|
||||
disable_animation: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ struct BookmarksView: View {
|
||||
let state: DamusState
|
||||
private let noneFilter: (NostrEvent) -> Bool = { _ in true }
|
||||
private let bookmarksTitle = NSLocalizedString("Bookmarks", comment: "Title of bookmarks view")
|
||||
@State private var clearAllAlert: Bool = false
|
||||
|
||||
@ObservedObject var manager: BookmarksManager
|
||||
|
||||
@@ -45,10 +46,17 @@ struct BookmarksView: View {
|
||||
.toolbar {
|
||||
if !bookmarks.isEmpty {
|
||||
Button(NSLocalizedString("Clear All", comment: "Button for clearing bookmarks data.")) {
|
||||
manager.clearAll()
|
||||
clearAllAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(NSLocalizedString("Are you sure you want to delete all of your bookmarks?", comment: "Alert for deleting all of the bookmarks."), isPresented: $clearAllAlert) {
|
||||
Button(NSLocalizedString("Cancel", comment: "Cancel deleting bookmarks."), role: .cancel) {
|
||||
}
|
||||
Button(NSLocalizedString("Continue", comment: "Continue with bookmarks.")) {
|
||||
manager.clearAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,11 +71,15 @@ struct ChatView: View {
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var disable_animation: Bool {
|
||||
self.damus_state.settings.disable_animation
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack {
|
||||
if is_active || just_started {
|
||||
ProfilePicView(pubkey: event.pubkey, size: 32, highlight: is_active ? .main : .none, profiles: damus_state.profiles)
|
||||
ProfilePicView(pubkey: event.pubkey, size: 32, highlight: is_active ? .main : .none, profiles: damus_state.profiles, disable_animation: disable_animation)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -43,7 +43,7 @@ struct DMChatView: View {
|
||||
let profile_page = ProfileView(damus_state: damus_state, pubkey: pubkey)
|
||||
return NavigationLink(destination: profile_page) {
|
||||
HStack {
|
||||
ProfilePicView(pubkey: pubkey, size: 24, highlight: .none, profiles: damus_state.profiles)
|
||||
ProfilePicView(pubkey: pubkey, size: 24, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
|
||||
ProfileName(pubkey: pubkey, profile: profile, damus: damus_state, show_friend_confirmed: true)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ struct EventView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
if event.known_kind == .boost {
|
||||
if let inner_ev = event.inner_event {
|
||||
if let inner_ev = event.get_inner_event(cache: damus.events) {
|
||||
RepostedEvent(damus: damus, event: event, inner_ev: inner_ev, options: options)
|
||||
} else {
|
||||
EmptyView()
|
||||
|
||||
@@ -70,7 +70,7 @@ struct BuilderEventView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
if let event {
|
||||
let ev = event.inner_event ?? event
|
||||
let ev = event.get_inner_event(cache: damus.events) ?? event
|
||||
let thread = ThreadModel(event: ev, damus_state: damus)
|
||||
let dest = ThreadView(state: damus, thread: thread)
|
||||
NavigationLink(destination: dest) {
|
||||
|
||||
@@ -28,11 +28,15 @@ struct EventProfile: View {
|
||||
eventview_pfp_size(size)
|
||||
}
|
||||
|
||||
var disable_animation: Bool {
|
||||
damus_state.settings.disable_animation
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center) {
|
||||
VStack {
|
||||
NavigationLink(destination: ProfileView(damus_state: damus_state, pubkey: pubkey)) {
|
||||
ProfilePicView(pubkey: pubkey, size: pfp_size, highlight: .none, profiles: damus_state.profiles)
|
||||
ProfilePicView(pubkey: pubkey, size: pfp_size, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,9 +31,9 @@ struct MutedEventView: View {
|
||||
.foregroundColor(DamusColors.adaptableGrey)
|
||||
|
||||
HStack {
|
||||
Text("Post from a user you've blocked", comment: "Text to indicate that what is being shown is a post from a user who has been blocked.")
|
||||
Text("Post from a user you've muted", comment: "Text to indicate that what is being shown is a post from a user who has been muted.")
|
||||
Spacer()
|
||||
Button(shown ? NSLocalizedString("Hide", comment: "Button to hide a post from a user who has been blocked.") : NSLocalizedString("Show", comment: "Button to show a post from a user who has been blocked.")) {
|
||||
Button(shown ? NSLocalizedString("Hide", comment: "Button to hide a post from a user who has been muted.") : NSLocalizedString("Show", comment: "Button to show a post from a user who has been muted.")) {
|
||||
shown.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,30 @@ struct EventViewOptions: OptionSet {
|
||||
static let embedded: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested]
|
||||
}
|
||||
|
||||
struct RelativeTime: View {
|
||||
@ObservedObject var time: RelativeTimeModel
|
||||
|
||||
var body: some View {
|
||||
Text(verbatim: "\(time.value)")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
struct TextEvent: View {
|
||||
let damus: DamusState
|
||||
let event: NostrEvent
|
||||
let pubkey: String
|
||||
let options: EventViewOptions
|
||||
let evdata: EventData
|
||||
|
||||
init(damus: DamusState, event: NostrEvent, pubkey: String, options: EventViewOptions) {
|
||||
self.damus = damus
|
||||
self.event = event
|
||||
self.pubkey = pubkey
|
||||
self.options = options
|
||||
self.evdata = damus.events.get_cache_data(event.id)
|
||||
}
|
||||
|
||||
var has_action_bar: Bool {
|
||||
!options.contains(.no_action_bar)
|
||||
@@ -55,7 +74,7 @@ struct TextEvent: View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
ProfileName(is_anon: is_anon)
|
||||
TimeDot
|
||||
Time
|
||||
RelativeTime(time: self.evdata.relative_time)
|
||||
Spacer()
|
||||
ContextButton
|
||||
}
|
||||
@@ -106,12 +125,6 @@ struct TextEvent: View {
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
var Time: some View {
|
||||
Text(verbatim: "\(format_relative_time(event.created_at))")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
var ContextButton: some View {
|
||||
EventMenuContext(event: event, keypair: damus.keypair, target_pubkey: event.pubkey, bookmarks: damus.bookmarks, muted_threads: damus.muted_threads)
|
||||
.padding([.bottom], 4)
|
||||
@@ -124,7 +137,17 @@ struct TextEvent: View {
|
||||
}
|
||||
|
||||
func EvBody(options: EventViewOptions) -> some View {
|
||||
return EventBody(damus_state: damus, event: event, size: .normal, options: options)
|
||||
let show_imgs = should_show_images(settings: damus.settings, contacts: damus.contacts, ev: event, our_pubkey: damus.pubkey)
|
||||
let artifacts = damus.events.get_cache_data(event.id).artifacts.artifacts ?? .just_content(event.get_content(damus.keypair.privkey))
|
||||
return NoteContentView(
|
||||
damus_state: damus,
|
||||
event: event,
|
||||
show_images: show_imgs,
|
||||
size: .normal,
|
||||
artifacts: artifacts,
|
||||
options: options
|
||||
)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
func Mention(_ mention: Mention) -> some View {
|
||||
@@ -162,6 +185,7 @@ struct TextEvent: View {
|
||||
TopPart(is_anon: is_anon)
|
||||
|
||||
ReplyPart
|
||||
|
||||
EvBody(options: self.options)
|
||||
|
||||
if let mention = get_mention() {
|
||||
|
||||
@@ -13,6 +13,7 @@ struct ImagePicker: UIViewControllerRepresentable {
|
||||
@Environment(\.presentationMode)
|
||||
private var presentationMode
|
||||
|
||||
let uploader: MediaUploader
|
||||
let sourceType: UIImagePickerController.SourceType
|
||||
let pubkey: String
|
||||
@Binding var image_upload_confirm: Bool
|
||||
@@ -108,9 +109,8 @@ struct ImagePicker: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
|
||||
let picker = UIImagePickerController()
|
||||
picker.sourceType = sourceType
|
||||
let mediaUploader = get_media_uploader(pubkey)
|
||||
picker.mediaTypes = ["public.image", "com.compuserve.gif"]
|
||||
if mediaUploader.supportsVideo && !imagesOnly {
|
||||
if uploader.supportsVideo && !imagesOnly {
|
||||
picker.mediaTypes.append("public.movie")
|
||||
}
|
||||
picker.delegate = context.coordinator
|
||||
|
||||
@@ -9,14 +9,14 @@ import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
|
||||
// lots of overlap between this and ImageContainerView
|
||||
struct ImageContainerView: View {
|
||||
|
||||
let url: URL?
|
||||
|
||||
@State private var image: UIImage?
|
||||
@State private var showShareSheet = false
|
||||
|
||||
let disable_animation: Bool
|
||||
|
||||
private struct ImageHandler: ImageModifier {
|
||||
@Binding var handler: UIImage?
|
||||
|
||||
@@ -29,7 +29,7 @@ struct ImageContainerView: View {
|
||||
var body: some View {
|
||||
|
||||
KFAnimatedImage(url)
|
||||
.imageContext(.note)
|
||||
.imageContext(.note, disable_animation: disable_animation)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
@@ -46,6 +46,6 @@ let test_image_url = URL(string: "https://jb55.com/red-me.jpg")!
|
||||
|
||||
struct ImageContainerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ImageContainerView(url: test_image_url)
|
||||
ImageContainerView(url: test_image_url, disable_animation: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ struct ImageView: View {
|
||||
@State private var selectedIndex = 0
|
||||
@State var showMenu = true
|
||||
|
||||
let disable_animation: Bool
|
||||
|
||||
var tabViewIndicator: some View {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(urls.indices, id: \.self) { index in
|
||||
@@ -37,7 +39,7 @@ struct ImageView: View {
|
||||
TabView(selection: $selectedIndex) {
|
||||
ForEach(urls.indices, id: \.self) { index in
|
||||
ZoomableScrollView {
|
||||
ImageContainerView(url: urls[index])
|
||||
ImageContainerView(url: urls[index], disable_animation: disable_animation)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(.top, Theme.safeAreaInsets?.top)
|
||||
.padding(.bottom, Theme.safeAreaInsets?.bottom)
|
||||
@@ -77,6 +79,6 @@ struct ImageView: View {
|
||||
|
||||
struct ImageView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ImageView(urls: [URL(string: "https://jb55.com/red-me.jpg")])
|
||||
ImageView(urls: [URL(string: "https://jb55.com/red-me.jpg")], disable_animation: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct ProfileImageContainerView: View {
|
||||
|
||||
let url: URL?
|
||||
|
||||
@State private var image: UIImage?
|
||||
@State private var showShareSheet = false
|
||||
|
||||
let disable_animation: Bool
|
||||
|
||||
private struct ImageHandler: ImageModifier {
|
||||
@Binding var handler: UIImage?
|
||||
|
||||
@@ -26,7 +27,7 @@ struct ProfileImageContainerView: View {
|
||||
var body: some View {
|
||||
|
||||
KFAnimatedImage(url)
|
||||
.imageContext(.pfp)
|
||||
.imageContext(.pfp, disable_animation: disable_animation)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
@@ -61,9 +62,9 @@ struct NavDismissBarView: View {
|
||||
}
|
||||
|
||||
struct ProfilePicImageView: View {
|
||||
|
||||
let pubkey: String
|
||||
let profiles: Profiles
|
||||
let disable_animation: Bool
|
||||
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
@@ -73,7 +74,7 @@ struct ProfilePicImageView: View {
|
||||
.ignoresSafeArea()
|
||||
|
||||
ZoomableScrollView {
|
||||
ProfileImageContainerView(url: get_profile_url(picture: nil, pubkey: pubkey, profiles: profiles))
|
||||
ProfileImageContainerView(url: get_profile_url(picture: nil, pubkey: pubkey, profiles: profiles), disable_animation: disable_animation)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(.top, Theme.safeAreaInsets?.top)
|
||||
.padding(.bottom, Theme.safeAreaInsets?.bottom)
|
||||
@@ -94,6 +95,8 @@ struct ProfileZoomView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ProfilePicImageView(
|
||||
pubkey: pubkey,
|
||||
profiles: make_preview_profiles(pubkey))
|
||||
profiles: make_preview_profiles(pubkey),
|
||||
disable_animation: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+101
-88
@@ -36,6 +36,7 @@ struct LoginView: View {
|
||||
@State var key: String = ""
|
||||
@State var is_pubkey: Bool = false
|
||||
@State var error: String? = nil
|
||||
@State private var credential_handler = CredentialHandler()
|
||||
|
||||
func get_error(parsed_key: ParsedKey?) -> String? {
|
||||
if self.error != nil {
|
||||
@@ -43,85 +44,12 @@ struct LoginView: View {
|
||||
}
|
||||
|
||||
if !key.isEmpty && parsed_key == nil {
|
||||
return "Invalid key"
|
||||
return LoginError.invalid_key.errorDescription
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func process_login(_ key: ParsedKey, is_pubkey: Bool) -> Bool {
|
||||
switch key {
|
||||
case .priv(let priv):
|
||||
do {
|
||||
try save_privkey(privkey: priv)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let pk = privkey_to_pubkey(privkey: priv) else {
|
||||
return false
|
||||
}
|
||||
save_pubkey(pubkey: pk)
|
||||
|
||||
case .pub(let pub):
|
||||
do {
|
||||
try clear_saved_privkey()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
save_pubkey(pubkey: pub)
|
||||
|
||||
case .nip05(let id):
|
||||
Task.init {
|
||||
guard let nip05 = await get_nip05_pubkey(id: id) else {
|
||||
self.error = "Could not fetch pubkey"
|
||||
return
|
||||
}
|
||||
|
||||
// this is a weird way to login anyways
|
||||
/*
|
||||
var bootstrap_relays = load_bootstrap_relays(pubkey: nip05.pubkey)
|
||||
for relay in nip05.relays {
|
||||
if !(bootstrap_relays.contains { $0 == relay }) {
|
||||
bootstrap_relays.append(relay)
|
||||
}
|
||||
}
|
||||
*/
|
||||
save_pubkey(pubkey: nip05.pubkey)
|
||||
|
||||
notify(.login, ())
|
||||
}
|
||||
|
||||
|
||||
case .hex(let hexstr):
|
||||
if is_pubkey {
|
||||
do {
|
||||
try clear_saved_privkey()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
save_pubkey(pubkey: hexstr)
|
||||
} else {
|
||||
do {
|
||||
try save_privkey(privkey: hexstr)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let pk = privkey_to_pubkey(privkey: hexstr) else {
|
||||
return false
|
||||
}
|
||||
save_pubkey(pubkey: pk)
|
||||
}
|
||||
}
|
||||
|
||||
notify(.login, ())
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
DamusGradient()
|
||||
@@ -158,17 +86,26 @@ struct LoginView: View {
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let p = parsed {
|
||||
DamusWhiteButton(NSLocalizedString("Login", comment: "Button to log into account.")) {
|
||||
if !process_login(p, is_pubkey: self.is_pubkey) {
|
||||
self.error = NSLocalizedString("Invalid key", comment: "Error message indicating that an invalid account key was entered for login.")
|
||||
Task {
|
||||
do {
|
||||
try await process_login(p, is_pubkey: is_pubkey)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onAppear {
|
||||
credential_handler.check_credentials()
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.navigationBarItems(leading: BackNav())
|
||||
}
|
||||
@@ -212,6 +149,71 @@ func parse_key(_ thekey: String) -> ParsedKey? {
|
||||
return nil
|
||||
}
|
||||
|
||||
enum LoginError: LocalizedError {
|
||||
case invalid_key
|
||||
case nip05_failed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalid_key:
|
||||
return NSLocalizedString("Invalid key", comment: "Error message indicating that an invalid account key was entered for login.")
|
||||
case .nip05_failed:
|
||||
return "Could not fetch pubkey"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func process_login(_ key: ParsedKey, is_pubkey: Bool) async throws {
|
||||
switch key {
|
||||
case .priv(let priv):
|
||||
try handle_privkey(priv)
|
||||
case .pub(let pub):
|
||||
try clear_saved_privkey()
|
||||
save_pubkey(pubkey: pub)
|
||||
|
||||
case .nip05(let id):
|
||||
guard let nip05 = await get_nip05_pubkey(id: id) else {
|
||||
throw LoginError.nip05_failed
|
||||
}
|
||||
|
||||
// this is a weird way to login anyways
|
||||
/*
|
||||
var bootstrap_relays = load_bootstrap_relays(pubkey: nip05.pubkey)
|
||||
for relay in nip05.relays {
|
||||
if !(bootstrap_relays.contains { $0 == relay }) {
|
||||
bootstrap_relays.append(relay)
|
||||
}
|
||||
}
|
||||
*/
|
||||
save_pubkey(pubkey: nip05.pubkey)
|
||||
|
||||
case .hex(let hexstr):
|
||||
if is_pubkey {
|
||||
try clear_saved_privkey()
|
||||
save_pubkey(pubkey: hexstr)
|
||||
} else {
|
||||
try handle_privkey(hexstr)
|
||||
}
|
||||
}
|
||||
|
||||
func handle_privkey(_ privkey: String) throws {
|
||||
try save_privkey(privkey: privkey)
|
||||
|
||||
guard let pk = privkey_to_pubkey(privkey: privkey) else {
|
||||
throw LoginError.invalid_key
|
||||
}
|
||||
|
||||
if let pub = bech32_pubkey(pk), let priv = bech32_privkey(privkey) {
|
||||
CredentialHandler().save_credential(pubkey: pub, privkey: priv)
|
||||
}
|
||||
save_pubkey(pubkey: pk)
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
notify(.login, ())
|
||||
}
|
||||
}
|
||||
|
||||
struct NIP05Result: Decodable {
|
||||
let names: Dictionary<String, String>
|
||||
let relays: Dictionary<String, [String]>?
|
||||
@@ -268,18 +270,29 @@ struct KeyInput: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TextField("", text: key)
|
||||
.placeholder(when: key.wrappedValue.isEmpty) {
|
||||
Text(title).foregroundColor(.white.opacity(0.6))
|
||||
ZStack(alignment: .leading) {
|
||||
TextField("", text: key)
|
||||
.placeholder(when: key.wrappedValue.isEmpty) {
|
||||
Text(title).foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
.padding()
|
||||
.padding(.leading, 20)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 4.0).opacity(0.2)
|
||||
}
|
||||
.autocapitalization(.none)
|
||||
.foregroundColor(.white)
|
||||
.font(.body.monospaced())
|
||||
.textContentType(.password)
|
||||
|
||||
Label("", systemImage: "doc.on.clipboard")
|
||||
.padding(.leading, 10)
|
||||
.onTapGesture {
|
||||
if let pastedkey = UIPasteboard.general.string {
|
||||
self.key.wrappedValue = pastedkey
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 4.0).opacity(0.2)
|
||||
}
|
||||
.autocapitalization(.none)
|
||||
.foregroundColor(.white)
|
||||
.font(.body.monospaced())
|
||||
.textContentType(.password)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ func show_indicator(timeline: Timeline, current: NewEventsBits, indicator_settin
|
||||
struct TabButton: View {
|
||||
let timeline: Timeline
|
||||
let img: String
|
||||
@Binding var selected: Timeline?
|
||||
@Binding var selected: Timeline
|
||||
@Binding var new_events: NewEventsBits
|
||||
|
||||
let settings: UserSettingsStore
|
||||
@@ -75,7 +75,7 @@ struct TabButton: View {
|
||||
|
||||
struct TabBar: View {
|
||||
@Binding var new_events: NewEventsBits
|
||||
@Binding var selected: Timeline?
|
||||
@Binding var selected: Timeline
|
||||
|
||||
let settings: UserSettingsStore
|
||||
let action: (Timeline) -> ()
|
||||
|
||||
@@ -29,7 +29,7 @@ struct MutelistView: View {
|
||||
damus_state.postbox.send(new_ev)
|
||||
users = get_mutelist_users(new_ev)
|
||||
} label: {
|
||||
Label(NSLocalizedString("Delete", comment: "Button to remove a user from their blocklist."), systemImage: "trash")
|
||||
Label(NSLocalizedString("Delete", comment: "Button to remove a user from their mutelist."), systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
|
||||
@@ -30,8 +30,12 @@ struct NoteContentView: View {
|
||||
let preview_height: CGFloat?
|
||||
let options: EventViewOptions
|
||||
|
||||
@State var artifacts: NoteArtifacts
|
||||
@State var preview: LinkViewRepresentable?
|
||||
@ObservedObject var artifacts_model: NoteArtifactsModel
|
||||
@ObservedObject var preview_model: PreviewModel
|
||||
|
||||
var artifacts: NoteArtifacts {
|
||||
return self.artifacts_model.state.artifacts ?? .just_content(event.get_content(damus_state.keypair.privkey))
|
||||
}
|
||||
|
||||
init(damus_state: DamusState, event: NostrEvent, show_images: Bool, size: EventViewKind, artifacts: NoteArtifacts, options: EventViewOptions) {
|
||||
self.damus_state = damus_state
|
||||
@@ -39,16 +43,10 @@ struct NoteContentView: View {
|
||||
self.show_images = show_images
|
||||
self.size = size
|
||||
self.options = options
|
||||
self._artifacts = State(initialValue: artifacts)
|
||||
self.preview_height = lookup_cached_preview_size(previews: damus_state.previews, evid: event.id)
|
||||
self._preview = State(initialValue: load_cached_preview(previews: damus_state.previews, evid: event.id))
|
||||
if let cache = damus_state.events.lookup_artifacts(evid: event.id) {
|
||||
self._artifacts = State(initialValue: cache)
|
||||
} else {
|
||||
let artifacts = render_note_content(ev: event, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey)
|
||||
damus_state.events.store_artifacts(evid: event.id, artifacts: artifacts)
|
||||
self._artifacts = State(initialValue: artifacts)
|
||||
}
|
||||
let cached = damus_state.events.get_cache_data(event.id)
|
||||
self._preview_model = ObservedObject(wrappedValue: cached.preview_model)
|
||||
self._artifacts_model = ObservedObject(wrappedValue: cached.artifacts_model)
|
||||
}
|
||||
|
||||
var truncate: Bool {
|
||||
@@ -59,6 +57,16 @@ struct NoteContentView: View {
|
||||
return options.contains(.pad_content)
|
||||
}
|
||||
|
||||
var preview: LinkViewRepresentable? {
|
||||
guard show_images,
|
||||
case .loaded(let preview) = preview_model.state,
|
||||
case .value(let cached) = preview else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return LinkViewRepresentable(meta: .linkmeta(cached))
|
||||
}
|
||||
|
||||
var truncatedText: some View {
|
||||
Group {
|
||||
if truncate {
|
||||
@@ -72,7 +80,7 @@ struct NoteContentView: View {
|
||||
}
|
||||
|
||||
var invoicesView: some View {
|
||||
InvoicesView(our_pubkey: damus_state.keypair.pubkey, invoices: artifacts.invoices)
|
||||
InvoicesView(our_pubkey: damus_state.keypair.pubkey, invoices: artifacts.invoices, settings: damus_state.settings)
|
||||
}
|
||||
|
||||
var translateView: some View {
|
||||
@@ -123,10 +131,10 @@ struct NoteContentView: View {
|
||||
}
|
||||
|
||||
if show_images && artifacts.images.count > 0 {
|
||||
ImageCarousel(previews: damus_state.previews, evid: event.id, urls: artifacts.images)
|
||||
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.images)
|
||||
} else if !show_images && artifacts.images.count > 0 {
|
||||
ZStack {
|
||||
ImageCarousel(previews: damus_state.previews, evid: event.id, urls: artifacts.images)
|
||||
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.images)
|
||||
Blur()
|
||||
.disabled(true)
|
||||
}
|
||||
@@ -151,6 +159,14 @@ struct NoteContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func load() async {
|
||||
guard let plan = get_preload_plan(cache: damus_state.events.get_cache_data(event.id), ev: event, our_keypair: damus_state.keypair, settings: damus_state.settings) else {
|
||||
return
|
||||
}
|
||||
|
||||
await preload_event(plan: plan, profiles: damus_state.profiles, our_keypair: damus_state.keypair, settings: damus_state.settings)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MainContent
|
||||
.onReceive(handle_notify(.profile_updated)) { notif in
|
||||
@@ -160,7 +176,11 @@ struct NoteContentView: View {
|
||||
switch block {
|
||||
case .mention(let m):
|
||||
if m.type == .pubkey && m.ref.ref_id == profile.pubkey {
|
||||
self.artifacts = render_note_content(ev: event, profiles: damus_state.profiles, privkey: damus_state.keypair.privkey)
|
||||
self.artifacts_model.state = .loading
|
||||
Task.init {
|
||||
await load()
|
||||
}
|
||||
return
|
||||
}
|
||||
case .relay: return
|
||||
case .text: return
|
||||
@@ -171,39 +191,10 @@ struct NoteContentView: View {
|
||||
}
|
||||
}
|
||||
.task {
|
||||
guard self.preview == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
if show_images, artifacts.links.count == 1 {
|
||||
let meta = await getMetaData(for: artifacts.links.first!)
|
||||
|
||||
damus_state.previews.store(evid: self.event.id, preview: meta)
|
||||
guard case .value(let cached) = damus_state.previews.lookup(self.event.id) else {
|
||||
return
|
||||
}
|
||||
let view = LinkViewRepresentable(meta: .linkmeta(cached))
|
||||
|
||||
self.preview = view
|
||||
}
|
||||
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
func getMetaData(for url: URL) async -> LPLinkMetadata? {
|
||||
// iOS 15 is crashing for some reason
|
||||
guard #available(iOS 16, *) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let provider = LPMetadataProvider()
|
||||
|
||||
do {
|
||||
return try await provider.startFetchingMetadata(for: url)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ImageName {
|
||||
@@ -274,6 +265,42 @@ struct NoteArtifacts: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
enum NoteArtifactState {
|
||||
case not_loaded
|
||||
case loading
|
||||
case loaded(NoteArtifacts)
|
||||
|
||||
var artifacts: NoteArtifacts? {
|
||||
if case .loaded(let artifacts) = self {
|
||||
return artifacts
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var is_loaded: Bool {
|
||||
switch self {
|
||||
case .not_loaded:
|
||||
return false
|
||||
case .loading:
|
||||
return false
|
||||
case .loaded:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var should_preload: Bool {
|
||||
switch self {
|
||||
case .loaded:
|
||||
return false
|
||||
case .loading:
|
||||
return false
|
||||
case .not_loaded:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: String?) -> NoteArtifacts {
|
||||
let blocks = ev.blocks(privkey)
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ struct EventGroupView: View {
|
||||
.frame(width: PFP_SIZE + 10)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
ProfilePicturesView(state: state, events: group.events)
|
||||
ProfilePicturesView(state: state, pubkeys: group.events.map { $0.pubkey })
|
||||
|
||||
if let event {
|
||||
let thread = ThreadModel(event: event, damus_state: state)
|
||||
|
||||
@@ -28,7 +28,7 @@ enum FriendFilter: String, StringCodable {
|
||||
case .all:
|
||||
return true
|
||||
case .friends:
|
||||
return contacts.is_in_friendosphere(pubkey)
|
||||
return contacts.is_friend_or_self(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,25 +74,13 @@ class NotificationFilter: ObservableObject, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
enum NotificationFilterState: String, StringCodable {
|
||||
enum NotificationFilterState: String {
|
||||
case all
|
||||
case zaps
|
||||
case replies
|
||||
|
||||
init?(from string: String) {
|
||||
guard let val = NotificationFilterState(rawValue: string) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self = val
|
||||
}
|
||||
|
||||
func to_string() -> String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
func is_other( item: NotificationItem) -> Bool {
|
||||
return item.is_zap == nil && item.is_reply == nil
|
||||
item.is_zap == nil && item.is_reply == nil
|
||||
}
|
||||
|
||||
func filter(_ item: NotificationItem) -> Bool {
|
||||
@@ -110,7 +98,8 @@ enum NotificationFilterState: String, StringCodable {
|
||||
struct NotificationsView: View {
|
||||
let state: DamusState
|
||||
@ObservedObject var notifications: NotificationsModel
|
||||
@StateObject var filter_state: NotificationFilter = NotificationFilter()
|
||||
@StateObject var filter = NotificationFilter()
|
||||
@SceneStorage("NotificationsView.filter_state") var filter_state: NotificationFilterState = .all
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@@ -123,14 +112,14 @@ struct NotificationsView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $filter_state.state) {
|
||||
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
|
||||
|
||||
NotificationTab(
|
||||
NotificationFilter(
|
||||
state: .all,
|
||||
fine_filter: filter_state.fine_filter
|
||||
fine_filter: filter.fine_filter
|
||||
)
|
||||
)
|
||||
.tag(NotificationFilterState.all)
|
||||
@@ -138,7 +127,7 @@ struct NotificationsView: View {
|
||||
NotificationTab(
|
||||
NotificationFilter(
|
||||
state: .zaps,
|
||||
fine_filter: filter_state.fine_filter
|
||||
fine_filter: filter.fine_filter
|
||||
)
|
||||
)
|
||||
.tag(NotificationFilterState.zaps)
|
||||
@@ -146,31 +135,31 @@ struct NotificationsView: View {
|
||||
NotificationTab(
|
||||
NotificationFilter(
|
||||
state: .replies,
|
||||
fine_filter: filter_state.fine_filter
|
||||
fine_filter: filter.fine_filter
|
||||
)
|
||||
)
|
||||
.tag(NotificationFilterState.replies)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if would_filter_non_friends_from_notifications(contacts: state.contacts, state: self.filter_state.state, items: self.notifications.notifications) {
|
||||
FriendsButton(filter: $filter_state.fine_filter)
|
||||
if would_filter_non_friends_from_notifications(contacts: state.contacts, state: filter_state, items: self.notifications.notifications) {
|
||||
FriendsButton(filter: $filter.fine_filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: filter_state.fine_filter) { val in
|
||||
.onChange(of: filter.fine_filter) { val in
|
||||
state.settings.friend_filter = val
|
||||
}
|
||||
.onChange(of: filter_state.state) { val in
|
||||
state.settings.notification_state = val
|
||||
.onChange(of: filter_state) { val in
|
||||
filter.state = val
|
||||
}
|
||||
.onAppear {
|
||||
self.filter_state.fine_filter = state.settings.friend_filter
|
||||
self.filter_state.state = state.settings.notification_state
|
||||
self.filter.fine_filter = state.settings.friend_filter
|
||||
filter.state = filter_state
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
CustomPicker(selection: $filter_state.state, content: {
|
||||
CustomPicker(selection: $filter_state, content: {
|
||||
Text("All", comment: "Label for filter for all notifications.")
|
||||
.tag(NotificationFilterState.all)
|
||||
|
||||
@@ -221,7 +210,7 @@ struct NotificationsView: View {
|
||||
|
||||
struct NotificationsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NotificationsView(state: test_damus_state(), notifications: NotificationsModel(), filter_state: NotificationFilter())
|
||||
NotificationsView(state: test_damus_state(), notifications: NotificationsModel(), filter: NotificationFilter())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import SwiftUI
|
||||
|
||||
struct ProfilePicturesView: View {
|
||||
let state: DamusState
|
||||
let events: [NostrEvent]
|
||||
let pubkeys: [String]
|
||||
|
||||
@State var nav_target: String? = nil
|
||||
@State var navigating: Bool = false
|
||||
@@ -19,10 +19,10 @@ struct ProfilePicturesView: View {
|
||||
EmptyView()
|
||||
}
|
||||
HStack {
|
||||
ForEach(events.prefix(8)) { ev in
|
||||
ProfilePicView(pubkey: ev.pubkey, size: 32.0, highlight: .none, profiles: state.profiles)
|
||||
ForEach(pubkeys.prefix(8), id: \.self) { pubkey in
|
||||
ProfilePicView(pubkey: pubkey, size: 32.0, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation)
|
||||
.onTapGesture {
|
||||
nav_target = ev.pubkey
|
||||
nav_target = pubkey
|
||||
navigating = true
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,6 @@ struct ProfilePicturesView: View {
|
||||
|
||||
struct ProfilePicturesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ProfilePicturesView(state: test_damus_state(), events: [test_event, test_event])
|
||||
ProfilePicturesView(state: test_damus_state(), pubkeys: ["a", "b"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ struct ParticipantsView: View {
|
||||
ForEach(originalReferences.pRefs) { participant in
|
||||
let pubkey = participant.id
|
||||
HStack {
|
||||
ProfilePicView(pubkey: pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles)
|
||||
ProfilePicView(pubkey: pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
let profile = damus_state.profiles.lookup(id: pubkey)
|
||||
|
||||
+33
-14
@@ -82,6 +82,8 @@ struct PostView: View {
|
||||
var content = self.post.string.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
|
||||
let imagesString = uploadedMedias.map { $0.uploadedURL.absoluteString }.joined(separator: " ")
|
||||
|
||||
let img_meta_tags = uploadedMedias.compactMap { $0.metadata?.to_tag() }
|
||||
|
||||
content.append(" " + imagesString + " ")
|
||||
|
||||
@@ -89,7 +91,7 @@ struct PostView: View {
|
||||
content.append(" nostr:" + id)
|
||||
}
|
||||
|
||||
let new_post = NostrPost(content: content, references: references, kind: kind)
|
||||
let new_post = NostrPost(content: content, references: references, kind: kind, tags: img_meta_tags)
|
||||
|
||||
NotificationCenter.default.post(name: .post, object: NostrPostResult.post(new_post))
|
||||
|
||||
@@ -246,9 +248,11 @@ struct PostView: View {
|
||||
}
|
||||
|
||||
func handle_upload(media: MediaUpload) {
|
||||
let uploader = get_media_uploader(damus_state.pubkey)
|
||||
let uploader = damus_state.settings.default_media_uploader
|
||||
Task.init {
|
||||
let img = getImage(media: media)
|
||||
print("img size w:\(img.size.width) h:\(img.size.height)")
|
||||
async let blurhash = calculate_blurhash(img: img)
|
||||
let res = await image_upload.start(media: media, uploader: uploader)
|
||||
|
||||
switch res {
|
||||
@@ -257,7 +261,9 @@ struct PostView: View {
|
||||
self.error = "Error uploading image :("
|
||||
return
|
||||
}
|
||||
let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, representingImage: img)
|
||||
let blurhash = await blurhash
|
||||
let meta = blurhash.map { bh in calculate_image_metadata(url: url, img: img, blurhash: bh) }
|
||||
let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, representingImage: img, metadata: meta)
|
||||
uploadedMedias.append(uploadedMedia)
|
||||
|
||||
case .failed(let error):
|
||||
@@ -271,21 +277,24 @@ struct PostView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var has_artifacts: Bool {
|
||||
var multiply_factor: CGFloat {
|
||||
if case .quoting = action {
|
||||
return true
|
||||
return 0.4
|
||||
} else if !uploadedMedias.isEmpty {
|
||||
return 0.2
|
||||
} else {
|
||||
return 1.0
|
||||
}
|
||||
return !uploadedMedias.isEmpty
|
||||
}
|
||||
|
||||
func Editor(deviceSize: GeometryProxy) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
ProfilePicView(pubkey: damus_state.pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles)
|
||||
ProfilePicView(pubkey: damus_state.pubkey, size: PFP_SIZE, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
|
||||
TextEntry
|
||||
}
|
||||
.frame(height: has_artifacts ? deviceSize.size.height*0.4 : deviceSize.size.height)
|
||||
.frame(height: deviceSize.size.height * multiply_factor)
|
||||
.id("post")
|
||||
|
||||
PVImageCarouselView(media: $uploadedMedias, deviceWidth: deviceSize.size.width)
|
||||
@@ -336,12 +345,12 @@ struct PostView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $attach_media) {
|
||||
ImagePicker(sourceType: .photoLibrary, pubkey: damus_state.pubkey, image_upload_confirm: $image_upload_confirm) { img in
|
||||
ImagePicker(uploader: damus_state.settings.default_media_uploader, sourceType: .photoLibrary, pubkey: damus_state.pubkey, image_upload_confirm: $image_upload_confirm) { img in
|
||||
self.mediaToUpload = .image(img)
|
||||
} onVideoPicked: { url in
|
||||
self.mediaToUpload = .video(url)
|
||||
}
|
||||
.alert(NSLocalizedString("Are you sure you want to upload this image?", comment: "Alert message asking if the user wants to upload an image."), isPresented: $image_upload_confirm) {
|
||||
.alert(NSLocalizedString("Are you sure you want to upload this media?", comment: "Alert message asking if the user wants to upload an image."), isPresented: $image_upload_confirm) {
|
||||
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
|
||||
if let mediaToUpload {
|
||||
self.handle_upload(media: mediaToUpload)
|
||||
@@ -352,11 +361,20 @@ struct PostView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $attach_camera) {
|
||||
// image_upload_confirm isn't handled here, I don't know we need to display it here too tbh
|
||||
ImagePicker(sourceType: .camera, pubkey: damus_state.pubkey, image_upload_confirm: $image_upload_confirm) { img in
|
||||
handle_upload(media: .image(img))
|
||||
|
||||
ImagePicker(uploader: damus_state.settings.default_media_uploader, sourceType: .camera, pubkey: damus_state.pubkey, image_upload_confirm: $image_upload_confirm) { img in
|
||||
self.mediaToUpload = .image(img)
|
||||
} onVideoPicked: { url in
|
||||
handle_upload(media: .video(url))
|
||||
self.mediaToUpload = .video(url)
|
||||
}
|
||||
.alert("Are you sure you want to upload this media?", isPresented: $image_upload_confirm) {
|
||||
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
|
||||
if let mediaToUpload {
|
||||
self.handle_upload(media: mediaToUpload)
|
||||
self.attach_camera = false
|
||||
}
|
||||
}
|
||||
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
|
||||
}
|
||||
}
|
||||
.onAppear() {
|
||||
@@ -501,6 +519,7 @@ struct UploadedMedia: Equatable {
|
||||
let localURL: URL
|
||||
let uploadedURL: URL
|
||||
let representingImage: UIImage
|
||||
let metadata: ImageMetadata?
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ struct EditMetadataView: View {
|
||||
var TopSection: some View {
|
||||
ZStack(alignment: .top) {
|
||||
GeometryReader { geo in
|
||||
BannerImageView(pubkey: damus_state.pubkey, profiles: damus_state.profiles)
|
||||
BannerImageView(pubkey: damus_state.pubkey, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: geo.size.width, height: BANNER_HEIGHT)
|
||||
.clipped()
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EditProfilePictureControl: View {
|
||||
|
||||
let uploader: MediaUploader
|
||||
let pubkey: String
|
||||
@Binding var profile_image: URL?
|
||||
@ObservedObject var viewModel: ProfileUploadingViewModel
|
||||
@@ -20,6 +20,8 @@ struct EditProfilePictureControl: View {
|
||||
@State private var show_library = false
|
||||
@State var image_upload_confirm: Bool = false
|
||||
|
||||
@State var mediaToUpload: MediaUpload? = nil
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
Button(action: {
|
||||
@@ -45,26 +47,43 @@ struct EditProfilePictureControl: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $show_camera) {
|
||||
// The alert may not be required for the profile pic upload case. Not showing the confirm check alert for this scenario
|
||||
ImagePicker(sourceType: .camera, pubkey: pubkey, image_upload_confirm: $image_upload_confirm, imagesOnly: true) { img in
|
||||
handle_upload(media: .image(img))
|
||||
|
||||
ImagePicker(uploader: uploader, sourceType: .camera, pubkey: pubkey, image_upload_confirm: $image_upload_confirm, imagesOnly: true) { img in
|
||||
self.mediaToUpload = .image(img)
|
||||
} onVideoPicked: { url in
|
||||
print("Cannot upload videos as profile image")
|
||||
}
|
||||
.alert("Are you sure you want to upload this image?", isPresented: $image_upload_confirm) {
|
||||
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
|
||||
if let mediaToUpload {
|
||||
self.handle_upload(media: mediaToUpload)
|
||||
self.show_camera = false
|
||||
}
|
||||
}
|
||||
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $show_library) {
|
||||
// The alert may not be required for the profile pic upload case. Not showing the confirm check alert for this scenario
|
||||
ImagePicker(sourceType: .photoLibrary, pubkey: pubkey, image_upload_confirm: $image_upload_confirm, imagesOnly: true) { img in
|
||||
handle_upload(media: .image(img))
|
||||
ImagePicker(uploader: uploader, sourceType: .photoLibrary, pubkey: pubkey, image_upload_confirm: $image_upload_confirm, imagesOnly: true) { img in
|
||||
self.mediaToUpload = .image(img)
|
||||
|
||||
} onVideoPicked: { url in
|
||||
print("Cannot upload videos as profile image")
|
||||
}
|
||||
.alert("Are you sure you want to upload this image?", isPresented: $image_upload_confirm) {
|
||||
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
|
||||
if let mediaToUpload {
|
||||
self.handle_upload(media: mediaToUpload)
|
||||
self.show_library = false
|
||||
}
|
||||
}
|
||||
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle_upload(media: MediaUpload) {
|
||||
viewModel.isLoading = true
|
||||
let uploader = get_media_uploader(pubkey)
|
||||
Task {
|
||||
let res = await image_upload.start(media: media, uploader: uploader)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ struct MaybeAnonPfpView: View {
|
||||
.frame(width: size, height: size)
|
||||
} else {
|
||||
NavigationLink(destination: ProfileView(damus_state: state, pubkey: pubkey)) {
|
||||
ProfilePicView(pubkey: pubkey, size: size, highlight: .none, profiles: state.profiles)
|
||||
ProfilePicView(pubkey: pubkey, size: size, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,13 +53,17 @@ struct EditProfilePictureView: View {
|
||||
.overlay(Circle().stroke(highlight_color(highlight), lineWidth: pfp_line_width(highlight)))
|
||||
.padding(2)
|
||||
}
|
||||
|
||||
var disable_animation: Bool {
|
||||
damus_state?.settings.disable_animation ?? false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(uiColor: .systemBackground)
|
||||
|
||||
KFAnimatedImage(get_profile_url())
|
||||
.imageContext(.pfp)
|
||||
.imageContext(.pfp, disable_animation: disable_animation)
|
||||
.cancelOnDisappear(true)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
@@ -87,12 +91,12 @@ struct EditProfilePictureView: View {
|
||||
}
|
||||
|
||||
struct InnerProfilePicView: View {
|
||||
|
||||
let url: URL?
|
||||
let fallbackUrl: URL?
|
||||
let pubkey: String
|
||||
let size: CGFloat
|
||||
let highlight: Highlight
|
||||
let disable_animation: Bool
|
||||
|
||||
var PlaceholderColor: Color {
|
||||
return id_to_color(pubkey)
|
||||
@@ -111,7 +115,7 @@ struct InnerProfilePicView: View {
|
||||
Color(uiColor: .systemBackground)
|
||||
|
||||
KFAnimatedImage(url)
|
||||
.imageContext(.pfp)
|
||||
.imageContext(.pfp, disable_animation: disable_animation)
|
||||
.onFailure(fallbackUrl: fallbackUrl, cacheKey: url?.absoluteString)
|
||||
.cancelOnDisappear(true)
|
||||
.configure { view in
|
||||
@@ -133,19 +137,21 @@ struct ProfilePicView: View {
|
||||
let size: CGFloat
|
||||
let highlight: Highlight
|
||||
let profiles: Profiles
|
||||
let disable_animation: Bool
|
||||
|
||||
@State var picture: String?
|
||||
|
||||
init (pubkey: String, size: CGFloat, highlight: Highlight, profiles: Profiles, picture: String? = nil) {
|
||||
init (pubkey: String, size: CGFloat, highlight: Highlight, profiles: Profiles, disable_animation: Bool, picture: String? = nil) {
|
||||
self.pubkey = pubkey
|
||||
self.profiles = profiles
|
||||
self.size = size
|
||||
self.highlight = highlight
|
||||
self._picture = State(initialValue: picture)
|
||||
self.disable_animation = disable_animation
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
InnerProfilePicView(url: get_profile_url(picture: picture, pubkey: pubkey, profiles: profiles), fallbackUrl: URL(string: robohash(pubkey)), pubkey: pubkey, size: size, highlight: highlight)
|
||||
InnerProfilePicView(url: get_profile_url(picture: picture, pubkey: pubkey, profiles: profiles), fallbackUrl: URL(string: robohash(pubkey)), pubkey: pubkey, size: size, highlight: highlight, disable_animation: disable_animation)
|
||||
.onReceive(handle_notify(.profile_updated)) { notif in
|
||||
let updated = notif.object as! ProfileUpdate
|
||||
|
||||
@@ -185,7 +191,9 @@ struct ProfilePicView_Previews: PreviewProvider {
|
||||
pubkey: pubkey,
|
||||
size: 100,
|
||||
highlight: .none,
|
||||
profiles: make_preview_profiles(pubkey))
|
||||
profiles: make_preview_profiles(pubkey),
|
||||
disable_animation: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,11 +23,15 @@ struct ProfilePictureSelector: View {
|
||||
|
||||
@State var profile_image: URL? = nil
|
||||
|
||||
var uploader: MediaUploader {
|
||||
damus_state?.settings.default_media_uploader ?? .nostrBuild
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let highlight: Highlight = .custom(Color.white, 2.0)
|
||||
ZStack {
|
||||
EditProfilePictureView(url: $profile_image, pubkey: pubkey, size: size, highlight: highlight, damus_state: damus_state)
|
||||
EditProfilePictureControl(pubkey: pubkey, profile_image: $profile_image, viewModel: viewModel, callback: callback)
|
||||
EditProfilePictureControl(uploader: uploader, pubkey: pubkey, profile_image: $profile_image, viewModel: viewModel, callback: callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ struct ProfileView: View {
|
||||
return AnyView(
|
||||
VStack(spacing: 0) {
|
||||
ZStack {
|
||||
BannerImageView(pubkey: profile.pubkey, profiles: damus_state.profiles)
|
||||
BannerImageView(pubkey: profile.pubkey, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: proxy.size.width, height: minY > 0 ? bannerHeight + minY : bannerHeight)
|
||||
.clipped()
|
||||
@@ -210,7 +210,7 @@ struct ProfileView: View {
|
||||
}) {
|
||||
navImage(systemImage: "ellipsis")
|
||||
}
|
||||
.confirmationDialog(NSLocalizedString("Actions", comment: "Title for confirmation dialog to either share, report, or block a profile."), isPresented: $action_sheet_presented) {
|
||||
.confirmationDialog(NSLocalizedString("Actions", comment: "Title for confirmation dialog to either share, report, or mute a profile."), isPresented: $action_sheet_presented) {
|
||||
Button(NSLocalizedString("Share", comment: "Button to share the link to a profile.")) {
|
||||
show_share_sheet = true
|
||||
}
|
||||
@@ -257,7 +257,7 @@ struct ProfileView: View {
|
||||
.profile_button_style(scheme: colorScheme)
|
||||
.contextMenu {
|
||||
if profile.reactions == false {
|
||||
Text("OnlyZaps Enabled")
|
||||
Text("OnlyZaps Enabled", comment: "Non-tappable text in context menu that shows up when the zap button on profile is long pressed to indicate that the user has enabled OnlyZaps, meaning that they would like to be only zapped and not accept reactions to their notes.")
|
||||
}
|
||||
|
||||
if let addr = profile.lud16 {
|
||||
@@ -278,7 +278,7 @@ struct ProfileView: View {
|
||||
}
|
||||
.cornerRadius(24)
|
||||
.sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) {
|
||||
SelectWalletView(showingSelectWallet: $showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: lnurl)
|
||||
SelectWalletView(default_wallet: damus_state.settings.default_wallet, showingSelectWallet: $showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: lnurl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ struct ProfileView: View {
|
||||
func nameSection(profile_data: Profile?) -> some View {
|
||||
return Group {
|
||||
HStack(alignment: .center) {
|
||||
ProfilePicView(pubkey: profile.pubkey, size: pfp_size, highlight: .custom(imageBorderColor(), 4.0), profiles: damus_state.profiles)
|
||||
ProfilePicView(pubkey: profile.pubkey, size: pfp_size, highlight: .custom(imageBorderColor(), 4.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
.padding(.top, -(pfp_size / 2.0))
|
||||
.offset(y: pfpOffset())
|
||||
.scaleEffect(pfpScale())
|
||||
@@ -340,7 +340,8 @@ struct ProfileView: View {
|
||||
is_zoomed.toggle()
|
||||
}
|
||||
.fullScreenCover(isPresented: $is_zoomed) {
|
||||
ProfilePicImageView(pubkey: profile.pubkey, profiles: damus_state.profiles) }
|
||||
ProfilePicImageView(pubkey: profile.pubkey, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ struct QRCodeView: View {
|
||||
let profile = damus_state.profiles.lookup(id: pubkey)
|
||||
|
||||
if (damus_state.profiles.lookup(id: pubkey)?.picture) != nil {
|
||||
ProfilePicView(pubkey: pubkey, size: 90.0, highlight: .custom(DamusColors.white, 4.0), profiles: damus_state.profiles)
|
||||
ProfilePicView(pubkey: pubkey, size: 90.0, highlight: .custom(DamusColors.white, 4.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
.padding(.top, 50)
|
||||
} else {
|
||||
Image(systemName: "person.fill")
|
||||
|
||||
@@ -31,8 +31,8 @@ struct RelayFilterView: View {
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 0)
|
||||
|
||||
List(Array(relays), id: \.url) { relay in
|
||||
RelayToggle(state: state, timeline: timeline, relay_id: relay.url.absoluteString)
|
||||
List(Array(relays), id: \.url.id) { relay in
|
||||
RelayToggle(state: state, timeline: timeline, relay_id: relay.url.url.absoluteString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ struct RelayConfigView: View {
|
||||
var recommended: [RelayDescriptor] {
|
||||
let rs: [RelayDescriptor] = []
|
||||
return BOOTSTRAP_RELAYS.reduce(into: rs) { xs, x in
|
||||
if state.pool.get_relay(x) == nil, let url = URL(string: x) {
|
||||
if state.pool.get_relay(x) == nil, let url = RelayURL(x) {
|
||||
xs.append(RelayDescriptor(url: url, info: .rw))
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ struct RelayConfigView: View {
|
||||
new_relay.removeLast();
|
||||
}
|
||||
|
||||
guard let url = URL(string: new_relay) else {
|
||||
guard let url = RelayURL(new_relay) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ struct RelayConfigView: View {
|
||||
|
||||
Section {
|
||||
List(Array(relays), id: \.url) { relay in
|
||||
RelayView(state: state, relay: relay.url.absoluteString, showActionButtons: $showActionButtons)
|
||||
RelayView(state: state, relay: relay.url.id, showActionButtons: $showActionButtons)
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
@@ -133,7 +133,7 @@ struct RelayConfigView: View {
|
||||
if recommended.count > 0 {
|
||||
Section {
|
||||
List(recommended, id: \.url) { r in
|
||||
RecommendedRelayView(damus: state, relay: r.url.absoluteString, showActionButtons: $showActionButtons)
|
||||
RecommendedRelayView(damus: state, relay: r.url.id, showActionButtons: $showActionButtons)
|
||||
}
|
||||
} header: {
|
||||
Text(NSLocalizedString("Recommended Relays", comment: "Section title for recommend relay servers that could be added as part of configuration"))
|
||||
|
||||
@@ -24,7 +24,7 @@ struct RelayStatus: View {
|
||||
if c.isConnected {
|
||||
conn_image = "network"
|
||||
conn_color = .green
|
||||
} else if c.isConnecting || c.isReconnecting {
|
||||
} else if c.isConnecting {
|
||||
connecting = true
|
||||
} else {
|
||||
conn_image = "exclamationmark.circle.fill"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Security
|
||||
|
||||
struct SaveKeysView: View {
|
||||
let account: CreateAccountModel
|
||||
@@ -15,6 +16,8 @@ struct SaveKeysView: View {
|
||||
@State var priv_copied: Bool = false
|
||||
@State var loading: Bool = false
|
||||
@State var error: String? = nil
|
||||
|
||||
@State private var credential_handler = CredentialHandler()
|
||||
|
||||
@FocusState var pubkey_focused: Bool
|
||||
@FocusState var privkey_focused: Bool
|
||||
@@ -97,6 +100,8 @@ struct SaveKeysView: View {
|
||||
|
||||
self.pool.register_handler(sub_id: "signup", handler: handle_event)
|
||||
|
||||
credential_handler.save_credential(pubkey: account.pubkey_bech32, privkey: account.privkey_bech32)
|
||||
|
||||
self.loading = true
|
||||
|
||||
self.pool.connect()
|
||||
@@ -127,7 +132,7 @@ struct SaveKeysView: View {
|
||||
|
||||
case .error(let err):
|
||||
self.loading = false
|
||||
self.error = "\(err.debugDescription)"
|
||||
self.error = String(describing: err)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ struct SearchHomeView: View {
|
||||
loading: $model.loading,
|
||||
damus: damus_state,
|
||||
show_friend_icon: true,
|
||||
filter: {
|
||||
if damus_state.muted_threads.isMutedThread($0, privkey: self.damus_state.keypair.privkey) {
|
||||
filter: { ev in
|
||||
if damus_state.muted_threads.isMutedThread(ev, privkey: self.damus_state.keypair.privkey) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -59,11 +59,12 @@ struct SearchHomeView: View {
|
||||
}
|
||||
|
||||
// If we can't determine the note's language with 50%+ confidence, lean on the side of caution and show it anyway.
|
||||
guard let noteLanguage = $0.note_language(damus_state.keypair.privkey) else {
|
||||
let note_lang = damus_state.events.get_cache_data(ev.id).translations_model.note_language
|
||||
guard let note_lang else {
|
||||
return true
|
||||
}
|
||||
|
||||
return preferredLanguages.contains(noteLanguage)
|
||||
return preferredLanguages.contains(note_lang)
|
||||
}
|
||||
)
|
||||
.refreshable {
|
||||
|
||||
@@ -7,82 +7,132 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum Search {
|
||||
struct MultiSearch {
|
||||
let hashtag: String
|
||||
let profiles: [SearchedUser]
|
||||
}
|
||||
|
||||
enum Search: Identifiable {
|
||||
case profiles([SearchedUser])
|
||||
case hashtag(String)
|
||||
case profile(String)
|
||||
case note(String)
|
||||
case nip05(String)
|
||||
case hex(String)
|
||||
case multi(MultiSearch)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .profiles: return "profiles"
|
||||
case .hashtag: return "hashtag"
|
||||
case .profile: return "profile"
|
||||
case .note: return "note"
|
||||
case .nip05: return "nip05"
|
||||
case .hex: return "hex"
|
||||
case .multi: return "multi"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchResultsView: View {
|
||||
struct AnySearchResultsView: View {
|
||||
let damus_state: DamusState
|
||||
@Binding var search: String
|
||||
let searches: [Search]
|
||||
|
||||
@State var result: Search? = nil
|
||||
var body: some View {
|
||||
VStack {
|
||||
ForEach(searches) { r in
|
||||
InnerSearchResults(damus_state: damus_state, search: r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InnerSearchResults: View {
|
||||
let damus_state: DamusState
|
||||
let search: Search?
|
||||
|
||||
func ProfileSearchResult(pk: String) -> some View {
|
||||
FollowUserView(target: .pubkey(pk), damus_state: damus_state)
|
||||
}
|
||||
|
||||
var MainContent: some View {
|
||||
ScrollView {
|
||||
Group {
|
||||
switch result {
|
||||
case .profiles(let results):
|
||||
LazyVStack {
|
||||
ForEach(results) { prof in
|
||||
ProfileSearchResult(pk: prof.pubkey)
|
||||
}
|
||||
}
|
||||
case .hashtag(let ht):
|
||||
let search_model = SearchModel(contacts: damus_state.contacts, pool: damus_state.pool, search: .filter_hashtag([ht]))
|
||||
let dst = SearchView(appstate: damus_state, search: search_model)
|
||||
NavigationLink(destination: dst) {
|
||||
Text("Search hashtag: #\(ht)", comment: "Navigation link to search hashtag.")
|
||||
}
|
||||
|
||||
case .nip05(let addr):
|
||||
SearchingEventView(state: damus_state, evid: addr, search_type: .nip05)
|
||||
|
||||
case .profile(let prof):
|
||||
let decoded = try? bech32_decode(prof)
|
||||
let hex = hex_encode(decoded!.data)
|
||||
|
||||
SearchingEventView(state: damus_state, evid: hex, search_type: .profile)
|
||||
case .hex(let h):
|
||||
//let prof_view = ProfileView(damus_state: damus_state, pubkey: h)
|
||||
//let ev_view = ThreadView(damus: damus_state, event_id: h)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
SearchingEventView(state: damus_state, evid: h, search_type: .event)
|
||||
|
||||
SearchingEventView(state: damus_state, evid: h, search_type: .profile)
|
||||
}
|
||||
|
||||
case .note(let nid):
|
||||
let decoded = try? bech32_decode(nid)
|
||||
let hex = hex_encode(decoded!.data)
|
||||
|
||||
SearchingEventView(state: damus_state, evid: hex, search_type: .event)
|
||||
case .none:
|
||||
Text("none", comment: "No search results.")
|
||||
}
|
||||
func HashtagSearch(_ ht: String) -> some View {
|
||||
let search_model = SearchModel(state: damus_state, search: .filter_hashtag([ht]))
|
||||
let dst = SearchView(appstate: damus_state, search: search_model)
|
||||
return NavigationLink(destination: dst) {
|
||||
Text("Search hashtag: #\(ht)", comment: "Navigation link to search hashtag.")
|
||||
}
|
||||
}
|
||||
|
||||
func ProfilesSearch(_ results: [SearchedUser]) -> some View {
|
||||
return LazyVStack {
|
||||
ForEach(results) { prof in
|
||||
ProfileSearchResult(pk: prof.pubkey)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MainContent
|
||||
.frame(maxHeight: .infinity)
|
||||
.onAppear {
|
||||
self.result = search_for_string(profiles: damus_state.profiles, search)
|
||||
}
|
||||
.onChange(of: search) { new in
|
||||
self.result = search_for_string(profiles: damus_state.profiles, new)
|
||||
Group {
|
||||
switch search {
|
||||
case .profiles(let results):
|
||||
ProfilesSearch(results)
|
||||
|
||||
case .hashtag(let ht):
|
||||
HashtagSearch(ht)
|
||||
|
||||
case .nip05(let addr):
|
||||
SearchingEventView(state: damus_state, evid: addr, search_type: .nip05)
|
||||
|
||||
case .profile(let prof):
|
||||
let decoded = try? bech32_decode(prof)
|
||||
let hex = hex_encode(decoded!.data)
|
||||
|
||||
SearchingEventView(state: damus_state, evid: hex, search_type: .profile)
|
||||
case .hex(let h):
|
||||
//let prof_view = ProfileView(damus_state: damus_state, pubkey: h)
|
||||
//let ev_view = ThreadView(damus: damus_state, event_id: h)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
SearchingEventView(state: damus_state, evid: h, search_type: .event)
|
||||
|
||||
SearchingEventView(state: damus_state, evid: h, search_type: .profile)
|
||||
}
|
||||
|
||||
case .note(let nid):
|
||||
let decoded = try? bech32_decode(nid)
|
||||
let hex = hex_encode(decoded!.data)
|
||||
|
||||
SearchingEventView(state: damus_state, evid: hex, search_type: .event)
|
||||
case .multi(let multi):
|
||||
VStack {
|
||||
HashtagSearch(multi.hashtag)
|
||||
ProfilesSearch(multi.profiles)
|
||||
}
|
||||
|
||||
case .none:
|
||||
Text("none", comment: "No search results.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchResultsView: View {
|
||||
let damus_state: DamusState
|
||||
@Binding var search: String
|
||||
@State var result: Search? = nil
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
InnerSearchResults(damus_state: damus_state, search: result)
|
||||
.padding()
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.onAppear {
|
||||
self.result = search_for_string(profiles: damus_state.profiles, search)
|
||||
}
|
||||
.onChange(of: search) { new in
|
||||
self.result = search_for_string(profiles: damus_state.profiles, new)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,8 +157,7 @@ func search_for_string(profiles: Profiles, _ new: String) -> Search? {
|
||||
}
|
||||
|
||||
if new.first! == "#" {
|
||||
let ht = String(new.dropFirst().filter{$0 != " "})
|
||||
return .hashtag(ht)
|
||||
return .hashtag(make_hashtagable(new))
|
||||
}
|
||||
|
||||
if hex_decode(new) != nil, new.count == 64 {
|
||||
@@ -127,7 +176,21 @@ func search_for_string(profiles: Profiles, _ new: String) -> Search? {
|
||||
}
|
||||
}
|
||||
|
||||
return .profiles(search_profiles(profiles: profiles, search: new))
|
||||
let multisearch = MultiSearch(hashtag: make_hashtagable(new), profiles: search_profiles(profiles: profiles, search: new))
|
||||
return .multi(multisearch)
|
||||
}
|
||||
|
||||
func make_hashtagable(_ str: String) -> String {
|
||||
var new = str
|
||||
guard str.utf8.count > 0 else {
|
||||
return str
|
||||
}
|
||||
|
||||
if new.hasPrefix("#") {
|
||||
new = String(new.dropFirst())
|
||||
}
|
||||
|
||||
return String(new.filter{$0 != " "})
|
||||
}
|
||||
|
||||
func search_profiles(profiles: Profiles, search: String) -> [SearchedUser] {
|
||||
|
||||
@@ -43,9 +43,8 @@ struct SearchView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let test_state = test_damus_state()
|
||||
let filter = NostrFilter.filter_hashtag(["bitcoin"])
|
||||
let pool = test_state.pool
|
||||
|
||||
let model = SearchModel(contacts: test_state.contacts, pool: pool, search: filter)
|
||||
let model = SearchModel(state: test_state, search: filter)
|
||||
|
||||
SearchView(appstate: test_state, search: model)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SelectWalletView: View {
|
||||
let default_wallet: Wallet
|
||||
@Binding var showingSelectWallet: Bool
|
||||
let our_pubkey: String
|
||||
let invoice: String
|
||||
@@ -38,8 +39,7 @@ struct SelectWalletView: View {
|
||||
Section(NSLocalizedString("Select a Lightning wallet", comment: "Title of section for selecting a Lightning wallet to pay a Lightning invoice.")) {
|
||||
List{
|
||||
Button() {
|
||||
let wallet_model = get_default_wallet(our_pubkey).model
|
||||
open_with_wallet(wallet: wallet_model, invoice: invoice)
|
||||
open_with_wallet(wallet: default_wallet.model, invoice: invoice)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Default Wallet", comment: "Button to pay a Lightning invoice with the user's default Lightning wallet.").font(.body).foregroundColor(.blue)
|
||||
@@ -73,6 +73,6 @@ struct SelectWalletView_Previews: PreviewProvider {
|
||||
@State static var invoice: String = ""
|
||||
|
||||
static var previews: some View {
|
||||
SelectWalletView(showingSelectWallet: $show, our_pubkey: "", invoice: "")
|
||||
SelectWalletView(default_wallet: .lnlink, showingSelectWallet: $show, our_pubkey: "", invoice: "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,15 +27,15 @@ struct AppearanceSettingsView: View {
|
||||
}
|
||||
|
||||
Section(NSLocalizedString("Images", comment: "Section title for images configuration.")) {
|
||||
Toggle(NSLocalizedString("Disable animations", comment: "Button to disable image animation"), isOn: $settings.disable_animation)
|
||||
Toggle(NSLocalizedString("Animations", comment: "Toggle to enable or disable image animation"), isOn: $settings.enable_animation)
|
||||
.toggleStyle(.switch)
|
||||
.onChange(of: settings.disable_animation) { _ in
|
||||
.onChange(of: settings.enable_animation) { _ in
|
||||
clear_kingfisher_cache()
|
||||
}
|
||||
Toggle(NSLocalizedString("Always show images", comment: "Setting to always show and never blur images"), isOn: $settings.always_show_images)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Picker(NSLocalizedString("Select image uploader", comment: "Prompt selection of user's image uploader"),
|
||||
Picker(NSLocalizedString("Image uploader", comment: "Prompt selection of user's image uploader"),
|
||||
selection: $settings.default_media_uploader) {
|
||||
ForEach(MediaUploader.allCases, id: \.self) { uploader in
|
||||
Text(uploader.model.displayName)
|
||||
|
||||
@@ -65,6 +65,17 @@ struct TranslationSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if settings.translation_service == .nokyctranslate {
|
||||
SecureField(NSLocalizedString("API Key (required)", comment: "Prompt for required entry of API Key to use translation server."), text: $settings.nokyctranslate_api_key)
|
||||
.disableAutocorrection(true)
|
||||
.disabled(settings.translation_service != .nokyctranslate)
|
||||
.autocapitalization(UITextAutocapitalizationType.none)
|
||||
|
||||
if settings.nokyctranslate_api_key == "" {
|
||||
Link(NSLocalizedString("Get API Key with BTC/Lightning", comment: "Button to navigate to nokyctranslate website to get a translation API key."), destination: URL(string: "https://nokyctranslate.com")!)
|
||||
}
|
||||
}
|
||||
|
||||
if settings.translation_service != .none {
|
||||
Toggle(NSLocalizedString("Automatically translate notes", comment: "Toggle to automatically translate notes."), isOn: $settings.auto_translate)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
@@ -17,8 +17,7 @@ struct ZapSettingsView: View {
|
||||
|
||||
init(pubkey: String, settings: UserSettingsStore) {
|
||||
self.pubkey = pubkey
|
||||
let zap_amt = get_default_zap_amount(pubkey: pubkey).formatted()
|
||||
_default_zap_amount = State(initialValue: zap_amt)
|
||||
_default_zap_amount = State(initialValue: settings.default_zap_amount.formatted())
|
||||
self._settings = ObservedObject(initialValue: settings)
|
||||
}
|
||||
|
||||
@@ -26,10 +25,10 @@ struct ZapSettingsView: View {
|
||||
Form {
|
||||
Section(
|
||||
header: Text(NSLocalizedString("OnlyZaps", comment: "Section header for enabling OnlyZaps mode (hide reactions)")),
|
||||
footer: Text(NSLocalizedString("Hide all 🤙's", comment: "Section footer describing onlyzaps mode"))
|
||||
footer: Text(NSLocalizedString("Hide all 🤙's", comment: "Section footer describing OnlyZaps mode"))
|
||||
|
||||
) {
|
||||
Toggle(NSLocalizedString("Enable OnlyZaps mode", comment: "Setting toggle to hide reactions."), isOn: $settings.onlyzaps_mode)
|
||||
Toggle(NSLocalizedString("OnlyZaps mode", comment: "Setting toggle to hide reactions."), isOn: $settings.onlyzaps_mode)
|
||||
.toggleStyle(.switch)
|
||||
.onChange(of: settings.onlyzaps_mode) { newVal in
|
||||
notify(.onlyzaps_mode, newVal)
|
||||
@@ -59,10 +58,10 @@ struct ZapSettingsView: View {
|
||||
.onReceive(Just(default_zap_amount)) { newValue in
|
||||
if let parsed = handle_string_amount(new_value: newValue) {
|
||||
self.default_zap_amount = parsed.formatted()
|
||||
set_default_zap_amount(pubkey: self.pubkey, amount: parsed)
|
||||
settings.default_zap_amount = parsed
|
||||
} else {
|
||||
self.default_zap_amount = ""
|
||||
set_default_zap_amount(pubkey: self.pubkey, amount: 0)
|
||||
settings.default_zap_amount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ struct SideMenuView: View {
|
||||
NavigationLink(destination: ProfileView(damus_state: damus_state, profile: profile_model, followers: followers)) {
|
||||
|
||||
HStack {
|
||||
ProfilePicView(pubkey: damus_state.pubkey, size: 60, highlight: .none, profiles: damus_state.profiles)
|
||||
ProfilePicView(pubkey: damus_state.pubkey, size: 60, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
if let display_name = profile?.display_name {
|
||||
|
||||
@@ -10,7 +10,7 @@ import SwiftUI
|
||||
|
||||
struct InnerTimelineView: View {
|
||||
@ObservedObject var events: EventHolder
|
||||
let damus: DamusState
|
||||
let state: DamusState
|
||||
let show_friend_icon: Bool
|
||||
let filter: (NostrEvent) -> Bool
|
||||
@State var nav_target: NostrEvent
|
||||
@@ -18,7 +18,7 @@ struct InnerTimelineView: View {
|
||||
|
||||
init(events: EventHolder, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool) {
|
||||
self.events = events
|
||||
self.damus = damus
|
||||
self.state = damus
|
||||
self.show_friend_icon = show_friend_icon
|
||||
self.filter = filter
|
||||
// dummy event to avoid MaybeThreadView
|
||||
@@ -26,7 +26,7 @@ struct InnerTimelineView: View {
|
||||
}
|
||||
|
||||
var event_options: EventViewOptions {
|
||||
if self.damus.settings.truncate_timeline_text {
|
||||
if self.state.settings.truncate_timeline_text {
|
||||
return [.wide, .truncate_content]
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ struct InnerTimelineView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let thread = ThreadModel(event: nav_target, damus_state: damus)
|
||||
let dest = ThreadView(state: damus, thread: thread)
|
||||
let thread = ThreadModel(event: nav_target, damus_state: state)
|
||||
let dest = ThreadView(state: state, thread: thread)
|
||||
NavigationLink(destination: dest, isActive: $navigating) {
|
||||
EmptyView()
|
||||
}
|
||||
@@ -44,13 +44,28 @@ struct InnerTimelineView: View {
|
||||
if events.isEmpty {
|
||||
EmptyTimelineView()
|
||||
} else {
|
||||
ForEach(events.filter(filter), id: \.id) { (ev: NostrEvent) in
|
||||
EventView(damus: damus, event: ev, options: event_options)
|
||||
let evs = events.filter(filter)
|
||||
let indexed = Array(zip(evs, 0...))
|
||||
ForEach(indexed, id: \.0.id) { tup in
|
||||
let ev = tup.0
|
||||
let ind = tup.1
|
||||
EventView(damus: state, event: ev, options: event_options)
|
||||
.onTapGesture {
|
||||
nav_target = ev.inner_event ?? ev
|
||||
nav_target = ev.get_inner_event(cache: state.events) ?? ev
|
||||
navigating = true
|
||||
}
|
||||
.padding(.top, 7)
|
||||
.onAppear {
|
||||
let to_preload =
|
||||
Array([indexed[safe: ind+1]?.0,
|
||||
indexed[safe: ind+2]?.0,
|
||||
indexed[safe: ind+3]?.0,
|
||||
indexed[safe: ind+4]?.0,
|
||||
indexed[safe: ind+5]?.0
|
||||
].compactMap({ $0 }))
|
||||
|
||||
preload_events(event_cache: state.events, events: to_preload, profiles: state.profiles, our_keypair: state.keypair, settings: state.settings)
|
||||
}
|
||||
|
||||
ThiccDivider()
|
||||
.padding([.top], 7)
|
||||
@@ -58,6 +73,7 @@ struct InnerTimelineView: View {
|
||||
}
|
||||
}
|
||||
//.padding(.horizontal)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,3 +85,4 @@ struct InnerTimelineView_Previews: PreviewProvider {
|
||||
.border(Color.red)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,13 +8,6 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
enum ZapType {
|
||||
case pub
|
||||
case anon
|
||||
case priv
|
||||
case non_zap
|
||||
}
|
||||
|
||||
struct ZapAmountItem: Identifiable, Hashable {
|
||||
let amount: Int
|
||||
let icon: String
|
||||
@@ -24,15 +17,15 @@ struct ZapAmountItem: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
func get_default_zap_amount_item(_ pubkey: String) -> ZapAmountItem {
|
||||
let def = get_default_zap_amount(pubkey: pubkey)
|
||||
func get_default_zap_amount_item(_ def: Int) -> ZapAmountItem {
|
||||
return ZapAmountItem(amount: def, icon: "🤙")
|
||||
}
|
||||
|
||||
func get_zap_amount_items(pubkey: String) -> [ZapAmountItem] {
|
||||
let def_item = get_default_zap_amount_item(pubkey)
|
||||
func get_zap_amount_items(_ default_zap_amt: Int) -> [ZapAmountItem] {
|
||||
let def_item = get_default_zap_amount_item(default_zap_amt)
|
||||
var entries = [
|
||||
ZapAmountItem(amount: 500, icon: "🙂"),
|
||||
ZapAmountItem(amount: 69, icon: "😘"),
|
||||
ZapAmountItem(amount: 420, icon: "🌿"),
|
||||
ZapAmountItem(amount: 5000, icon: "💜"),
|
||||
ZapAmountItem(amount: 10_000, icon: "😍"),
|
||||
ZapAmountItem(amount: 20_000, icon: "🤩"),
|
||||
@@ -53,74 +46,156 @@ struct CustomizeZapView: View {
|
||||
@State var comment: String
|
||||
@State var custom_amount: String
|
||||
@State var custom_amount_sats: Int?
|
||||
@State var selected_amount: ZapAmountItem
|
||||
@State var zap_type: ZapType
|
||||
@State var invoice: String
|
||||
@State var error: String?
|
||||
@State var showing_wallet_selector: Bool
|
||||
@State var zapping: Bool
|
||||
@State var show_zap_types: Bool = false
|
||||
|
||||
let zap_amounts: [ZapAmountItem]
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
func fillColor() -> Color {
|
||||
colorScheme == .light ? DamusColors.white : DamusColors.black
|
||||
}
|
||||
|
||||
func fontColor() -> Color {
|
||||
colorScheme == .light ? DamusColors.black : DamusColors.white
|
||||
}
|
||||
|
||||
init(state: DamusState, event: NostrEvent, lnurl: String) {
|
||||
self._comment = State(initialValue: "")
|
||||
self.event = event
|
||||
self.zap_amounts = get_zap_amount_items(pubkey: state.pubkey)
|
||||
self.zap_amounts = get_zap_amount_items(state.settings.default_zap_amount)
|
||||
self._error = State(initialValue: nil)
|
||||
self._invoice = State(initialValue: "")
|
||||
self._showing_wallet_selector = State(initialValue: false)
|
||||
self._custom_amount = State(initialValue: "")
|
||||
self._zap_type = State(initialValue: .pub)
|
||||
let selected = get_default_zap_amount_item(state.pubkey)
|
||||
self._selected_amount = State(initialValue: selected)
|
||||
self._zap_type = State(initialValue: state.settings.default_zap_type)
|
||||
self._custom_amount = State(initialValue: String(state.settings.default_zap_amount))
|
||||
self._custom_amount_sats = State(initialValue: nil)
|
||||
self._zapping = State(initialValue: false)
|
||||
self.lnurl = lnurl
|
||||
self.state = state
|
||||
}
|
||||
|
||||
var zap_type_desc: String {
|
||||
switch zap_type {
|
||||
case .pub:
|
||||
return NSLocalizedString("Everyone on can see that you zapped", comment: "Description of public zap type where the zap is sent publicly and identifies the user who sent it.")
|
||||
case .anon:
|
||||
return NSLocalizedString("No one can see that you zapped", comment: "Description of anonymous zap type where the zap is sent anonymously and does not identify the user who sent it.")
|
||||
case .priv:
|
||||
let pk = event.pubkey
|
||||
let prof = state.profiles.lookup(id: pk)
|
||||
let name = Profile.displayName(profile: prof, pubkey: pk).username
|
||||
return String.localizedStringWithFormat(NSLocalizedString("private_zap_description", value: "Only '%@' can see that you zapped them", comment: "Description of private zap type where the zap is sent privately and does not identify the user to the public."), name)
|
||||
case .non_zap:
|
||||
return NSLocalizedString("No zaps are sent, only a lightning payment.", comment: "Description of non-zap type where sats are sent to the user's wallet as a regular Lightning payment, not as a zap.")
|
||||
|
||||
func amount_parts(_ n: Int) -> [ZapAmountItem] {
|
||||
var i: Int = -1
|
||||
let start = n * 3
|
||||
let end = start + 3
|
||||
|
||||
return zap_amounts.filter { _ in
|
||||
i += 1
|
||||
return i >= start && i < end
|
||||
}
|
||||
}
|
||||
|
||||
var ZapTypePicker: some View {
|
||||
Picker(NSLocalizedString("Zap Type", comment: "Header text to indicate that the picker below it is to choose the type of zap to send."), selection: $zap_type) {
|
||||
Text("Public", comment: "Picker option to indicate that a zap should be sent publicly and identify the user as who sent it.").tag(ZapType.pub)
|
||||
Text("Private", comment: "Picker option to indicate that a zap should be sent privately and not identify the user to the public.").tag(ZapType.priv)
|
||||
Text("Anonymous", comment: "Picker option to indicate that a zap should be sent anonymously and not identify the user as who sent it.").tag(ZapType.anon)
|
||||
Text(verbatim: NSLocalizedString("none_zap_type", value: "None", comment: "Picker option to indicate that sats should be sent to the user's wallet as a regular Lightning payment, not as a zap.")).tag(ZapType.non_zap)
|
||||
func AmountsPart(n: Int) -> some View {
|
||||
HStack(alignment: .center, spacing: 15) {
|
||||
ForEach(amount_parts(n)) { entry in
|
||||
ZapAmountButton(zapAmountItem: entry, action: {custom_amount_sats = entry.amount; custom_amount = String(entry.amount)})
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
|
||||
var AmountPicker: some View {
|
||||
Picker(NSLocalizedString("Zap Amount", comment: "Title of picker that allows selection of predefined amounts to zap."), selection: $selected_amount) {
|
||||
ForEach(zap_amounts) { entry in
|
||||
let fmt = format_msats_abbrev(Int64(entry.amount) * 1000)
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("\(entry.icon)")
|
||||
.frame(width: 30)
|
||||
Text("\(fmt)")
|
||||
.frame(width: 50)
|
||||
}
|
||||
.tag(entry)
|
||||
VStack {
|
||||
AmountsPart(n: 0)
|
||||
|
||||
AmountsPart(n: 1)
|
||||
|
||||
AmountsPart(n: 2)
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
|
||||
func ZapAmountButton(zapAmountItem: ZapAmountItem, action: @escaping () -> ()) -> some View {
|
||||
Button(action: action) {
|
||||
let fmt = format_msats_abbrev(Int64(zapAmountItem.amount) * 1000)
|
||||
Text(verbatim: "\(zapAmountItem.icon)\n\(fmt)")
|
||||
.contentShape(Rectangle())
|
||||
.font(.headline)
|
||||
.frame(width: 70, height: 70)
|
||||
.foregroundColor(fontColor())
|
||||
.background(custom_amount_sats == zapAmountItem.amount ? fillColor() : DamusColors.adaptableGrey)
|
||||
.cornerRadius(15)
|
||||
.overlay(RoundedRectangle(cornerRadius: 15)
|
||||
.stroke(DamusColors.purple.opacity(custom_amount_sats == zapAmountItem.amount ? 1.0 : 0.0), lineWidth: 2))
|
||||
}
|
||||
}
|
||||
|
||||
var CustomZapTextField: some View {
|
||||
VStack(alignment: .center, spacing: 0) {
|
||||
TextField("", text: $custom_amount)
|
||||
.placeholder(when: custom_amount.isEmpty, alignment: .center) {
|
||||
Text(String("0"))
|
||||
}
|
||||
.accentColor(.clear)
|
||||
.font(.system(size: 72, weight: .heavy))
|
||||
.minimumScaleFactor(0.01)
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.center)
|
||||
.onReceive(Just(custom_amount)) { newValue in
|
||||
if let parsed = handle_string_amount(new_value: newValue) {
|
||||
self.custom_amount = parsed.formatted()
|
||||
self.custom_amount_sats = parsed
|
||||
} else {
|
||||
self.custom_amount = ""
|
||||
self.custom_amount_sats = nil
|
||||
}
|
||||
}
|
||||
Text(custom_amount_sats == 1 ? "sat" : "sats", comment: "Shortened form of satoshi, display unit of measure where 1,000,000,000 satoshis is 1 Bitcoin. Used to indicate how many sats will be zapped to a note, configured through the custom zap view.")
|
||||
.font(.system(size: 18, weight: .heavy))
|
||||
}
|
||||
}
|
||||
|
||||
var ZapReply: some View {
|
||||
HStack {
|
||||
if #available(iOS 16.0, *) {
|
||||
TextField(NSLocalizedString("Send a reply with your zap...", comment: "Placeholder text for a comment to send as part of a zap to the user."), text: $comment, axis: .vertical)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.lineLimit(5)
|
||||
} else {
|
||||
TextField(NSLocalizedString("Send a reply with your zap...", comment: "Placeholder text for a comment to send as part of a zap to the user."), text: $comment)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 30)
|
||||
.padding(10)
|
||||
.background(.secondary.opacity(0.2))
|
||||
.cornerRadius(10)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
|
||||
var ZapButton: some View {
|
||||
VStack {
|
||||
if zapping {
|
||||
Text("Zapping...", comment: "Text to indicate that the app is in the process of sending a zap.")
|
||||
} else {
|
||||
Button(NSLocalizedString("Zap", comment: "Button to send a zap.")) {
|
||||
let amount = custom_amount_sats
|
||||
send_zap(damus_state: state, event: event, lnurl: lnurl, is_custom: true, comment: comment, amount_sats: amount, zap_type: zap_type)
|
||||
self.zapping = true
|
||||
}
|
||||
.disabled(custom_amount_sats == 0 || custom_amount.isEmpty)
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.frame(width: 130, height: 50)
|
||||
.foregroundColor(.white)
|
||||
.background(LINEAR_GRADIENT)
|
||||
.opacity(custom_amount_sats == 0 || custom_amount.isEmpty ? 0.5 : 1.0)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
if let error {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
}
|
||||
|
||||
func receive_zap(notif: Notification) {
|
||||
@@ -144,98 +219,103 @@ struct CustomizeZapView: View {
|
||||
}
|
||||
break
|
||||
case .got_zap_invoice(let inv):
|
||||
if should_show_wallet_selector(state.pubkey) {
|
||||
if state.settings.show_wallet_selector {
|
||||
self.invoice = inv
|
||||
self.showing_wallet_selector = true
|
||||
} else {
|
||||
end_editing()
|
||||
open_with_wallet(wallet: get_default_wallet(state.pubkey).model, invoice: inv)
|
||||
let wallet = state.settings.default_wallet.model
|
||||
open_with_wallet(wallet: wallet, invoice: inv)
|
||||
self.showing_wallet_selector = false
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MainContent
|
||||
.sheet(isPresented: $showing_wallet_selector) {
|
||||
SelectWalletView(showingSelectWallet: $showing_wallet_selector, our_pubkey: state.pubkey, invoice: invoice)
|
||||
SelectWalletView(default_wallet: state.settings.default_wallet, showingSelectWallet: $showing_wallet_selector, our_pubkey: state.pubkey, invoice: invoice)
|
||||
}
|
||||
.onReceive(handle_notify(.zapping)) { notif in
|
||||
receive_zap(notif: notif)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.background(fillColor().edgesIgnoringSafeArea(.all))
|
||||
.onTapGesture {
|
||||
hideKeyboard()
|
||||
}
|
||||
}
|
||||
|
||||
var TheForm: some View {
|
||||
Form {
|
||||
|
||||
Group {
|
||||
Section(content: {
|
||||
AmountPicker
|
||||
.frame(height: 120)
|
||||
}, header: {
|
||||
Text("Zap Amount in sats", comment: "Header text to indicate that the picker below it is to choose a pre-defined amount of sats to zap.")
|
||||
})
|
||||
|
||||
Section(content: {
|
||||
// Use the selected sats amount as the placeholder text so that the UI is less confusing.
|
||||
// User can type in their custom amount, which hides the placeholder.
|
||||
TextField(selected_amount.amount.formatted(), text: $custom_amount)
|
||||
.keyboardType(.numberPad)
|
||||
.onReceive(Just(custom_amount)) { newValue in
|
||||
if let parsed = handle_string_amount(new_value: newValue) {
|
||||
self.custom_amount = parsed.formatted()
|
||||
self.custom_amount_sats = parsed
|
||||
} else {
|
||||
self.custom_amount = ""
|
||||
self.custom_amount_sats = nil
|
||||
}
|
||||
}
|
||||
}, header: {
|
||||
Text("Custom Zap Amount", comment: "Header text to indicate that the text field below it is to enter a custom zap amount.")
|
||||
})
|
||||
|
||||
Section(content: {
|
||||
TextField(NSLocalizedString("Awesome post!", comment: "Placeholder text for a comment to send as part of a zap to the user."), text: $comment)
|
||||
}, header: {
|
||||
Text("Comment", comment: "Header text to indicate that the text field below it is a comment that will be used to send as part of a zap to the user.")
|
||||
})
|
||||
func ZapTypeButton() -> some View {
|
||||
Button(action: {
|
||||
show_zap_types = true
|
||||
}) {
|
||||
switch zap_type {
|
||||
case .pub:
|
||||
Image(systemName: "person.2")
|
||||
Text("Public", comment: "Button text to indicate that the zap type is a public zap.")
|
||||
case .anon:
|
||||
Image(systemName: "person.fill.questionmark")
|
||||
Text("Anonymous", comment: "Button text to indicate that the zap type is a anonymous zap.")
|
||||
case .priv:
|
||||
Image(systemName: "lock")
|
||||
Text("Private", comment: "Button text to indicate that the zap type is a private zap.")
|
||||
case .non_zap:
|
||||
Image(systemName: "bolt")
|
||||
Text("None", comment: "Button text to indicate that the zap type is a private zap.")
|
||||
}
|
||||
.dismissKeyboardOnTap()
|
||||
|
||||
Section(content: {
|
||||
ZapTypePicker
|
||||
}, header: {
|
||||
Text("Zap Type", comment: "Header text to indicate that the picker below it is to choose the type of zap to send.")
|
||||
}, footer: {
|
||||
Text(zap_type_desc)
|
||||
})
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundColor(fontColor())
|
||||
.padding(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
|
||||
.background(DamusColors.adaptableGrey)
|
||||
.cornerRadius(15)
|
||||
}
|
||||
|
||||
var CustomZap: some View {
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
|
||||
ZapTypeButton()
|
||||
.padding(.top, 50)
|
||||
|
||||
if zapping {
|
||||
Text("Zapping...", comment: "Text to indicate that the app is in the process of sending a zap.")
|
||||
Spacer()
|
||||
|
||||
CustomZapTextField
|
||||
|
||||
AmountPicker
|
||||
|
||||
ZapReply
|
||||
|
||||
ZapButton
|
||||
|
||||
Spacer()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.sheet(isPresented: $show_zap_types) {
|
||||
if #available(iOS 16.0, *) {
|
||||
ZapPicker
|
||||
.presentationDetents([.medium])
|
||||
.presentationDragIndicator(.visible)
|
||||
} else {
|
||||
Button(NSLocalizedString("Zap", comment: "Button to send a zap.")) {
|
||||
let amount = custom_amount_sats ?? selected_amount.amount
|
||||
send_zap(damus_state: state, event: event, lnurl: lnurl, is_custom: true, comment: comment, amount_sats: amount, zap_type: zap_type)
|
||||
self.zapping = true
|
||||
}
|
||||
.zIndex(16)
|
||||
ZapPicker
|
||||
}
|
||||
|
||||
if let error {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
var ZapPicker: some View {
|
||||
ZapTypePicker(zap_type: $zap_type, settings: state.settings, profiles: state.profiles, pubkey: event.pubkey)
|
||||
}
|
||||
|
||||
var MainContent: some View {
|
||||
TheForm
|
||||
CustomZap
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func hideKeyboard() {
|
||||
let resign = #selector(UIResponder.resignFirstResponder)
|
||||
UIApplication.shared.sendAction(resign, to: nil, from: nil, for: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//
|
||||
// ZapTypePicker.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum ZapType: String, StringCodable {
|
||||
case pub
|
||||
case anon
|
||||
case priv
|
||||
case non_zap
|
||||
|
||||
init?(from string: String) {
|
||||
guard let v = ZapType(rawValue: string) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self = v
|
||||
}
|
||||
|
||||
func to_string() -> String {
|
||||
return self.rawValue
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct ZapTypePicker: View {
|
||||
@Binding var zap_type: ZapType
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
let profiles: Profiles
|
||||
let pubkey: String
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
func fillColor() -> Color {
|
||||
colorScheme == .light ? DamusColors.white : DamusColors.black
|
||||
}
|
||||
|
||||
func fontColor() -> Color {
|
||||
colorScheme == .light ? DamusColors.black : DamusColors.white
|
||||
}
|
||||
|
||||
var is_default: Bool {
|
||||
zap_type == settings.default_zap_type
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
HStack {
|
||||
Text("Zap type", comment: "Text to indicate that the buttons below it is for choosing the type of zap to send.")
|
||||
.font(.system(size: 25, weight: .heavy))
|
||||
Spacer()
|
||||
if !is_default {
|
||||
Button(action: {
|
||||
settings.default_zap_type = zap_type
|
||||
}) {
|
||||
Label(NSLocalizedString("Make Default", comment: "Button label to indicate that tapping it will make the selected zap type be the default for future zaps."), image: "checkmark.circle.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
ZapTypeSelection(text: "Public", comment: "Picker option to indicate that a zap should be sent publicly and identify the user as who sent it.", img: "person.2.circle.fill", action: {zap_type = ZapType.pub}, type: ZapType.pub)
|
||||
ZapTypeSelection(text: "Private", comment: "Picker option to indicate that a zap should be sent privately and not identify the user to the public.", img: "lock.circle.fill", action: {zap_type = ZapType.priv}, type: ZapType.priv)
|
||||
ZapTypeSelection(text: "Anonymous", comment: "Picker option to indicate that a zap should be sent anonymously and not identify the user as who sent it.", img: "person.crop.circle.fill.badge.questionmark", action: {zap_type = ZapType.anon}, type: ZapType.anon)
|
||||
ZapTypeSelection(text: "None", comment: "Picker option to indicate that sats should be sent to the user's wallet as a regular Lightning payment, not as a zap.", img: "bolt.circle.fill", action: {zap_type = ZapType.non_zap}, type: ZapType.non_zap)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
func ZapTypeSelection(text: LocalizedStringKey, comment: StaticString, img: String, action: @escaping () -> (), type: ZapType) -> some View {
|
||||
Button(action: action) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack {
|
||||
Image(systemName: img)
|
||||
.foregroundColor(.gray)
|
||||
.font(.system(size: 24))
|
||||
Text(text, comment: comment)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
Text(zap_type_desc(type: type, profiles: profiles, pubkey: pubkey))
|
||||
.padding(.horizontal)
|
||||
.foregroundColor(.gray)
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 400, maxWidth: .infinity, minHeight: 50, maxHeight: 70)
|
||||
.foregroundColor(fontColor())
|
||||
.background(zap_type == type ? fillColor() : DamusColors.adaptableGrey)
|
||||
.cornerRadius(15)
|
||||
.overlay(RoundedRectangle(cornerRadius: 15)
|
||||
.stroke(DamusColors.purple.opacity(zap_type == type ? 1.0 : 0.0), lineWidth: 2))
|
||||
}
|
||||
}
|
||||
|
||||
struct ZapTypePicker_Previews: PreviewProvider {
|
||||
@State static var zap_type: ZapType = .pub
|
||||
@State static var default_type: ZapType = .pub
|
||||
static var previews: some View {
|
||||
let ds = test_damus_state()
|
||||
ZapTypePicker(zap_type: $zap_type, settings: ds.settings, profiles: ds.profiles, pubkey: "bob")
|
||||
}
|
||||
}
|
||||
|
||||
func zap_type_desc(type: ZapType, profiles: Profiles, pubkey: String) -> String {
|
||||
switch type {
|
||||
case .pub:
|
||||
return NSLocalizedString("Everyone will see that you zapped", comment: "Description of public zap type where the zap is sent publicly and identifies the user who sent it.")
|
||||
case .anon:
|
||||
return NSLocalizedString("No one will see that you zapped", comment: "Description of anonymous zap type where the zap is sent anonymously and does not identify the user who sent it.")
|
||||
case .priv:
|
||||
let prof = profiles.lookup(id: pubkey)
|
||||
let name = Profile.displayName(profile: prof, pubkey: pubkey).username
|
||||
return String.localizedStringWithFormat(NSLocalizedString("private_zap_description", value: "Only '%@' will see that you zapped them", comment: "Description of private zap type where the zap is sent privately and does not identify the user to the public."), name)
|
||||
case .non_zap:
|
||||
return NSLocalizedString("No zaps will be sent, only a lightning payment.", comment: "Description of non-zap type where sats are sent to the user's wallet as a regular Lightning payment, not as a zap.")
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user