mirror of
git://jb55.com/damus
synced 2024-09-30 00:40:45 +00:00
Compare commits
9 Commits
5492d9f499
...
ae2f48484a
Author | SHA1 | Date | |
---|---|---|---|
|
ae2f48484a | ||
|
2c9b280a04 | ||
|
ba494f94ab | ||
|
26d2627a1c | ||
|
c2918aaf16 | ||
|
e332a7f82c | ||
|
8fbc9dc773 | ||
|
90c68fedfc | ||
|
ada99418f6 |
@ -36,6 +36,7 @@
|
|||||||
3ACB685F297633BC00C46468 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3ACB685D297633BC00C46468 /* Localizable.strings */; };
|
3ACB685F297633BC00C46468 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3ACB685D297633BC00C46468 /* Localizable.strings */; };
|
||||||
3ACBCB78295FE5C70037388A /* TimeAgoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACBCB77295FE5C70037388A /* TimeAgoTests.swift */; };
|
3ACBCB78295FE5C70037388A /* TimeAgoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACBCB77295FE5C70037388A /* TimeAgoTests.swift */; };
|
||||||
3AE45AF6297BB2E700C1D842 /* LibreTranslateServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */; };
|
3AE45AF6297BB2E700C1D842 /* LibreTranslateServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */; };
|
||||||
|
3AFE89C32BD4156F00AD31EF /* MCEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 3AFE89C22BD4156F00AD31EF /* MCEmojiPicker */; };
|
||||||
3CCD1E6A2A874C4E0099A953 /* Nip98HTTPAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCD1E692A874C4E0099A953 /* Nip98HTTPAuth.swift */; };
|
3CCD1E6A2A874C4E0099A953 /* Nip98HTTPAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCD1E692A874C4E0099A953 /* Nip98HTTPAuth.swift */; };
|
||||||
4C06670128FC7C5900038D2A /* RelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C06670028FC7C5900038D2A /* RelayView.swift */; };
|
4C06670128FC7C5900038D2A /* RelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C06670028FC7C5900038D2A /* RelayView.swift */; };
|
||||||
4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4C06670328FC7EC500038D2A /* Kingfisher */; };
|
4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4C06670328FC7EC500038D2A /* Kingfisher */; };
|
||||||
@ -444,8 +445,6 @@
|
|||||||
BA3759932ABCCEBA0018D73B /* CameraModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759902ABCCEBA0018D73B /* CameraModel.swift */; };
|
BA3759932ABCCEBA0018D73B /* CameraModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759902ABCCEBA0018D73B /* CameraModel.swift */; };
|
||||||
BA3759942ABCCEBA0018D73B /* CameraService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759912ABCCEBA0018D73B /* CameraService.swift */; };
|
BA3759942ABCCEBA0018D73B /* CameraService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759912ABCCEBA0018D73B /* CameraService.swift */; };
|
||||||
BA3759972ABCCF360018D73B /* CameraPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759962ABCCF360018D73B /* CameraPreview.swift */; };
|
BA3759972ABCCF360018D73B /* CameraPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759962ABCCF360018D73B /* CameraPreview.swift */; };
|
||||||
BA4AB0AE2A63B9270070A32A /* AddEmojiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AB0AD2A63B9270070A32A /* AddEmojiView.swift */; };
|
|
||||||
BA4AB0B02A63B94D0070A32A /* EmojiListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AB0AF2A63B94D0070A32A /* EmojiListItemView.swift */; };
|
|
||||||
BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA693073295D649800ADDB87 /* UserSettingsStore.swift */; };
|
BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA693073295D649800ADDB87 /* UserSettingsStore.swift */; };
|
||||||
BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; };
|
BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; };
|
||||||
D2277EEA2A089BD5006C3807 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.swift */; };
|
D2277EEA2A089BD5006C3807 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.swift */; };
|
||||||
@ -490,6 +489,7 @@
|
|||||||
D76556D62B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */; };
|
D76556D62B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */; };
|
||||||
D76874F32AE3632B00FB0F68 /* ProfileZapLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */; };
|
D76874F32AE3632B00FB0F68 /* ProfileZapLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */; };
|
||||||
D77BFA0B2AE3051200621634 /* ProfileActionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */; };
|
D77BFA0B2AE3051200621634 /* ProfileActionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */; };
|
||||||
|
D7831AF82BBE11E2005DA780 /* VideoCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7831AF72BBE11E2005DA780 /* VideoCacheTests.swift */; };
|
||||||
D783A63F2AD4E53D00658DDA /* SuggestedHashtagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */; };
|
D783A63F2AD4E53D00658DDA /* SuggestedHashtagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */; };
|
||||||
D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */; };
|
D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */; };
|
||||||
D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; };
|
D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; };
|
||||||
@ -520,6 +520,7 @@
|
|||||||
D7ADD3DE2B53854300F104C4 /* DamusPurpleURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */; };
|
D7ADD3DE2B53854300F104C4 /* DamusPurpleURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */; };
|
||||||
D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */; };
|
D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */; };
|
||||||
D7ADD3E22B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */; };
|
D7ADD3E22B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */; };
|
||||||
|
D7C28E3B2BBB4D0000EE459F /* VideoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C28E3A2BBB4D0000EE459F /* VideoCache.swift */; };
|
||||||
D7C6787E2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */; };
|
D7C6787E2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */; };
|
||||||
D7CB5D3B2B112FBB00AD4105 /* NotificationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */; };
|
D7CB5D3B2B112FBB00AD4105 /* NotificationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */; };
|
||||||
D7CB5D3C2B1130C600AD4105 /* LocalNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDA128B29EB19C40006FA5A /* LocalNotification.swift */; };
|
D7CB5D3C2B1130C600AD4105 /* LocalNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDA128B29EB19C40006FA5A /* LocalNotification.swift */; };
|
||||||
@ -1366,8 +1367,6 @@
|
|||||||
BA3759902ABCCEBA0018D73B /* CameraModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraModel.swift; sourceTree = "<group>"; };
|
BA3759902ABCCEBA0018D73B /* CameraModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraModel.swift; sourceTree = "<group>"; };
|
||||||
BA3759912ABCCEBA0018D73B /* CameraService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraService.swift; sourceTree = "<group>"; };
|
BA3759912ABCCEBA0018D73B /* CameraService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraService.swift; sourceTree = "<group>"; };
|
||||||
BA3759962ABCCF360018D73B /* CameraPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraPreview.swift; sourceTree = "<group>"; };
|
BA3759962ABCCF360018D73B /* CameraPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraPreview.swift; sourceTree = "<group>"; };
|
||||||
BA4AB0AD2A63B9270070A32A /* AddEmojiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEmojiView.swift; sourceTree = "<group>"; };
|
|
||||||
BA4AB0AF2A63B94D0070A32A /* EmojiListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiListItemView.swift; sourceTree = "<group>"; };
|
|
||||||
BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = "<group>"; };
|
BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = "<group>"; };
|
||||||
BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = "<group>"; };
|
BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = "<group>"; };
|
||||||
D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; };
|
D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; };
|
||||||
@ -1402,6 +1401,7 @@
|
|||||||
D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleWelcomeView.swift; sourceTree = "<group>"; };
|
D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleWelcomeView.swift; sourceTree = "<group>"; };
|
||||||
D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileZapLinkView.swift; sourceTree = "<group>"; };
|
D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileZapLinkView.swift; sourceTree = "<group>"; };
|
||||||
D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = "<group>"; };
|
D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = "<group>"; };
|
||||||
|
D7831AF72BBE11E2005DA780 /* VideoCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCacheTests.swift; sourceTree = "<group>"; };
|
||||||
D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedHashtagsView.swift; sourceTree = "<group>"; };
|
D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedHashtagsView.swift; sourceTree = "<group>"; };
|
||||||
D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; };
|
D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; };
|
||||||
D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; };
|
D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; };
|
||||||
@ -1417,6 +1417,7 @@
|
|||||||
D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURL.swift; sourceTree = "<group>"; };
|
D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURL.swift; sourceTree = "<group>"; };
|
||||||
D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURLSheetView.swift; sourceTree = "<group>"; };
|
D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURLSheetView.swift; sourceTree = "<group>"; };
|
||||||
D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleVerifyNpubView.swift; sourceTree = "<group>"; };
|
D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleVerifyNpubView.swift; sourceTree = "<group>"; };
|
||||||
|
D7C28E3A2BBB4D0000EE459F /* VideoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCache.swift; sourceTree = "<group>"; };
|
||||||
D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP98AuthenticatedRequest.swift; sourceTree = "<group>"; };
|
D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP98AuthenticatedRequest.swift; sourceTree = "<group>"; };
|
||||||
D7CB5D3D2B116DAD00AD4105 /* NotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManager.swift; sourceTree = "<group>"; };
|
D7CB5D3D2B116DAD00AD4105 /* NotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManager.swift; sourceTree = "<group>"; };
|
||||||
D7CB5D442B116FE800AD4105 /* Contacts+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Contacts+.swift"; sourceTree = "<group>"; };
|
D7CB5D442B116FE800AD4105 /* Contacts+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Contacts+.swift"; sourceTree = "<group>"; };
|
||||||
@ -1469,6 +1470,7 @@
|
|||||||
4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */,
|
4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */,
|
||||||
4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */,
|
4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */,
|
||||||
4C27C9322A64766F007DBC75 /* MarkdownUI in Frameworks */,
|
4C27C9322A64766F007DBC75 /* MarkdownUI in Frameworks */,
|
||||||
|
3AFE89C32BD4156F00AD31EF /* MCEmojiPicker in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -1650,6 +1652,7 @@
|
|||||||
D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */,
|
D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */,
|
||||||
B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */,
|
B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */,
|
||||||
B533694D2B66D791008A805E /* MutelistManager.swift */,
|
B533694D2B66D791008A805E /* MutelistManager.swift */,
|
||||||
|
D7C28E3A2BBB4D0000EE459F /* VideoCache.swift */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -1722,8 +1725,6 @@
|
|||||||
4C1A9A2629DDE31900516EAC /* TranslationSettingsView.swift */,
|
4C1A9A2629DDE31900516EAC /* TranslationSettingsView.swift */,
|
||||||
E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */,
|
E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */,
|
||||||
5053ACA62A56DF3B00851AE3 /* DeveloperSettingsView.swift */,
|
5053ACA62A56DF3B00851AE3 /* DeveloperSettingsView.swift */,
|
||||||
BA4AB0AD2A63B9270070A32A /* AddEmojiView.swift */,
|
|
||||||
BA4AB0AF2A63B94D0070A32A /* EmojiListItemView.swift */,
|
|
||||||
);
|
);
|
||||||
path = Settings;
|
path = Settings;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -2553,6 +2554,7 @@
|
|||||||
E06336A92B75832100A88E6B /* ImageMetadataTest.swift */,
|
E06336A92B75832100A88E6B /* ImageMetadataTest.swift */,
|
||||||
D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */,
|
D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */,
|
||||||
D72927AC2BAB515C00F93E90 /* RelayURLTests.swift */,
|
D72927AC2BAB515C00F93E90 /* RelayURLTests.swift */,
|
||||||
|
D7831AF72BBE11E2005DA780 /* VideoCacheTests.swift */,
|
||||||
);
|
);
|
||||||
path = damusTests;
|
path = damusTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -2825,6 +2827,7 @@
|
|||||||
4C649880286E0EE300EAE2B3 /* secp256k1 */,
|
4C649880286E0EE300EAE2B3 /* secp256k1 */,
|
||||||
4C06670328FC7EC500038D2A /* Kingfisher */,
|
4C06670328FC7EC500038D2A /* Kingfisher */,
|
||||||
4C27C9312A64766F007DBC75 /* MarkdownUI */,
|
4C27C9312A64766F007DBC75 /* MarkdownUI */,
|
||||||
|
3AFE89C22BD4156F00AD31EF /* MCEmojiPicker */,
|
||||||
);
|
);
|
||||||
productName = damus;
|
productName = damus;
|
||||||
productReference = 4CE6DEE327F7A08100C66700 /* damus.app */;
|
productReference = 4CE6DEE327F7A08100C66700 /* damus.app */;
|
||||||
@ -2962,6 +2965,7 @@
|
|||||||
4CCF9AB02A1FE80B00E03CFB /* XCRemoteSwiftPackageReference "GSPlayer" */,
|
4CCF9AB02A1FE80B00E03CFB /* XCRemoteSwiftPackageReference "GSPlayer" */,
|
||||||
4C27C9302A64766F007DBC75 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
4C27C9302A64766F007DBC75 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
||||||
D7A343EC2AD0D77C00CED48B /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */,
|
D7A343EC2AD0D77C00CED48B /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */,
|
||||||
|
3AFE89C12BD4156F00AD31EF /* XCRemoteSwiftPackageReference "MCEmojiPicker" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */;
|
productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@ -3131,7 +3135,6 @@
|
|||||||
F75BA12F29A18EF500E10810 /* BookmarksView.swift in Sources */,
|
F75BA12F29A18EF500E10810 /* BookmarksView.swift in Sources */,
|
||||||
4CB883B6297730E400DC99E7 /* LNUrls.swift in Sources */,
|
4CB883B6297730E400DC99E7 /* LNUrls.swift in Sources */,
|
||||||
4C7FF7D52823313F009601DB /* Mentions.swift in Sources */,
|
4C7FF7D52823313F009601DB /* Mentions.swift in Sources */,
|
||||||
BA4AB0AE2A63B9270070A32A /* AddEmojiView.swift in Sources */,
|
|
||||||
4C32B94D2A9AD44700DC3548 /* Offset.swift in Sources */,
|
4C32B94D2A9AD44700DC3548 /* Offset.swift in Sources */,
|
||||||
4C633350283D40E500B1C9C3 /* HomeModel.swift in Sources */,
|
4C633350283D40E500B1C9C3 /* HomeModel.swift in Sources */,
|
||||||
4C987B57283FD07F0042CE38 /* FollowersModel.swift in Sources */,
|
4C987B57283FD07F0042CE38 /* FollowersModel.swift in Sources */,
|
||||||
@ -3174,7 +3177,6 @@
|
|||||||
4C9BB83429C12D9900FC4E37 /* EventProfileName.swift in Sources */,
|
4C9BB83429C12D9900FC4E37 /* EventProfileName.swift in Sources */,
|
||||||
4C7D09602A098C5D00943473 /* WalletView.swift in Sources */,
|
4C7D09602A098C5D00943473 /* WalletView.swift in Sources */,
|
||||||
4CB8838F296F781C00DC99E7 /* ReactionsView.swift in Sources */,
|
4CB8838F296F781C00DC99E7 /* ReactionsView.swift in Sources */,
|
||||||
BA4AB0B02A63B94D0070A32A /* EmojiListItemView.swift in Sources */,
|
|
||||||
B5C60C202B530D5100C5ECA7 /* MuteItem.swift in Sources */,
|
B5C60C202B530D5100C5ECA7 /* MuteItem.swift in Sources */,
|
||||||
4C75EFB328049D640006080F /* NostrEvent.swift in Sources */,
|
4C75EFB328049D640006080F /* NostrEvent.swift in Sources */,
|
||||||
4C32B9582A9AD44700DC3548 /* VeriferOptions.swift in Sources */,
|
4C32B9582A9AD44700DC3548 /* VeriferOptions.swift in Sources */,
|
||||||
@ -3288,6 +3290,7 @@
|
|||||||
4C1253522A76C6130004F4B8 /* ComposeNotify.swift in Sources */,
|
4C1253522A76C6130004F4B8 /* ComposeNotify.swift in Sources */,
|
||||||
4C7D09662A0AE62100943473 /* AlbyButton.swift in Sources */,
|
4C7D09662A0AE62100943473 /* AlbyButton.swift in Sources */,
|
||||||
D7100C582B76FC8400C59298 /* MarketingContentView.swift in Sources */,
|
D7100C582B76FC8400C59298 /* MarketingContentView.swift in Sources */,
|
||||||
|
D7C28E3B2BBB4D0000EE459F /* VideoCache.swift in Sources */,
|
||||||
4CAAD8AD298851D000060CEA /* AccountDeletion.swift in Sources */,
|
4CAAD8AD298851D000060CEA /* AccountDeletion.swift in Sources */,
|
||||||
4CFF8F6329CC9AD7008DB934 /* ImageContextMenuModifier.swift in Sources */,
|
4CFF8F6329CC9AD7008DB934 /* ImageContextMenuModifier.swift in Sources */,
|
||||||
4C54AA0A29A55429003E4487 /* EventGroup.swift in Sources */,
|
4C54AA0A29A55429003E4487 /* EventGroup.swift in Sources */,
|
||||||
@ -3544,6 +3547,7 @@
|
|||||||
75AD872B2AA23A460085EF2C /* Block+Tests.swift in Sources */,
|
75AD872B2AA23A460085EF2C /* Block+Tests.swift in Sources */,
|
||||||
E0E024112B7C19C20075735D /* TranslationTests.swift in Sources */,
|
E0E024112B7C19C20075735D /* TranslationTests.swift in Sources */,
|
||||||
F944F56E29EA9CCC0067B3BF /* DamusParseContentTests.swift in Sources */,
|
F944F56E29EA9CCC0067B3BF /* DamusParseContentTests.swift in Sources */,
|
||||||
|
D7831AF82BBE11E2005DA780 /* VideoCacheTests.swift in Sources */,
|
||||||
3A5E47C72A4A76C800C0D090 /* TrieTests.swift in Sources */,
|
3A5E47C72A4A76C800C0D090 /* TrieTests.swift in Sources */,
|
||||||
B501062D2B363036003874F5 /* AuthIntegrationTests.swift in Sources */,
|
B501062D2B363036003874F5 /* AuthIntegrationTests.swift in Sources */,
|
||||||
4CB883AE2976FA9300DC99E7 /* FormatTests.swift in Sources */,
|
4CB883AE2976FA9300DC99E7 /* FormatTests.swift in Sources */,
|
||||||
@ -4005,6 +4009,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)",
|
"$(PROJECT_DIR)",
|
||||||
);
|
);
|
||||||
|
MARKETING_VERSION = 1.9;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2;
|
PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@ -4054,6 +4059,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)",
|
"$(PROJECT_DIR)",
|
||||||
);
|
);
|
||||||
|
MARKETING_VERSION = 1.9;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2;
|
PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@ -4258,6 +4264,14 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
3AFE89C12BD4156F00AD31EF /* XCRemoteSwiftPackageReference "MCEmojiPicker" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/izyumkin/MCEmojiPicker";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 1.2.3;
|
||||||
|
};
|
||||||
|
};
|
||||||
4C06670228FC7EC500038D2A /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
|
4C06670228FC7EC500038D2A /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/onevcat/Kingfisher";
|
repositoryURL = "https://github.com/onevcat/Kingfisher";
|
||||||
@ -4301,6 +4315,11 @@
|
|||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
3AFE89C22BD4156F00AD31EF /* MCEmojiPicker */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 3AFE89C12BD4156F00AD31EF /* XCRemoteSwiftPackageReference "MCEmojiPicker" */;
|
||||||
|
productName = MCEmojiPicker;
|
||||||
|
};
|
||||||
4C06670328FC7EC500038D2A /* Kingfisher */ = {
|
4C06670328FC7EC500038D2A /* Kingfisher */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 4C06670228FC7EC500038D2A /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
package = 4C06670228FC7EC500038D2A /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||||
|
@ -18,6 +18,15 @@
|
|||||||
"version" : "7.6.1"
|
"version" : "7.6.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "mcemojipicker",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/izyumkin/MCEmojiPicker",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "e0b4903b75ae1cc418d276d84d1cb946b8a1d73c",
|
||||||
|
"version" : "1.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "secp256k1.swift",
|
"identity" : "secp256k1.swift",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
@ -516,6 +516,7 @@ struct ContentView: View {
|
|||||||
print("txn: 📙 DAMUS BACKGROUNDED")
|
print("txn: 📙 DAMUS BACKGROUNDED")
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
damus_state.ndb.close()
|
damus_state.ndb.close()
|
||||||
|
VideoCache.standard?.periodic_purge()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case .inactive:
|
case .inactive:
|
||||||
|
@ -14,6 +14,7 @@ struct LongformEvent {
|
|||||||
var image: URL? = nil
|
var image: URL? = nil
|
||||||
var summary: String? = nil
|
var summary: String? = nil
|
||||||
var published_at: Date? = nil
|
var published_at: Date? = nil
|
||||||
|
var labels: [String]? = nil
|
||||||
|
|
||||||
static func parse(from ev: NostrEvent) -> LongformEvent {
|
static func parse(from ev: NostrEvent) -> LongformEvent {
|
||||||
var longform = LongformEvent(event: ev)
|
var longform = LongformEvent(event: ev)
|
||||||
@ -26,6 +27,10 @@ struct LongformEvent {
|
|||||||
case "summary": longform.summary = tag[1].string()
|
case "summary": longform.summary = tag[1].string()
|
||||||
case "published_at":
|
case "published_at":
|
||||||
longform.published_at = Double(tag[1].string()).map { d in Date(timeIntervalSince1970: d) }
|
longform.published_at = Double(tag[1].string()).map { d in Date(timeIntervalSince1970: d) }
|
||||||
|
case "t":
|
||||||
|
if (longform.labels?.append(tag[1].string())) == nil {
|
||||||
|
longform.labels = [tag[1].string()]
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
396
damus/Models/VideoCache.swift
Normal file
396
damus/Models/VideoCache.swift
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
//
|
||||||
|
// VideoCache.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D'Aquino on 2024-04-01.
|
||||||
|
//
|
||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
import AVKit
|
||||||
|
|
||||||
|
// Default expiry time of only 1 day to prevent using too much storage
|
||||||
|
fileprivate let DEFAULT_EXPIRY_TIME: TimeInterval = 60*60*24
|
||||||
|
// Default cache directory is in the system-provided caches directory, so that the operating system can delete files when it needs storage space
|
||||||
|
// (https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html)
|
||||||
|
fileprivate let DEFAULT_CACHE_DIRECTORY_PATH: URL? = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("video_cache")
|
||||||
|
|
||||||
|
struct VideoCache {
|
||||||
|
private let cache_url: URL
|
||||||
|
private let expiry_time: TimeInterval
|
||||||
|
private var loader_queue: DispatchQueue
|
||||||
|
static var standard: VideoCache? = try? VideoCache()
|
||||||
|
|
||||||
|
init?(cache_url: URL? = nil, expiry_time: TimeInterval = DEFAULT_EXPIRY_TIME) throws {
|
||||||
|
guard let cache_url_to_apply = cache_url ?? DEFAULT_CACHE_DIRECTORY_PATH else { return nil }
|
||||||
|
self.cache_url = cache_url_to_apply
|
||||||
|
self.expiry_time = expiry_time
|
||||||
|
self.loader_queue = DispatchQueue.init(
|
||||||
|
label: "com.damus.video_loader",
|
||||||
|
qos: .utility,
|
||||||
|
attributes: []
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create the cache directory if it doesn't exist
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: self.cache_url, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
} catch {
|
||||||
|
Log.error("Could not create cache directory: %s", for: .storage, error.localizedDescription)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks for a cached video and returns its URL if available, otherwise downloads and caches the video.
|
||||||
|
func maybe_cached_url_for(video_url: URL) throws -> URL {
|
||||||
|
let cached_url = url_to_cached_url(url: video_url)
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: cached_url.path) {
|
||||||
|
// Check if the cached video has expired
|
||||||
|
let file_attributes = try FileManager.default.attributesOfItem(atPath: cached_url.path)
|
||||||
|
if let modification_date = file_attributes[.modificationDate] as? Date, Date().timeIntervalSince(modification_date) <= expiry_time {
|
||||||
|
// Video is not expired
|
||||||
|
return cached_url
|
||||||
|
} else {
|
||||||
|
return video_url
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return video_url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads video content using URLSession and caches it to disk.
|
||||||
|
private func download_and_cache_video(from url: URL) async throws -> URL {
|
||||||
|
let (data, response) = try await URLSession.shared.data(from: url)
|
||||||
|
|
||||||
|
guard let http_response = response as? HTTPURLResponse,
|
||||||
|
200..<300 ~= http_response.statusCode else {
|
||||||
|
throw URLError(.badServerResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
let destination_url = url_to_cached_url(url: url)
|
||||||
|
|
||||||
|
try data.write(to: destination_url)
|
||||||
|
return destination_url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an asset that may be cached (or not)
|
||||||
|
/// - Parameter video_url: The video URL to load
|
||||||
|
/// - Returns: An AVAsset + loader delegate wrapped together. The AVAsset can be used with AVPlayer. The loader delegate does not need to be used. Just keep it around to avoid it from being garbage collected
|
||||||
|
mutating func maybe_cached_asset_for(video_url: URL) throws -> MaybeCachedAVAsset? {
|
||||||
|
let maybe_cached_url = try self.maybe_cached_url_for(video_url: video_url)
|
||||||
|
if maybe_cached_url.isFileURL {
|
||||||
|
// We have this video cached. Return the cached asset
|
||||||
|
return MaybeCachedAVAsset(av_asset: AVAsset(url: maybe_cached_url), loader: nil)
|
||||||
|
}
|
||||||
|
// If we get here, we do not have the video cached yet.
|
||||||
|
// Load the video asset using our custom loader delegate, which will give us control over how video data is loaded, and allows us to cache it
|
||||||
|
guard let loader_delegate = LoaderDelegate(url: video_url, video_cache: self) else { return nil }
|
||||||
|
let video_asset = AVURLAsset(url: loader_delegate.streaming_url) // Get the modified URL that forces the AVAsset to use our loader delegate
|
||||||
|
video_asset.resourceLoader.setDelegate(loader_delegate, queue: self.loader_queue)
|
||||||
|
|
||||||
|
// Return the video asset to the player who is requesting this. Loading and caching will take place as AVPlayer makes loading requests
|
||||||
|
return MaybeCachedAVAsset(av_asset: video_asset, loader: loader_delegate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func url_to_cached_url(url: URL) -> URL {
|
||||||
|
let hashed_url = hash_url(url)
|
||||||
|
let file_extension = url.pathExtension
|
||||||
|
return self.cache_url.appendingPathComponent(hashed_url + "." + file_extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes all cached videos older than the expiry time.
|
||||||
|
func periodic_purge(completion: ((Error?) -> Void)? = nil) {
|
||||||
|
DispatchQueue.global(qos: .background).async {
|
||||||
|
Log.info("Starting periodic video cache purge", for: .storage)
|
||||||
|
let file_manager = FileManager.default
|
||||||
|
do {
|
||||||
|
let cached_files = try file_manager.contentsOfDirectory(at: self.cache_url, includingPropertiesForKeys: [.contentModificationDateKey], options: .skipsHiddenFiles)
|
||||||
|
|
||||||
|
for file in cached_files {
|
||||||
|
let attributes = try file.resourceValues(forKeys: [.contentModificationDateKey])
|
||||||
|
if let modification_date = attributes.contentModificationDate, Date().timeIntervalSince(modification_date) > self.expiry_time {
|
||||||
|
try file_manager.removeItem(at: file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
completion?(nil)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
completion?(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caches a video to storage with a given data
|
||||||
|
func save(data video_data: Data, for video_url: URL) throws {
|
||||||
|
if video_url.isFileURL {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.info("Caching video for: %s", for: .storage, video_url.absoluteString)
|
||||||
|
let cache_destination_url: URL = self.url_to_cached_url(url: video_url)
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: cache_destination_url.path) {
|
||||||
|
try FileManager.default.removeItem(at: cache_destination_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
try video_data.write(to: cache_destination_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hashes the URL using SHA-256
|
||||||
|
private func hash_url(_ url: URL) -> String {
|
||||||
|
let data = Data(url.absoluteString.utf8)
|
||||||
|
let hashed_data = SHA256.hash(data: data)
|
||||||
|
return hashed_data.compactMap { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MaybeCachedAVAsset {
|
||||||
|
let av_asset: AVAsset
|
||||||
|
let loader: LoaderDelegate?
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Resource loader delegate
|
||||||
|
|
||||||
|
/// This handles the nitty gritty of loading data for a particular video for the AVPlayer, and saves up that data to the cache.
|
||||||
|
class LoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDataDelegate, URLSessionTaskDelegate {
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
static let protocol_suffix = "cache"
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: Stored properties
|
||||||
|
|
||||||
|
/// The video cache to use when saving data
|
||||||
|
let cache: VideoCache
|
||||||
|
/// Video URL to be loaded
|
||||||
|
let url: URL
|
||||||
|
/// The URL to be used as a parameter to AVURLAsset, which forces it to use our delegate for data loading
|
||||||
|
let streaming_url: URL
|
||||||
|
/// The data loading requests we must fulfill
|
||||||
|
private var loading_requests = [AVAssetResourceLoadingRequest]()
|
||||||
|
/// The URL session we will use for handling video data loading
|
||||||
|
var url_session: URLSession? = nil
|
||||||
|
/// The video download task
|
||||||
|
var loading_task: URLSessionDataTask? = nil
|
||||||
|
/// The latest information response we received whilst downloading the video
|
||||||
|
var latest_info_response: URLResponse?
|
||||||
|
/// All of the video data we got so far from the download
|
||||||
|
var downloaded_video_data = Data()
|
||||||
|
/// Whether the download is successfully completed
|
||||||
|
var download_completed: Bool = false
|
||||||
|
/// Semaphore to avoid race conditions
|
||||||
|
let semaphore = DispatchSemaphore(value: 1)
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: Initializer
|
||||||
|
|
||||||
|
init?(url: URL, video_cache: VideoCache) {
|
||||||
|
self.cache = video_cache
|
||||||
|
self.url = url
|
||||||
|
guard let streaming_url = Self.streaming_url(from: url) else { return nil }
|
||||||
|
self.streaming_url = streaming_url
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: AVAssetResourceLoaderDelegate protocol implementation
|
||||||
|
// This allows us to handle the data loading for the AVPlayer
|
||||||
|
|
||||||
|
// This is called when our AVPlayer wants to load some video data. Here we need to do two things:
|
||||||
|
// - just respond whether or not we can handle the request
|
||||||
|
// - Queue up the load request so that we can work on it on the background
|
||||||
|
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
|
||||||
|
Log.debug("Receiving load request for: %s", for: .storage, self.url.absoluteString)
|
||||||
|
|
||||||
|
// Use semaphore to avoid race condition
|
||||||
|
semaphore.wait()
|
||||||
|
defer { semaphore.signal() } // Use defer to avoid forgetting to signal and causing deadlocks
|
||||||
|
|
||||||
|
self.start_downloading_video_if_not_already() // Start downloading data if we have not started
|
||||||
|
self.loading_requests.append(loadingRequest) // Add this loading request to our queue
|
||||||
|
return true // Yes Mr. AVPlayer, we can handle this loading request for you.
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is called when our AVPlayer wants to cancel a loading request.
|
||||||
|
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
|
||||||
|
Log.debug("Receiving load request cancellation for: %s", for: .storage, self.url.absoluteString)
|
||||||
|
|
||||||
|
// Use semaphore to avoid race condition
|
||||||
|
semaphore.wait()
|
||||||
|
defer { semaphore.signal() } // Use defer to avoid forgetting to signal and causing deadlocks
|
||||||
|
|
||||||
|
self.remove(loading_request: loadingRequest)
|
||||||
|
|
||||||
|
// Pause downloading if we have no loading requests from our AVPlayer
|
||||||
|
if loading_requests.isEmpty {
|
||||||
|
loading_task?.suspend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: URLSessionDataDelegate
|
||||||
|
// This helps us receive updates from our URL download session as we download the video
|
||||||
|
// This enables us to progressively serve AV loading requests we have on our queue
|
||||||
|
|
||||||
|
// Our URLSession (which is downloading the video) will call this function when we receive a URL response
|
||||||
|
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
||||||
|
Log.debug("Receiving URL response for: %s", for: .storage, self.url.absoluteString)
|
||||||
|
|
||||||
|
// Use semaphore to avoid race condition
|
||||||
|
semaphore.wait()
|
||||||
|
defer { semaphore.signal() } // Use defer to avoid forgetting to signal and causing deadlocks
|
||||||
|
|
||||||
|
self.latest_info_response = response
|
||||||
|
self.process_loading_requests()
|
||||||
|
|
||||||
|
completionHandler(.allow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our URLSession (which is downloading the video) will call this function when we receive some video data
|
||||||
|
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||||
|
Log.debug("Receiving data (%d bytes) for: %s", for: .storage, data.count, self.url.absoluteString)
|
||||||
|
|
||||||
|
// Use semaphore to avoid race condition
|
||||||
|
semaphore.wait()
|
||||||
|
defer { semaphore.signal() } // Use defer to avoid forgetting to signal and causing deadlocks
|
||||||
|
|
||||||
|
self.downloaded_video_data.append(data)
|
||||||
|
self.process_loading_requests()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: Internal methods
|
||||||
|
// Were we do some heavy lifting
|
||||||
|
|
||||||
|
/// Goes through the loading requests we received from the AVPlayer and respond to them if we can. This is called when we get updates from our download operation.
|
||||||
|
private func process_loading_requests() {
|
||||||
|
Log.debug("Processing loading requests for: %s", for: .storage, self.url.absoluteString)
|
||||||
|
var served_loading_requests = 0
|
||||||
|
for loading_request in loading_requests {
|
||||||
|
if loading_request.isCancelled {
|
||||||
|
self.remove(loading_request: loading_request)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let content_info_request = loading_request.contentInformationRequest,
|
||||||
|
let latest_info_response {
|
||||||
|
self.respond(to: content_info_request, with: latest_info_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let data_request = loading_request.dataRequest, self.respond_if_possible(to: data_request) == true {
|
||||||
|
served_loading_requests += 1
|
||||||
|
loading_request.finishLoading()
|
||||||
|
self.remove(loading_request: loading_request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.debug("Served %d loading requests for: %s", for: .storage, served_loading_requests, self.url.absoluteString)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func respond(to info_request: AVAssetResourceLoadingContentInformationRequest, with response: URLResponse) {
|
||||||
|
info_request.isByteRangeAccessSupported = true
|
||||||
|
info_request.contentType = response.mimeType
|
||||||
|
info_request.contentLength = response.expectedContentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
private func respond_if_possible(to data_request: AVAssetResourceLoadingDataRequest) -> Bool {
|
||||||
|
let bytes_downloaded = Int64(self.downloaded_video_data.count)
|
||||||
|
let bytes_requested = Int64(data_request.requestedLength)
|
||||||
|
|
||||||
|
if bytes_downloaded < data_request.currentOffset {
|
||||||
|
return false // We do not have enough bytes to respond to this request
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes_downloaded_but_unread = bytes_downloaded - data_request.currentOffset
|
||||||
|
let bytes_requested_and_unread = data_request.requestedOffset + bytes_requested - data_request.currentOffset
|
||||||
|
let bytes_to_respond = min(bytes_requested_and_unread, bytes_downloaded_but_unread)
|
||||||
|
|
||||||
|
guard let byte_range = Range(NSMakeRange(Int(data_request.currentOffset), Int(bytes_to_respond))) else { return false }
|
||||||
|
|
||||||
|
data_request.respond(with: self.downloaded_video_data.subdata(in: byte_range))
|
||||||
|
|
||||||
|
let request_end_offset = data_request.requestedOffset + bytes_requested
|
||||||
|
|
||||||
|
return data_request.currentOffset >= request_end_offset
|
||||||
|
}
|
||||||
|
|
||||||
|
private func start_downloading_video_if_not_already() {
|
||||||
|
if self.download_completed {
|
||||||
|
Log.info("Already downloaded video data for: %s. Won't start downloading again", for: .storage, self.url.absoluteString)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if self.url_session == nil {
|
||||||
|
self.downloaded_video_data = Data() // We are starting from scratch, so make sure we don't add corrupt data to the mix
|
||||||
|
let new_url_session = self.create_url_session()
|
||||||
|
let loading_task = new_url_session.dataTask(with: self.url)
|
||||||
|
loading_task.resume()
|
||||||
|
|
||||||
|
Log.info("Started downloading video data for: %s", for: .storage, self.url.absoluteString)
|
||||||
|
|
||||||
|
self.url_session = new_url_session
|
||||||
|
self.loading_task = loading_task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: URLSessionTaskDelegate
|
||||||
|
|
||||||
|
// Called when we are finished downloading the video
|
||||||
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
|
|
||||||
|
// Use semaphore to avoid race condition
|
||||||
|
semaphore.wait()
|
||||||
|
defer { semaphore.signal() } // Use defer to avoid forgetting to signal and causing deadlocks
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Log.info("Error on downloading '%s'. Error: %s", for: .storage, self.url.absoluteString, error.localizedDescription)
|
||||||
|
self.download_completed = false
|
||||||
|
self.url_session?.invalidateAndCancel()
|
||||||
|
self.url_session = nil
|
||||||
|
self.loading_task = nil
|
||||||
|
self.start_downloading_video_if_not_already()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.info("Finished downloading data for '%s' without errors", for: .storage, self.url.absoluteString)
|
||||||
|
self.download_completed = true
|
||||||
|
do {
|
||||||
|
try self.cache.save(data: self.downloaded_video_data, for: self.url)
|
||||||
|
Log.info("Saved cache video data for: %s", for: .storage, self.url.absoluteString)
|
||||||
|
self.url_session?.invalidateAndCancel()
|
||||||
|
self.url_session = nil
|
||||||
|
self.loading_task = nil
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Log.error("Failed to save cache video data for: %s", for: .storage, self.url.absoluteString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: Utility functions
|
||||||
|
|
||||||
|
/// Modifies the url to change its protocol and force AV loaders to use our delegate for data loading.
|
||||||
|
/// - Parameter url: The URL to be modified
|
||||||
|
/// - Returns: The modified URL with custom scheme
|
||||||
|
private static func streaming_url(from url: URL) -> URL? {
|
||||||
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
|
||||||
|
components.scheme = (components.scheme ?? "") + protocol_suffix
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func create_url_session() -> URLSession {
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
let operationQueue = OperationQueue()
|
||||||
|
operationQueue.maxConcurrentOperationCount = 1
|
||||||
|
return URLSession(
|
||||||
|
configuration: config,
|
||||||
|
delegate: self, // Set ourselves as the delegate, so that we can receive updates and use them to serve our AV Loading requests.
|
||||||
|
delegateQueue: operationQueue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a loading request from our queue
|
||||||
|
/// - Parameter loading_request: The loading request object to be removed
|
||||||
|
private func remove(loading_request: AVAssetResourceLoadingRequest) {
|
||||||
|
self.loading_requests.removeAll(where: { $0 == loading_request })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -240,6 +240,11 @@ func should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSet
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// don't translate reposts, longform, etc
|
||||||
|
if event.kind != 1 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Do not translate self-authored notes if logged in with a private key
|
// Do not translate self-authored notes if logged in with a private key
|
||||||
// as we can assume the user can understand their own notes.
|
// 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.
|
// The detected language prediction could be incorrect and not in the list of preferred languages.
|
||||||
|
@ -10,7 +10,7 @@ import Foundation
|
|||||||
// This is `fileprivate` because external code should use the `get_default_bootstrap_relays` instead.
|
// This is `fileprivate` because external code should use the `get_default_bootstrap_relays` instead.
|
||||||
fileprivate let BOOTSTRAP_RELAYS = [
|
fileprivate let BOOTSTRAP_RELAYS = [
|
||||||
"wss://relay.damus.io",
|
"wss://relay.damus.io",
|
||||||
"wss://eden.nostr.land",
|
"wss://nostr.land",
|
||||||
"wss://nostr.wine",
|
"wss://nostr.wine",
|
||||||
"wss://nos.lol",
|
"wss://nos.lol",
|
||||||
]
|
]
|
||||||
|
@ -6,8 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import MCEmojiPicker
|
||||||
|
|
||||||
|
|
||||||
struct EventActionBar: View {
|
struct EventActionBar: View {
|
||||||
let damus_state: DamusState
|
let damus_state: DamusState
|
||||||
@ -20,6 +19,8 @@ struct EventActionBar: View {
|
|||||||
@State var show_share_action: Bool = false
|
@State var show_share_action: Bool = false
|
||||||
@State var show_repost_action: Bool = false
|
@State var show_repost_action: Bool = false
|
||||||
|
|
||||||
|
@State private var isOnTopHalfOfScreen: Bool = false
|
||||||
|
|
||||||
@ObservedObject var bar: ActionBarModel
|
@ObservedObject var bar: ActionBarModel
|
||||||
|
|
||||||
init(damus_state: DamusState, event: NostrEvent, bar: ActionBarModel? = nil) {
|
init(damus_state: DamusState, event: NostrEvent, bar: ActionBarModel? = nil) {
|
||||||
@ -72,7 +73,7 @@ struct EventActionBar: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
LikeButton(damus_state: damus_state, liked: bar.liked, liked_emoji: bar.our_like != nil ? to_reaction_emoji(ev: bar.our_like!) : nil) { emoji in
|
LikeButton(damus_state: damus_state, liked: bar.liked, liked_emoji: bar.our_like != nil ? to_reaction_emoji(ev: bar.our_like!) : nil, isOnTopHalfOfScreen: $isOnTopHalfOfScreen) { emoji in
|
||||||
if bar.liked {
|
if bar.liked {
|
||||||
//notify(.delete, bar.our_like)
|
//notify(.delete, bar.our_like)
|
||||||
} else {
|
} else {
|
||||||
@ -135,6 +136,20 @@ struct EventActionBar: View {
|
|||||||
self.bar.our_like = liked.event
|
self.bar.our_like = liked.event
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.background(
|
||||||
|
GeometryReader { geometry in
|
||||||
|
EmptyView()
|
||||||
|
.onAppear {
|
||||||
|
let eventActionBarY = geometry.frame(in: .global).midY
|
||||||
|
let screenMidY = UIScreen.main.bounds.midY
|
||||||
|
self.isOnTopHalfOfScreen = eventActionBarY > screenMidY
|
||||||
|
}
|
||||||
|
.onChange(of: geometry.frame(in: .global).midY) { newY in
|
||||||
|
let screenMidY = UIScreen.main.bounds.midY
|
||||||
|
self.isOnTopHalfOfScreen = newY > screenMidY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func send_like(emoji: String) {
|
func send_like(emoji: String) {
|
||||||
@ -168,15 +183,17 @@ struct LikeButton: View {
|
|||||||
let damus_state: DamusState
|
let damus_state: DamusState
|
||||||
let liked: Bool
|
let liked: Bool
|
||||||
let liked_emoji: String?
|
let liked_emoji: String?
|
||||||
|
@Binding var isOnTopHalfOfScreen: Bool
|
||||||
let action: (_ emoji: String) -> Void
|
let action: (_ emoji: String) -> Void
|
||||||
|
|
||||||
// For reactions background
|
// For reactions background
|
||||||
@State private var showReactionsBG = 0
|
@State private var showReactionsBG = 0
|
||||||
@State private var showEmojis: [Int] = []
|
|
||||||
@State private var rotateThumb = -45
|
@State private var rotateThumb = -45
|
||||||
|
|
||||||
@State private var isReactionsVisible = false
|
@State private var isReactionsVisible = false
|
||||||
|
|
||||||
|
@State private var selectedEmoji: String = ""
|
||||||
|
|
||||||
// Following four are Shaka animation properties
|
// Following four are Shaka animation properties
|
||||||
let timer = Timer.publish(every: 0.10, on: .main, in: .common).autoconnect()
|
let timer = Timer.publish(every: 0.10, on: .main, in: .common).autoconnect()
|
||||||
@State private var shouldAnimate = false
|
@State private var shouldAnimate = false
|
||||||
@ -228,7 +245,15 @@ struct LikeButton: View {
|
|||||||
amountOfAngleIncrease = 20.0
|
amountOfAngleIncrease = 20.0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.overlay(reactionsOverlay())
|
.emojiPicker(
|
||||||
|
isPresented: $isReactionsVisible,
|
||||||
|
selectedEmoji: $selectedEmoji,
|
||||||
|
arrowDirection: isOnTopHalfOfScreen ? .down : .up,
|
||||||
|
isDismissAfterChoosing: true
|
||||||
|
)
|
||||||
|
.onChange(of: selectedEmoji) { newSelectedEmoji in
|
||||||
|
self.action(newSelectedEmoji)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func shakaAnimationLogic() {
|
func shakaAnimationLogic() {
|
||||||
@ -251,110 +276,11 @@ struct LikeButton: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func reactionsOverlay() -> some View {
|
|
||||||
Group {
|
|
||||||
if isReactionsVisible {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 20)
|
|
||||||
.frame(width: calculateOverlayWidth(), height: 50)
|
|
||||||
.foregroundColor(DamusColors.black)
|
|
||||||
.scaleEffect(Double(showReactionsBG), anchor: .topTrailing)
|
|
||||||
.animation(
|
|
||||||
.interpolatingSpring(stiffness: 170, damping: 15).delay(0.05),
|
|
||||||
value: showReactionsBG
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
Rectangle()
|
|
||||||
.foregroundColor(Color.white.opacity(0.2))
|
|
||||||
.frame(width: calculateOverlayWidth(), height: 50)
|
|
||||||
.clipShape(
|
|
||||||
RoundedRectangle(cornerRadius: 20)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.overlay(reactions())
|
|
||||||
}
|
|
||||||
.offset(y: -40)
|
|
||||||
.onTapGesture {
|
|
||||||
withAnimation(.easeOut(duration: 0.2)) {
|
|
||||||
isReactionsVisible = false
|
|
||||||
showReactionsBG = 0
|
|
||||||
}
|
|
||||||
showEmojis = []
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func calculateOverlayWidth() -> CGFloat {
|
|
||||||
let maxWidth: CGFloat = 250
|
|
||||||
let numberOfEmojis = emojis.count
|
|
||||||
let minimumWidth: CGFloat = 75
|
|
||||||
|
|
||||||
if numberOfEmojis > 0 {
|
|
||||||
let emojiWidth: CGFloat = 25
|
|
||||||
let padding: CGFloat = 15
|
|
||||||
let buttonWidth: CGFloat = 18
|
|
||||||
let buttonPadding: CGFloat = 20
|
|
||||||
|
|
||||||
let totalWidth = CGFloat(numberOfEmojis) * (emojiWidth + padding) + buttonWidth + buttonPadding
|
|
||||||
return min(maxWidth, max(minimumWidth, totalWidth))
|
|
||||||
} else {
|
|
||||||
return minimumWidth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reactions() -> some View {
|
|
||||||
HStack {
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 15) {
|
|
||||||
ForEach(emojis, id: \.self) { emoji in
|
|
||||||
if let index = emojis.firstIndex(of: emoji) {
|
|
||||||
let scale = index < showEmojis.count ? showEmojis[index] : 0
|
|
||||||
Text(emoji)
|
|
||||||
.font(.system(size: 25))
|
|
||||||
.scaleEffect(Double(scale))
|
|
||||||
.onTapGesture {
|
|
||||||
emojiTapped(emoji)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.leading, 10)
|
|
||||||
}
|
|
||||||
Button(action: {
|
|
||||||
withAnimation(.easeOut(duration: 0.2)) {
|
|
||||||
isReactionsVisible = false
|
|
||||||
showReactionsBG = 0
|
|
||||||
}
|
|
||||||
showEmojis = []
|
|
||||||
}) {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.font(.system(size: 18))
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
.padding(.trailing, 7.5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// When reaction button is long pressed, it displays the multiple emojis overlay and displays the user's selected emojis with an animation
|
// When reaction button is long pressed, it displays the multiple emojis overlay and displays the user's selected emojis with an animation
|
||||||
private func reactionLongPressed() {
|
private func reactionLongPressed() {
|
||||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
showEmojis = Array(repeating: 0, count: emojis.count) // Initialize the showEmojis array
|
|
||||||
|
|
||||||
for (index, _) in emojis.enumerated() {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1 * Double(index)) {
|
|
||||||
withAnimation(.interpolatingSpring(stiffness: 170, damping: 8)) {
|
|
||||||
if index < showEmojis.count {
|
|
||||||
showEmojis[index] = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isReactionsVisible = true
|
isReactionsVisible = true
|
||||||
showReactionsBG = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func emojiTapped(_ emoji: String) {
|
private func emojiTapped(_ emoji: String) {
|
||||||
@ -364,9 +290,7 @@ struct LikeButton: View {
|
|||||||
|
|
||||||
withAnimation(.easeOut(duration: 0.2)) {
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
isReactionsVisible = false
|
isReactionsVisible = false
|
||||||
showReactionsBG = 0
|
|
||||||
}
|
}
|
||||||
showEmojis = []
|
|
||||||
|
|
||||||
withAnimation(Animation.easeOut(duration: 0.15)) {
|
withAnimation(Animation.easeOut(duration: 0.15)) {
|
||||||
shouldAnimate = true
|
shouldAnimate = true
|
||||||
|
@ -29,7 +29,7 @@ struct EventBody: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if event.known_kind == .longform {
|
if event.known_kind == .longform {
|
||||||
LongformPreviewBody(state: damus_state, ev: event, options: options)
|
LongformPreviewBody(state: damus_state, ev: event, options: options, header: true)
|
||||||
|
|
||||||
// truncated longform bodies are just the preview
|
// truncated longform bodies are just the preview
|
||||||
if !options.contains(.truncate_content) {
|
if !options.contains(.truncate_content) {
|
||||||
|
@ -6,25 +6,31 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
struct LongformPreviewBody: View {
|
struct LongformPreviewBody: View {
|
||||||
let state: DamusState
|
let state: DamusState
|
||||||
let event: LongformEvent
|
let event: LongformEvent
|
||||||
let options: EventViewOptions
|
let options: EventViewOptions
|
||||||
|
let header: Bool
|
||||||
|
@State var blur_images: Bool = true
|
||||||
|
|
||||||
@ObservedObject var artifacts: NoteArtifactsModel
|
@ObservedObject var artifacts: NoteArtifactsModel
|
||||||
|
|
||||||
init(state: DamusState, ev: LongformEvent, options: EventViewOptions) {
|
init(state: DamusState, ev: LongformEvent, options: EventViewOptions, header: Bool) {
|
||||||
self.state = state
|
self.state = state
|
||||||
self.event = ev
|
self.event = ev
|
||||||
self.options = options
|
self.options = options
|
||||||
|
self.header = header
|
||||||
|
|
||||||
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model)
|
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(state: DamusState, ev: NostrEvent, options: EventViewOptions) {
|
init(state: DamusState, ev: NostrEvent, options: EventViewOptions, header: Bool) {
|
||||||
self.state = state
|
self.state = state
|
||||||
self.event = LongformEvent.parse(from: ev)
|
self.event = LongformEvent.parse(from: ev)
|
||||||
self.options = options
|
self.options = options
|
||||||
|
self.header = header
|
||||||
|
|
||||||
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
|
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
|
||||||
}
|
}
|
||||||
@ -34,6 +40,67 @@ struct LongformPreviewBody: View {
|
|||||||
return Text(wordCount)
|
return Text(wordCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var truncate: Bool {
|
||||||
|
return options.contains(.truncate_content)
|
||||||
|
}
|
||||||
|
|
||||||
|
var truncate_very_short: Bool {
|
||||||
|
return options.contains(.truncate_content_very_short)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncatedText(content: CompatibleText) -> some View {
|
||||||
|
Group {
|
||||||
|
if truncate_very_short {
|
||||||
|
TruncatedText(text: content, maxChars: 140)
|
||||||
|
.font(header ? .body : .caption)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
}
|
||||||
|
else if truncate {
|
||||||
|
TruncatedText(text: content)
|
||||||
|
.font(header ? .body : .caption)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
} else {
|
||||||
|
content.text
|
||||||
|
.font(header ? .body : .caption)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Placeholder(url: URL) -> some View {
|
||||||
|
Group {
|
||||||
|
if let meta = state.events.lookup_img_metadata(url: url),
|
||||||
|
case .processed(let blurhash) = meta.state {
|
||||||
|
Image(uiImage: blurhash)
|
||||||
|
.resizable()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: header ? .infinity : 150)
|
||||||
|
} else {
|
||||||
|
DamusColors.adaptableWhite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleImage(url: URL) -> some View {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
.background {
|
||||||
|
Placeholder(url: url)
|
||||||
|
}
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: header ? .infinity : 150)
|
||||||
|
.cornerRadius(1)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if options.contains(.wide) {
|
if options.contains(.wide) {
|
||||||
@ -46,23 +113,71 @@ struct LongformPreviewBody: View {
|
|||||||
|
|
||||||
var Main: some View {
|
var Main: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
if let title = event.title {
|
if let url = event.image {
|
||||||
Text(title)
|
if (self.options.contains(.no_media)) {
|
||||||
.font(.title)
|
EmptyView()
|
||||||
} else {
|
} else if !blur_images || (!blur_images && !state.settings.media_previews) {
|
||||||
Text("Untitled", comment: "Text indicating that the long-form note title is untitled.")
|
titleImage(url: url)
|
||||||
.font(.title)
|
} else if blur_images || (blur_images && !state.settings.media_previews) {
|
||||||
|
ZStack {
|
||||||
|
titleImage(url: url)
|
||||||
|
Blur()
|
||||||
|
.onTapGesture {
|
||||||
|
blur_images = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(event.title ?? "Untitled")
|
||||||
|
.font(header ? .title : .headline)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.top, 5)
|
||||||
|
|
||||||
|
if let summary = event.summary {
|
||||||
|
truncatedText(content: CompatibleText(stringLiteral: summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let labels = event.labels {
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack {
|
||||||
|
ForEach(labels, id: \.self) { label in
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.padding(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
|
||||||
|
.background(DamusColors.neutral1)
|
||||||
|
.cornerRadius(20)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 20)
|
||||||
|
.stroke(DamusColors.neutral3, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
.padding(10)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(event.summary ?? "")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
|
|
||||||
if case .loaded(let arts) = artifacts.state,
|
if case .loaded(let arts) = artifacts.state,
|
||||||
case .longform(let longform) = arts
|
case .longform(let longform) = arts
|
||||||
{
|
{
|
||||||
Words(longform.words).font(.footnote)
|
Words(longform.words).font(.footnote)
|
||||||
|
.padding([.horizontal, .bottom], 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(DamusColors.neutral3)
|
||||||
|
.cornerRadius(10)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(DamusColors.neutral1, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.onAppear {
|
||||||
|
blur_images = should_blur_images(settings: state.settings, contacts: state.contacts, ev: event.event, our_pubkey: state.pubkey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,7 +194,7 @@ struct LongformPreview: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
EventShell(state: state, event: event.event, options: options) {
|
EventShell(state: state, event: event.event, options: options) {
|
||||||
LongformPreviewBody(state: state, ev: event, options: options)
|
LongformPreviewBody(state: state, ev: event, options: options, header: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,48 +0,0 @@
|
|||||||
//
|
|
||||||
// AddEmojiView.swift
|
|
||||||
// damus
|
|
||||||
//
|
|
||||||
// Created by Suhail Saqan on 7/16/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct AddEmojiView: View {
|
|
||||||
@Binding var emoji: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack(alignment: .leading) {
|
|
||||||
HStack{
|
|
||||||
TextField(NSLocalizedString("⚡", comment: "Placeholder example for an emoji reaction"), text: $emoji)
|
|
||||||
.padding(2)
|
|
||||||
.padding(.leading, 25)
|
|
||||||
.opacity(emoji == "" ? 0.5 : 1)
|
|
||||||
.autocorrectionDisabled(true)
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.onChange(of: emoji) { newEmoji in
|
|
||||||
if let lastEmoji = newEmoji.last.map(String.init), isValidEmoji(lastEmoji) {
|
|
||||||
self.emoji = lastEmoji
|
|
||||||
} else {
|
|
||||||
self.emoji = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Label("", image: "close-circle")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
.padding(.trailing, -25.0)
|
|
||||||
.opacity((emoji == "") ? 0.0 : 1.0)
|
|
||||||
.onTapGesture {
|
|
||||||
self.emoji = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Label("", image: "copy2")
|
|
||||||
.padding(.leading, -10)
|
|
||||||
.onTapGesture {
|
|
||||||
if let pastedEmoji = UIPasteboard.general.string {
|
|
||||||
self.emoji = pastedEmoji
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,81 +0,0 @@
|
|||||||
//
|
|
||||||
// EmojiListItemView.swift
|
|
||||||
// damus
|
|
||||||
//
|
|
||||||
// Created by Suhail Saqan on 7/16/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct EmojiListItemView: View {
|
|
||||||
@ObservedObject var settings: UserSettingsStore
|
|
||||||
|
|
||||||
let emoji: String
|
|
||||||
let recommended: Bool
|
|
||||||
|
|
||||||
@Binding var showActionButtons: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
HStack {
|
|
||||||
if showActionButtons {
|
|
||||||
if recommended {
|
|
||||||
AddButton()
|
|
||||||
} else {
|
|
||||||
RemoveButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(emoji)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.swipeActions {
|
|
||||||
if !recommended {
|
|
||||||
RemoveButton()
|
|
||||||
.tint(.red)
|
|
||||||
} else {
|
|
||||||
AddButton()
|
|
||||||
.tint(.green)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.contextMenu {
|
|
||||||
if !showActionButtons {
|
|
||||||
CopyAction(emoji: emoji)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func CopyAction(emoji: String) -> some View {
|
|
||||||
Button {
|
|
||||||
UIPasteboard.general.setValue(emoji, forPasteboardType: "public.plain-text")
|
|
||||||
} label: {
|
|
||||||
Label(NSLocalizedString("Copy", comment: "Button to copy an emoji reaction"), image: "copy2")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func RemoveButton() -> some View {
|
|
||||||
Button(action: {
|
|
||||||
if let index = settings.emoji_reactions.firstIndex(of: emoji) {
|
|
||||||
settings.emoji_reactions.remove(at: index)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Image(systemName: "minus.circle")
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 20, height: 20)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.padding(.leading, -5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AddButton() -> some View {
|
|
||||||
Button(action: {
|
|
||||||
settings.emoji_reactions.append(emoji)
|
|
||||||
}) {
|
|
||||||
Image(systemName: "plus.circle")
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 20, height: 20)
|
|
||||||
.foregroundColor(.green)
|
|
||||||
.padding(.leading, -5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,114 +6,31 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import MCEmojiPicker
|
||||||
|
|
||||||
struct ReactionsSettingsView: View {
|
struct ReactionsSettingsView: View {
|
||||||
@ObservedObject var settings: UserSettingsStore
|
@ObservedObject var settings: UserSettingsStore
|
||||||
|
@State private var isReactionsVisible: Bool = false
|
||||||
@State var new_emoji: String = ""
|
|
||||||
@State private var showActionButtons = false
|
|
||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
|
||||||
|
|
||||||
var recommended: [String] {
|
|
||||||
return getMissingRecommendedEmojis(added: settings.emoji_reactions)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
Section {
|
||||||
AddEmojiView(emoji: $new_emoji)
|
Text(settings.default_emoji_reaction)
|
||||||
|
.emojiPicker(
|
||||||
|
isPresented: $isReactionsVisible,
|
||||||
|
selectedEmoji: $settings.default_emoji_reaction,
|
||||||
|
arrowDirection: .up,
|
||||||
|
isDismissAfterChoosing: true
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
isReactionsVisible = true
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text(NSLocalizedString("Add Emoji", comment: "Label for section for adding an emoji to the reactions list."))
|
Text(NSLocalizedString("Select default emoji", comment: "Prompt selection of user's default emoji reaction"))
|
||||||
.font(.system(size: 18, weight: .heavy))
|
|
||||||
.padding(.bottom, 5)
|
|
||||||
} footer: {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
if !new_emoji.isEmpty {
|
|
||||||
Button(NSLocalizedString("Cancel", comment: "Button to cancel out of view adding user inputted emoji.")) {
|
|
||||||
new_emoji = ""
|
|
||||||
}
|
|
||||||
.font(.system(size: 14, weight: .bold))
|
|
||||||
.frame(width: 80, height: 30)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.background(LINEAR_GRADIENT)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
.padding(EdgeInsets(top: 15, leading: 0, bottom: 0, trailing: 0))
|
|
||||||
|
|
||||||
Button(NSLocalizedString("Add", comment: "Button to confirm adding user inputted emoji.")) {
|
|
||||||
if isValidEmoji(new_emoji) {
|
|
||||||
settings.emoji_reactions.append(new_emoji)
|
|
||||||
new_emoji = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(.system(size: 14, weight: .bold))
|
|
||||||
.frame(width: 80, height: 30)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.background(LINEAR_GRADIENT)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
.padding(EdgeInsets(top: 15, leading: 0, bottom: 0, trailing: 0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Picker(NSLocalizedString("Select default emoji", comment: "Prompt selection of user's default emoji reaction"),
|
|
||||||
selection: $settings.default_emoji_reaction) {
|
|
||||||
ForEach(settings.emoji_reactions, id: \.self) { emoji in
|
|
||||||
Text(emoji)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
List {
|
|
||||||
ForEach(Array(zip(settings.emoji_reactions, 1...)), id: \.1) { tup in
|
|
||||||
EmojiListItemView(settings: settings, emoji: tup.0, recommended: false, showActionButtons: $showActionButtons)
|
|
||||||
}
|
|
||||||
.onMove(perform: showActionButtons ? move: nil)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Emoji Reactions", comment: "Section title for emoji reactions that are currently added.")
|
|
||||||
.font(.system(size: 18, weight: .heavy))
|
|
||||||
.padding(.bottom, 5)
|
|
||||||
}
|
|
||||||
|
|
||||||
if recommended.count > 0 {
|
|
||||||
Section {
|
|
||||||
List(Array(zip(recommended, 1...)), id: \.1) { tup in
|
|
||||||
EmojiListItemView(settings: settings, emoji: tup.0, recommended: true, showActionButtons: $showActionButtons)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Recommended Emojis", comment: "Section title for recommend emojis")
|
|
||||||
.font(.system(size: 18, weight: .heavy))
|
|
||||||
.padding(.bottom, 5)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(NSLocalizedString("Reactions", comment: "Title of emoji reactions view"))
|
.navigationTitle(NSLocalizedString("Reactions", comment: "Title of emoji reactions view"))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.toolbar {
|
|
||||||
if showActionButtons {
|
|
||||||
Button("Done") {
|
|
||||||
showActionButtons.toggle()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Button("Edit") {
|
|
||||||
showActionButtons.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func move(from: IndexSet, to: Int) {
|
|
||||||
settings.emoji_reactions.move(fromOffsets: from, toOffset: to)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the emojis that are in the recommended list but the user has not added yet
|
|
||||||
func getMissingRecommendedEmojis(added: [String], recommended: [String] = default_emoji_reactions) -> [String] {
|
|
||||||
let addedSet = Set(added)
|
|
||||||
let missingEmojis = recommended.filter { !addedSet.contains($0) }
|
|
||||||
return missingEmojis
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,7 +33,7 @@ struct DamusVideoPlayer: View {
|
|||||||
else {
|
else {
|
||||||
mute = nil
|
mute = nil
|
||||||
}
|
}
|
||||||
_model = StateObject(wrappedValue: DamusVideoPlayerViewModel(url: url, video_size: video_size, controller: controller, mute: mute))
|
_model = StateObject(wrappedValue: DamusVideoPlayerViewModel.cached_video_model(url: url, video_size: video_size, controller: controller, mute: mute))
|
||||||
self.visibility_tracking_method = visibility_tracking_method
|
self.visibility_tracking_method = visibility_tracking_method
|
||||||
self.style = style
|
self.style = style
|
||||||
}
|
}
|
||||||
|
@ -26,6 +26,7 @@ func video_has_audio(player: AVPlayer) async -> Bool {
|
|||||||
final class DamusVideoPlayerViewModel: ObservableObject {
|
final class DamusVideoPlayerViewModel: ObservableObject {
|
||||||
|
|
||||||
private let url: URL
|
private let url: URL
|
||||||
|
private let maybe_cached_av_asset: VideoCache.MaybeCachedAVAsset?
|
||||||
private let player_item: AVPlayerItem
|
private let player_item: AVPlayerItem
|
||||||
let player: AVPlayer
|
let player: AVPlayer
|
||||||
fileprivate let controller: VideoController
|
fileprivate let controller: VideoController
|
||||||
@ -57,10 +58,22 @@ final class DamusVideoPlayerViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func cached_video_model(url: URL, video_size: Binding<CGSize?>, controller: VideoController, mute: Bool? = nil) -> Self {
|
||||||
|
let maybe_cached_url = (try? VideoCache.standard?.maybe_cached_url_for(video_url: url)) ?? url
|
||||||
|
Log.info("Loading video with URL: %s",for: .render, maybe_cached_url.absoluteString)
|
||||||
|
return Self.init(url: maybe_cached_url, video_size: video_size, controller: controller, mute: mute)
|
||||||
|
}
|
||||||
|
|
||||||
init(url: URL, video_size: Binding<CGSize?>, controller: VideoController, mute: Bool? = nil) {
|
init(url: URL, video_size: Binding<CGSize?>, controller: VideoController, mute: Bool? = nil) {
|
||||||
self.url = url
|
self.url = url
|
||||||
player_item = AVPlayerItem(url: url)
|
let maybe_cached_av_asset = try? VideoCache.standard?.maybe_cached_asset_for(video_url: url)
|
||||||
|
if maybe_cached_av_asset == nil {
|
||||||
|
Log.info("Something went wrong when trying to load the video with the video cache. Gracefully downgrading to non-cache video loading", for: .storage)
|
||||||
|
}
|
||||||
|
self.maybe_cached_av_asset = maybe_cached_av_asset // Save this wrapped asset to avoid having the loader delegate garbage collected while we still need it.
|
||||||
|
player_item = AVPlayerItem(asset: self.maybe_cached_av_asset?.av_asset ?? AVURLAsset(url: url))
|
||||||
player = AVPlayer(playerItem: player_item)
|
player = AVPlayer(playerItem: player_item)
|
||||||
|
player.automaticallyWaitsToMinimizeStalling = true
|
||||||
self.controller = controller
|
self.controller = controller
|
||||||
_video_size = video_size
|
_video_size = video_size
|
||||||
|
|
||||||
|
@ -96,9 +96,16 @@ struct ConnectWalletView: View {
|
|||||||
openURL(URL(string:"https://nwc.getalby.com/apps/new?c=Damus")!)
|
openURL(URL(string:"https://nwc.getalby.com/apps/new?c=Damus")!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Mutiny Wallet NWC is way too advanced to recommend for normal
|
||||||
|
// users until they have a way to do async receive.
|
||||||
|
//
|
||||||
|
|
||||||
|
/*
|
||||||
MutinyButton() {
|
MutinyButton() {
|
||||||
openURL(URL(string:"https://app.mutinywallet.com/settings/connections?callbackUri=nostr%2bwalletconnect&name=Damus")!)
|
openURL(URL(string:"https://app.mutinywallet.com/settings/connections?callbackUri=nostr%2bwalletconnect&name=Damus")!)
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if let pasted_nwc = UIPasteboard.general.string {
|
if let pasted_nwc = UIPasteboard.general.string {
|
||||||
|
112
damusTests/VideoCacheTests.swift
Normal file
112
damusTests/VideoCacheTests.swift
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
//
|
||||||
|
// VideoCacheTests.swift
|
||||||
|
// damusTests
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2024-04-03.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import damus
|
||||||
|
|
||||||
|
// TODO: Reduce test dependency on external factors such as external URLs.
|
||||||
|
let TEST_VIDEO_URL = "http://cdn.jb55.com/s/zaps-build.mp4"
|
||||||
|
let LONG_TEST_EXPIRY_TIME: TimeInterval = 60 * 60 * 24 // A long expiry time for a video (in seconds).
|
||||||
|
let SHORT_TEST_EXPIRY_TIME: TimeInterval = 15 // A short expiry time for a video (in seconds). Must be as short as possible but large enough to allow some test operations to occur
|
||||||
|
let CACHE_SAVE_TIME_TIMEOUT: TimeInterval = 8 // How long the test will wait for the cache to save a file (in seconds)
|
||||||
|
let EXPIRY_TIME_MARGIN: TimeInterval = 3 // The extra time we will wait after expected expiry, to avoid test timing issues. (in seconds)
|
||||||
|
|
||||||
|
final class VideoCacheTests: XCTestCase {
|
||||||
|
|
||||||
|
func testCachedURLForExistingVideo() throws {
|
||||||
|
// Create a temporary directory for the cache
|
||||||
|
let test_cache_directory = FileManager.default.temporaryDirectory.appendingPathComponent("test_video_cache")
|
||||||
|
|
||||||
|
// Create a test video file
|
||||||
|
let original_video_url = URL(string: TEST_VIDEO_URL)!
|
||||||
|
FileManager.default.createFile(atPath: original_video_url.path, contents: Data(), attributes: nil)
|
||||||
|
|
||||||
|
// Create a VideoCache instance with the temporary cache directory
|
||||||
|
let test_expiry_time: TimeInterval = 10
|
||||||
|
let video_cache = try VideoCache(cache_url: test_cache_directory, expiry_time: test_expiry_time)!
|
||||||
|
|
||||||
|
// Call the maybe_cached_url_for method with the test video URL
|
||||||
|
let expected_cache_url = video_cache.url_to_cached_url(url: original_video_url)
|
||||||
|
let maybe_cached_url = try video_cache.maybe_cached_url_for(video_url: original_video_url)
|
||||||
|
|
||||||
|
// Assert that the returned URL is the same as the original
|
||||||
|
XCTAssertEqual(maybe_cached_url, original_video_url, "Returned URL should be the same as the original video URL on the first time we download it")
|
||||||
|
|
||||||
|
// Check that next time we get this video, we get the cached URL.
|
||||||
|
let cached_url_expectation = XCTestExpectation(description: "On second time we get a video, the cached URL should be returned")
|
||||||
|
let start_time = Date()
|
||||||
|
while Date().timeIntervalSince(start_time) < CACHE_SAVE_TIME_TIMEOUT {
|
||||||
|
let maybe_cached_url = try video_cache.maybe_cached_url_for(video_url: original_video_url)
|
||||||
|
if maybe_cached_url == expected_cache_url {
|
||||||
|
cached_url_expectation.fulfill()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sleep(1)
|
||||||
|
}
|
||||||
|
wait(for: [cached_url_expectation], timeout: CACHE_SAVE_TIME_TIMEOUT)
|
||||||
|
|
||||||
|
// Now wait for the remaining time until the expiry time + a margin
|
||||||
|
let remaining_time = test_expiry_time + EXPIRY_TIME_MARGIN - Date().timeIntervalSince(start_time)
|
||||||
|
|
||||||
|
// Wait for the expiry time to pass
|
||||||
|
sleep(UInt32(max(remaining_time, 0)))
|
||||||
|
|
||||||
|
// Call the periodic_purge method to purge expired video items
|
||||||
|
video_cache.periodic_purge()
|
||||||
|
|
||||||
|
// Call the maybe_cached_url_for method again
|
||||||
|
let maybe_cached_url_after_expiry = try video_cache.maybe_cached_url_for(video_url: original_video_url)
|
||||||
|
|
||||||
|
// Assert that the returned URL is the same as the original video URL, since the cache should have expired.
|
||||||
|
XCTAssertEqual(maybe_cached_url_after_expiry, original_video_url, "Video cache should expire after expiry time")
|
||||||
|
|
||||||
|
// Clean up the temporary files and directory
|
||||||
|
try FileManager.default.removeItem(at: test_cache_directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClearCache() throws {
|
||||||
|
// Create a temporary directory for the cache
|
||||||
|
let test_cache_directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("test_video_cache")
|
||||||
|
try FileManager.default.createDirectory(at: test_cache_directory, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
|
||||||
|
// Create a test video file
|
||||||
|
let original_video_url = URL(string: TEST_VIDEO_URL)!
|
||||||
|
FileManager.default.createFile(atPath: original_video_url.path, contents: Data(), attributes: nil)
|
||||||
|
|
||||||
|
// Create a VideoCache instance with the temporary cache directory and a longer expiry time
|
||||||
|
let expiry_time: TimeInterval = LONG_TEST_EXPIRY_TIME
|
||||||
|
let video_cache = try VideoCache(cache_url: test_cache_directory, expiry_time: expiry_time)!
|
||||||
|
|
||||||
|
// Request the cached URL for the test video to create the cached file
|
||||||
|
let expected_cache_url = video_cache.url_to_cached_url(url: original_video_url)
|
||||||
|
let _ = try video_cache.maybe_cached_url_for(video_url: original_video_url)
|
||||||
|
|
||||||
|
// Check that next time we get this video, we get the cached URL.
|
||||||
|
let cached_url_expectation = XCTestExpectation(description: "On second time we get a video, the cached URL should be returned")
|
||||||
|
let start_time = Date()
|
||||||
|
while Date().timeIntervalSince(start_time) < CACHE_SAVE_TIME_TIMEOUT {
|
||||||
|
let maybe_cached_url = try video_cache.maybe_cached_url_for(video_url: original_video_url)
|
||||||
|
if maybe_cached_url == expected_cache_url {
|
||||||
|
cached_url_expectation.fulfill()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sleep(1)
|
||||||
|
}
|
||||||
|
wait(for: [cached_url_expectation], timeout: CACHE_SAVE_TIME_TIMEOUT)
|
||||||
|
|
||||||
|
// Call the periodic_purge method
|
||||||
|
DamusCacheManager.shared.clear_cache(damus_state: test_damus_state, completion: {
|
||||||
|
// Assert that fetching the cached URL after clearing cache will
|
||||||
|
let maybe_cached_url_after_purge = try? video_cache.maybe_cached_url_for(video_url: original_video_url)
|
||||||
|
XCTAssertEqual(maybe_cached_url_after_purge, original_video_url)
|
||||||
|
|
||||||
|
// Clean up the temporary directory
|
||||||
|
try? FileManager.default.removeItem(at: test_cache_directory)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -99,12 +99,4 @@ final class WalletConnectTests: XCTestCase {
|
|||||||
XCTAssertEqual(ev.remaining.count, 1)
|
XCTAssertEqual(ev.remaining.count, 1)
|
||||||
XCTAssertEqual(ev.remaining[0].relay.url.absoluteString, "ws://127.0.0.1")
|
XCTAssertEqual(ev.remaining[0].relay.url.absoluteString, "ws://127.0.0.1")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPerformanceExample() throws {
|
|
||||||
// This is an example of a performance test case.
|
|
||||||
self.measure {
|
|
||||||
// Put the code you want to measure the time of here.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -29,13 +29,6 @@ class damusTests: XCTestCase {
|
|||||||
XCTAssertEqual(pubkey, pubkey_same)
|
XCTAssertEqual(pubkey, pubkey_same)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPerformanceExample() throws {
|
|
||||||
// This is an example of a performance test case.
|
|
||||||
self.measure {
|
|
||||||
// Put the code you want to measure the time of here.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRandomBytes() {
|
func testRandomBytes() {
|
||||||
let bytes = random_bytes(count: 32)
|
let bytes = random_bytes(count: 32)
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user