diff --git a/.gitignore b/.gitignore index 0f016b4..418e5d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ TwitterAPI-Info.plist +**/*.xcuserstate diff --git a/Broadcast.xcodeproj/project.pbxproj b/Broadcast.xcodeproj/project.pbxproj index 8f7380f..de308f6 100644 --- a/Broadcast.xcodeproj/project.pbxproj +++ b/Broadcast.xcodeproj/project.pbxproj @@ -8,9 +8,12 @@ /* Begin PBXBuildFile section */ 7101073626C810AC00A713A5 /* NullStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7101073526C810AC00A713A5 /* NullStateView.swift */; }; + 710F03BF27B6700D00AE6C5B /* TwitterClientManager+CompressMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 710F03BE27B6700D00AE6C5B /* TwitterClientManager+CompressMedia.swift */; }; + 710F03C127B970FB00AE6C5B /* EnvironmentKeys+CornerRadius.swift in Sources */ = {isa = PBXBuildFile; fileRef = 710F03C027B970FB00AE6C5B /* EnvironmentKeys+CornerRadius.swift */; }; 711EF99426C959A700FD8A9F /* BroadcastUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */; }; 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711F3FF9268F50C800605C89 /* Animation.extension.swift */; }; - 715AAE0A26C923A1002BCEA1 /* Swifter in Frameworks */ = {isa = PBXBuildFile; productRef = 715AAE0926C923A1002BCEA1 /* Swifter */; }; + 713E16EA27A6EAA900314B44 /* UserAvatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713E16E927A6EAA900314B44 /* UserAvatar.swift */; }; + 714782D5278D97AB00942618 /* Twift in Frameworks */ = {isa = PBXBuildFile; productRef = 714782D4278D97AB00942618 /* Twift */; }; 715AAE0C26C9589A002BCEA1 /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */; }; 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717041F226B6FFEA00001360 /* RepliesListView.swift */; }; 717041F526B7037300001360 /* TweetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717041F426B7037300001360 /* TweetView.swift */; }; @@ -18,41 +21,39 @@ 71800BF026905DB3009D11A1 /* EngagementCountersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BEF26905DB3009D11A1 /* EngagementCountersView.swift */; }; 71800BF226906721009D11A1 /* ActionBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BF126906721009D11A1 /* ActionBarView.swift */; }; 71800BF426906BC0009D11A1 /* DraftsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BF326906BC0009D11A1 /* DraftsListView.swift */; }; - 71800BF8269998BF009D11A1 /* UIImage.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BF7269998BF009D11A1 /* UIImage.extension.swift */; }; 71800BFD26999B1B009D11A1 /* DraftsModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 71800BFB26999B1B009D11A1 /* DraftsModel.xcdatamodeld */; }; 71800BFF26999BA6009D11A1 /* PersistanceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71800BFE26999BA6009D11A1 /* PersistanceController.swift */; }; 7188E6292687B0FE007CFD78 /* BroadcastApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6282687B0FE007CFD78 /* BroadcastApp.swift */; }; 7188E62B2687B0FE007CFD78 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E62A2687B0FE007CFD78 /* ContentView.swift */; }; 7188E62D2687B0FF007CFD78 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7188E62C2687B0FF007CFD78 /* Assets.xcassets */; }; 7188E6302687B0FF007CFD78 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7188E62F2687B0FF007CFD78 /* Preview Assets.xcassets */; }; - 7188E63B2687B19D007CFD78 /* Notification.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E63A2687B19D007CFD78 /* Notification.extension.swift */; }; - 7188E6472687B6C2007CFD78 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6462687B6C2007CFD78 /* SafariView.swift */; }; 7188E6492687C0E0007CFD78 /* BroadcastButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */; }; 7188E64B2687C44D007CFD78 /* String.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E64A2687C44D007CFD78 /* String.extension.swift */; }; 7188E64D2687C6A1007CFD78 /* Binding.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E64C2687C6A1007CFD78 /* Binding.extension.swift */; }; 7188E64F2687CCD0007CFD78 /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E64E2687CCD0007CFD78 /* PhotoPicker.swift */; }; 7188E6532687D16C007CFD78 /* AttachmentThumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6522687D16C007CFD78 /* AttachmentThumbnail.swift */; }; 7188E65D26887DCD007CFD78 /* SwiftKeychainWrapper in Frameworks */ = {isa = PBXBuildFile; productRef = 7188E65C26887DCD007CFD78 /* SwiftKeychainWrapper */; }; - 7188E65F26889147007CFD78 /* SignOutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E65E26889147007CFD78 /* SignOutView.swift */; }; 7188E6612688A01F007CFD78 /* Font.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6602688A01F007CFD78 /* Font.extension.swift */; }; 7188E6632688A0FC007CFD78 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6622688A0FC007CFD78 /* WelcomeView.swift */; }; 7188E6652688A436007CFD78 /* ThemeHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6642688A436007CFD78 /* ThemeHelper.swift */; }; - 7188E6672688B99E007CFD78 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E6662688B99E007CFD78 /* VisualEffectView.swift */; }; 7188E66A2688D7BA007CFD78 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 7188E6692688D7BA007CFD78 /* Introspect */; }; 7188E66C2688DB44007CFD78 /* UIApplication.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */; }; 719087CD26891586005B96CE /* TwitterText in Frameworks */ = {isa = PBXBuildFile; productRef = 719087CC26891586005B96CE /* TwitterText */; }; 719087CF26891C7F005B96CE /* Array.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 719087CE26891C7F005B96CE /* Array.extension.swift */; }; + 7197795B27B408540079AD69 /* AttachmentDropDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7197795A27B408540079AD69 /* AttachmentDropDelegate.swift */; }; 7199AE7A26B96D0D001DEB46 /* NSRegularExpression+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */; }; 7199AE7C26B97327001DEB46 /* UserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7B26B97327001DEB46 /* UserView.swift */; }; 7199AE7E26B973B2001DEB46 /* MentionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7D26B973B2001DEB46 /* MentionBar.swift */; }; 7199AE8026B9892E001DEB46 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7199AE7F26B9892E001DEB46 /* Debouncer.swift */; }; + 719D19C127AEDD4E003120F0 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 719D19C027AEDD4E003120F0 /* VisualEffectView.swift */; }; 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A10889268B073B007E1FFB /* Haptics.swift */; }; 71A6A264278C73AD00BF2387 /* TwitterAPI-Info.example.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */; }; - 71AA4AC6268A032400B7B577 /* RemoteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71AA4AC5268A032400B7B577 /* RemoteImage.swift */; }; - 71B8290C268D0AC6002AEE72 /* TwitterClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */; }; + 71A9157927A59C9000706024 /* LocalMediaPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A9157827A59C9000706024 /* LocalMediaPreview.swift */; }; + 71B8290C268D0AC6002AEE72 /* TwitterClientManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290B268D0AC6002AEE72 /* TwitterClientManager.swift */; }; 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */; }; 71BBAAE9268CF1BD004048A0 /* ComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */; }; 71BBAAEB268CF532004048A0 /* TwitterAPI-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 71BBAAEA268CF532004048A0 /* TwitterAPI-Info.plist */; }; + 71D283F127A469EF00640B2A /* AttributeScopes+TwitterEntities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D283F027A469EF00640B2A /* AttributeScopes+TwitterEntities.swift */; }; 71E36FFB2689EED40078D956 /* ShakeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E36FFA2689EED40078D956 /* ShakeModifier.swift */; }; /* End PBXBuildFile section */ @@ -68,10 +69,13 @@ /* Begin PBXFileReference section */ 7101073526C810AC00A713A5 /* NullStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NullStateView.swift; sourceTree = ""; }; + 710F03BE27B6700D00AE6C5B /* TwitterClientManager+CompressMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TwitterClientManager+CompressMedia.swift"; sourceTree = ""; }; + 710F03C027B970FB00AE6C5B /* EnvironmentKeys+CornerRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentKeys+CornerRadius.swift"; sourceTree = ""; }; 711EF99126C959A700FD8A9F /* BroadcastUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BroadcastUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 711EF99326C959A700FD8A9F /* BroadcastUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastUITests.swift; sourceTree = ""; }; 711EF99526C959A700FD8A9F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 711F3FF9268F50C800605C89 /* Animation.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Animation.extension.swift; sourceTree = ""; }; + 713E16E927A6EAA900314B44 /* UserAvatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAvatar.swift; sourceTree = ""; }; 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtils.swift; sourceTree = ""; }; 717041F226B6FFEA00001360 /* RepliesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepliesListView.swift; sourceTree = ""; }; 717041F426B7037300001360 /* TweetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TweetView.swift; sourceTree = ""; }; @@ -79,7 +83,6 @@ 71800BEF26905DB3009D11A1 /* EngagementCountersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementCountersView.swift; sourceTree = ""; }; 71800BF126906721009D11A1 /* ActionBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBarView.swift; sourceTree = ""; }; 71800BF326906BC0009D11A1 /* DraftsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsListView.swift; sourceTree = ""; }; - 71800BF7269998BF009D11A1 /* UIImage.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.extension.swift; sourceTree = ""; }; 71800BFC26999B1B009D11A1 /* DraftsModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DraftsModel.xcdatamodel; sourceTree = ""; }; 71800BFE26999BA6009D11A1 /* PersistanceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistanceController.swift; sourceTree = ""; }; 7188E6252687B0FE007CFD78 /* Broadcast.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Broadcast.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -88,31 +91,30 @@ 7188E62C2687B0FF007CFD78 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 7188E62F2687B0FF007CFD78 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 7188E6312687B0FF007CFD78 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 7188E63A2687B19D007CFD78 /* Notification.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.extension.swift; sourceTree = ""; }; - 7188E6462687B6C2007CFD78 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastButtonStyle.swift; sourceTree = ""; }; 7188E64A2687C44D007CFD78 /* String.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.extension.swift; sourceTree = ""; }; 7188E64C2687C6A1007CFD78 /* Binding.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Binding.extension.swift; sourceTree = ""; }; 7188E64E2687CCD0007CFD78 /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = ""; }; 7188E6522687D16C007CFD78 /* AttachmentThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentThumbnail.swift; sourceTree = ""; }; - 7188E65E26889147007CFD78 /* SignOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignOutView.swift; sourceTree = ""; }; 7188E6602688A01F007CFD78 /* Font.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Font.extension.swift; sourceTree = ""; }; 7188E6622688A0FC007CFD78 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 7188E6642688A436007CFD78 /* ThemeHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeHelper.swift; sourceTree = ""; }; - 7188E6662688B99E007CFD78 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.extension.swift; sourceTree = ""; }; 719087CE26891C7F005B96CE /* Array.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.extension.swift; sourceTree = ""; }; + 7197795A27B408540079AD69 /* AttachmentDropDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDropDelegate.swift; sourceTree = ""; }; 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+Convenience.swift"; sourceTree = ""; }; 7199AE7B26B97327001DEB46 /* UserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserView.swift; sourceTree = ""; }; 7199AE7D26B973B2001DEB46 /* MentionBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionBar.swift; sourceTree = ""; }; 7199AE7F26B9892E001DEB46 /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = ""; }; + 719D19C027AEDD4E003120F0 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; 71A10889268B073B007E1FFB /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 71A6A263278C73AD00BF2387 /* TwitterAPI-Info.example.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.example.plist"; sourceTree = ""; }; - 71AA4AC5268A032400B7B577 /* RemoteImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImage.swift; sourceTree = ""; }; - 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterClient.swift; sourceTree = ""; }; + 71A9157827A59C9000706024 /* LocalMediaPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMediaPreview.swift; sourceTree = ""; }; + 71B8290B268D0AC6002AEE72 /* TwitterClientManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitterClientManager.swift; sourceTree = ""; }; 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastTweetReplyView.swift; sourceTree = ""; }; 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerView.swift; sourceTree = ""; }; 71BBAAEA268CF532004048A0 /* TwitterAPI-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "TwitterAPI-Info.plist"; sourceTree = ""; }; + 71D283F027A469EF00640B2A /* AttributeScopes+TwitterEntities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributeScopes+TwitterEntities.swift"; sourceTree = ""; }; 71E36FFA2689EED40078D956 /* ShakeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShakeModifier.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -131,7 +133,7 @@ 7188E65D26887DCD007CFD78 /* SwiftKeychainWrapper in Frameworks */, 7188E66A2688D7BA007CFD78 /* Introspect in Frameworks */, 719087CD26891586005B96CE /* TwitterText in Frameworks */, - 715AAE0A26C923A1002BCEA1 /* Swifter in Frameworks */, + 714782D5278D97AB00942618 /* Twift in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -171,7 +173,6 @@ 7188E6312687B0FF007CFD78 /* Info.plist */, 7188E6282687B0FE007CFD78 /* BroadcastApp.swift */, 7188E62A2687B0FE007CFD78 /* ContentView.swift */, - 7188E65E26889147007CFD78 /* SignOutView.swift */, 7188E6622688A0FC007CFD78 /* WelcomeView.swift */, 7188E62C2687B0FF007CFD78 /* Assets.xcassets */, 7188E6392687B192007CFD78 /* Extensions */, @@ -199,12 +200,12 @@ 711F3FF9268F50C800605C89 /* Animation.extension.swift */, 719087CE26891C7F005B96CE /* Array.extension.swift */, 7188E6602688A01F007CFD78 /* Font.extension.swift */, - 7188E63A2687B19D007CFD78 /* Notification.extension.swift */, 7188E64A2687C44D007CFD78 /* String.extension.swift */, 7188E66B2688DB44007CFD78 /* UIApplication.extension.swift */, - 71800BF7269998BF009D11A1 /* UIImage.extension.swift */, 717041F626B703A600001360 /* TwitterClient+MockTweet.swift */, 7199AE7926B96D0D001DEB46 /* NSRegularExpression+Convenience.swift */, + 710F03BE27B6700D00AE6C5B /* TwitterClientManager+CompressMedia.swift */, + 710F03C027B970FB00AE6C5B /* EnvironmentKeys+CornerRadius.swift */, ); path = Extensions; sourceTree = ""; @@ -212,14 +213,16 @@ 7188E6402687B431007CFD78 /* Helpers */ = { isa = PBXGroup; children = ( + 71D283F027A469EF00640B2A /* AttributeScopes+TwitterEntities.swift */, 7188E64C2687C6A1007CFD78 /* Binding.extension.swift */, + 7199AE7F26B9892E001DEB46 /* Debouncer.swift */, 71A10889268B073B007E1FFB /* Haptics.swift */, - 71E36FFA2689EED40078D956 /* ShakeModifier.swift */, - 7188E6642688A436007CFD78 /* ThemeHelper.swift */, - 71B8290B268D0AC6002AEE72 /* TwitterClient.swift */, 71800BFE26999BA6009D11A1 /* PersistanceController.swift */, - 7199AE7F26B9892E001DEB46 /* Debouncer.swift */, + 71E36FFA2689EED40078D956 /* ShakeModifier.swift */, 715AAE0B26C9589A002BCEA1 /* TestUtils.swift */, + 7188E6642688A436007CFD78 /* ThemeHelper.swift */, + 71B8290B268D0AC6002AEE72 /* TwitterClientManager.swift */, + 7197795A27B408540079AD69 /* AttachmentDropDelegate.swift */, ); path = Helpers; sourceTree = ""; @@ -228,21 +231,21 @@ isa = PBXGroup; children = ( 71800BF126906721009D11A1 /* ActionBarView.swift */, + 71A9157827A59C9000706024 /* LocalMediaPreview.swift */, 7188E6522687D16C007CFD78 /* AttachmentThumbnail.swift */, 7188E6482687C0E0007CFD78 /* BroadcastButtonStyle.swift */, 71BBAAE8268CF1BD004048A0 /* ComposerView.swift */, 71800BF326906BC0009D11A1 /* DraftsListView.swift */, 71800BEF26905DB3009D11A1 /* EngagementCountersView.swift */, 71B8290F268F2195002AEE72 /* LastTweetReplyView.swift */, + 7199AE7D26B973B2001DEB46 /* MentionBar.swift */, + 7101073526C810AC00A713A5 /* NullStateView.swift */, 7188E64E2687CCD0007CFD78 /* PhotoPicker.swift */, - 71AA4AC5268A032400B7B577 /* RemoteImage.swift */, - 7188E6462687B6C2007CFD78 /* SafariView.swift */, - 7188E6662688B99E007CFD78 /* VisualEffectView.swift */, 717041F226B6FFEA00001360 /* RepliesListView.swift */, 717041F426B7037300001360 /* TweetView.swift */, 7199AE7B26B97327001DEB46 /* UserView.swift */, - 7199AE7D26B973B2001DEB46 /* MentionBar.swift */, - 7101073526C810AC00A713A5 /* NullStateView.swift */, + 713E16E927A6EAA900314B44 /* UserAvatar.swift */, + 719D19C027AEDD4E003120F0 /* VisualEffectView.swift */, ); path = "Helper Views"; sourceTree = ""; @@ -286,7 +289,7 @@ 7188E65C26887DCD007CFD78 /* SwiftKeychainWrapper */, 7188E6692688D7BA007CFD78 /* Introspect */, 719087CC26891586005B96CE /* TwitterText */, - 715AAE0926C923A1002BCEA1 /* Swifter */, + 714782D4278D97AB00942618 /* Twift */, ); productName = Broadcast; productReference = 7188E6252687B0FE007CFD78 /* Broadcast.app */; @@ -323,7 +326,7 @@ 7188E65B26887DCD007CFD78 /* XCRemoteSwiftPackageReference "SwiftKeychainWrapper" */, 7188E6682688D7BA007CFD78 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 719087CB26891586005B96CE /* XCRemoteSwiftPackageReference "twitter-text" */, - 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */, + 714782D3278D97AB00942618 /* XCRemoteSwiftPackageReference "Twift" */, ); productRefGroup = 7188E6262687B0FE007CFD78 /* Products */; projectDirPath = ""; @@ -392,18 +395,18 @@ 7188E6612688A01F007CFD78 /* Font.extension.swift in Sources */, 7188E62B2687B0FE007CFD78 /* ContentView.swift in Sources */, 717041F526B7037300001360 /* TweetView.swift in Sources */, - 7188E63B2687B19D007CFD78 /* Notification.extension.swift in Sources */, + 710F03C127B970FB00AE6C5B /* EnvironmentKeys+CornerRadius.swift in Sources */, 717041F326B6FFEA00001360 /* RepliesListView.swift in Sources */, 7188E6292687B0FE007CFD78 /* BroadcastApp.swift in Sources */, - 71B8290C268D0AC6002AEE72 /* TwitterClient.swift in Sources */, + 71B8290C268D0AC6002AEE72 /* TwitterClientManager.swift in Sources */, 7188E6632688A0FC007CFD78 /* WelcomeView.swift in Sources */, 7199AE7A26B96D0D001DEB46 /* NSRegularExpression+Convenience.swift in Sources */, 717041F726B703A600001360 /* TwitterClient+MockTweet.swift in Sources */, - 7188E6472687B6C2007CFD78 /* SafariView.swift in Sources */, 7188E6492687C0E0007CFD78 /* BroadcastButtonStyle.swift in Sources */, 71800BF026905DB3009D11A1 /* EngagementCountersView.swift in Sources */, 7199AE7C26B97327001DEB46 /* UserView.swift in Sources */, 7188E6652688A436007CFD78 /* ThemeHelper.swift in Sources */, + 7197795B27B408540079AD69 /* AttachmentDropDelegate.swift in Sources */, 7199AE7E26B973B2001DEB46 /* MentionBar.swift in Sources */, 71800BF226906721009D11A1 /* ActionBarView.swift in Sources */, 7188E64F2687CCD0007CFD78 /* PhotoPicker.swift in Sources */, @@ -415,18 +418,19 @@ 71800BFF26999BA6009D11A1 /* PersistanceController.swift in Sources */, 71B82910268F2195002AEE72 /* LastTweetReplyView.swift in Sources */, 71800BF426906BC0009D11A1 /* DraftsListView.swift in Sources */, + 71A9157927A59C9000706024 /* LocalMediaPreview.swift in Sources */, 71E36FFB2689EED40078D956 /* ShakeModifier.swift in Sources */, - 71800BF8269998BF009D11A1 /* UIImage.extension.swift in Sources */, + 71D283F127A469EF00640B2A /* AttributeScopes+TwitterEntities.swift in Sources */, 719087CF26891C7F005B96CE /* Array.extension.swift in Sources */, 7188E64D2687C6A1007CFD78 /* Binding.extension.swift in Sources */, + 719D19C127AEDD4E003120F0 /* VisualEffectView.swift in Sources */, 7199AE8026B9892E001DEB46 /* Debouncer.swift in Sources */, 711F3FFA268F50C800605C89 /* Animation.extension.swift in Sources */, - 71AA4AC6268A032400B7B577 /* RemoteImage.swift in Sources */, - 7188E6672688B99E007CFD78 /* VisualEffectView.swift in Sources */, - 7188E65F26889147007CFD78 /* SignOutView.swift in Sources */, 71A1088A268B073B007E1FFB /* Haptics.swift in Sources */, 71BBAAE9268CF1BD004048A0 /* ComposerView.swift in Sources */, + 713E16EA27A6EAA900314B44 /* UserAvatar.swift in Sources */, 715AAE0C26C9589A002BCEA1 /* TestUtils.swift in Sources */, + 710F03BF27B6700D00AE6C5B /* TwitterClientManager+CompressMedia.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -530,7 +534,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -585,7 +589,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.5; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -601,17 +605,17 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 38; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Broadcast/Preview Content\""; DEVELOPMENT_TEAM = YC249PY26F; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Broadcast/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = me.daneden.Broadcast; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -625,17 +629,17 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 38; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Broadcast/Preview Content\""; DEVELOPMENT_TEAM = YC249PY26F; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Broadcast/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = me.daneden.Broadcast; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -676,12 +680,12 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */ = { + 714782D3278D97AB00942618 /* XCRemoteSwiftPackageReference "Twift" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/daneden/Swifter"; + repositoryURL = "https://github.com/daneden/Twift.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.5.1; + branch = main; + kind = branch; }; }; 7188E65B26887DCD007CFD78 /* XCRemoteSwiftPackageReference "SwiftKeychainWrapper" */ = { @@ -711,10 +715,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 715AAE0926C923A1002BCEA1 /* Swifter */ = { + 714782D4278D97AB00942618 /* Twift */ = { isa = XCSwiftPackageProductDependency; - package = 715AAE0826C923A1002BCEA1 /* XCRemoteSwiftPackageReference "Swifter" */; - productName = Swifter; + package = 714782D3278D97AB00942618 /* XCRemoteSwiftPackageReference "Twift" */; + productName = Twift; }; 7188E65C26887DCD007CFD78 /* SwiftKeychainWrapper */ = { isa = XCSwiftPackageProductDependency; diff --git a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c92d616..1d4f7c5 100644 --- a/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Broadcast.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { "object": { "pins": [ - { - "package": "Swifter", - "repositoryURL": "https://github.com/daneden/Swifter", - "state": { - "branch": null, - "revision": "21a1cf736971d07dec56cf5cc0294f31a34ec528", - "version": "2.5.1" - } - }, { "package": "SwiftKeychainWrapper", "repositoryURL": "https://github.com/jrendel/SwiftKeychainWrapper", @@ -28,6 +19,15 @@ "version": "0.1.3" } }, + { + "package": "Twift", + "repositoryURL": "https://github.com/daneden/Twift.git", + "state": { + "branch": "main", + "revision": "30a21fca9a1d7399c4fb52cf0d04dfaceaa2d0ca", + "version": null + } + }, { "package": "twitter-text", "repositoryURL": "https://github.com/nysander/twitter-text.git", diff --git a/Broadcast/BroadcastApp.swift b/Broadcast/BroadcastApp.swift index 2be99a3..8e78025 100644 --- a/Broadcast/BroadcastApp.swift +++ b/Broadcast/BroadcastApp.swift @@ -11,7 +11,7 @@ import SwiftUI struct BroadcastApp: App { @Environment(\.scenePhase) var scenePhase @StateObject var themeHelper = ThemeHelper.shared - @StateObject var twitterClient = TwitterClient() + @StateObject var twitterClient = TwitterClientManager() let persistenceController = PersistanceController.shared var body: some Scene { @@ -24,16 +24,12 @@ struct BroadcastApp: App { .environment(\.managedObjectContext, persistenceController.container.viewContext) .accentColor(themeHelper.color) .onChange(of: scenePhase) { newPhase in - if newPhase == .active { - twitterClient.revalidateAccount() - } - persistenceController.save() } VisualEffectView(effect: UIBlurEffect(style: .regular)) .frame(height: geom.safeAreaInsets.top) - .ignoresSafeArea(.all, edges: .top) + .ignoresSafeArea(.container, edges: .top) } } } diff --git a/Broadcast/ContentView.swift b/Broadcast/ContentView.swift index 6560d94..6e227c7 100644 --- a/Broadcast/ContentView.swift +++ b/Broadcast/ContentView.swift @@ -8,16 +8,17 @@ import SwiftUI import Introspect import TwitterText +import Twift struct ContentView: View { - @ScaledMetric private var captionSize: CGFloat = 14 + @Environment(\.cornerRadius) var cornerRadius: Double + @ScaledMetric private var captionSize: CGFloat = 20 @ScaledMetric private var bottomPadding: CGFloat = 80 @ScaledMetric private var replyBoxLimit: CGFloat = 96 - @EnvironmentObject var twitterClient: TwitterClient + @EnvironmentObject var twitterClient: TwitterClientManager @State private var photoPickerIsPresented = false - @State private var signOutScreenIsPresented = false @State private var repliesSheetIsPresented = false @State private var sendingTweet = false @@ -25,7 +26,7 @@ struct ContentView: View { @State private var replyBoxHeight: CGFloat = 0 private var imageHeightCompensation: CGFloat { - (twitterClient.draft.media == nil ? 0 : bottomPadding) + + (twitterClient.selectedMedia.isEmpty ? 0 : bottomPadding) + (replying ? min(replyBoxHeight, replyBoxLimit) : 0) } @@ -33,7 +34,7 @@ struct ContentView: View { GeometryReader { geom in ZStack(alignment: .bottom) { ScrollView { - VStack { + VStack(spacing: captionSize / 2) { if replying, let lastTweet = twitterClient.lastTweet { LastTweetReplyView(lastTweet: lastTweet) .background(GeometryReader { geometry in @@ -54,7 +55,7 @@ struct ContentView: View { .padding() .frame(maxWidth: .infinity) .background(Color(.systemRed).opacity(0.2)) - .cornerRadius(captionSize) + .cornerRadius(cornerRadius) .onTapGesture { withAnimation { twitterClient.state = .idle @@ -62,60 +63,61 @@ struct ContentView: View { } } - if $twitterClient.user.wrappedValue != nil { - ComposerView(signOutScreenIsPresented: $signOutScreenIsPresented) + if twitterClient.user != nil { + ComposerView() .frame( height: geom.size.height - (bottomPadding + (captionSize * 2)) - imageHeightCompensation, alignment: .topLeading ) + .animation(.springAnimation, value: imageHeightCompensation) - AttachmentThumbnail(image: $twitterClient.draft.media) + AttachmentThumbnail(media: $twitterClient.selectedMedia) + .disabled(twitterClient.state.isBusy) } else { WelcomeView() } } - .padding() - .padding(.bottom, bottomPadding) + .padding(.top, captionSize) + .padding(.horizontal) .frame(maxWidth: geom.size.width) } - - VStack { - if twitterClient.user != nil { - ActionBarView(replying: $replying) - } else { - Button(action: { twitterClient.signIn() }) { - Label("Sign In With Twitter", image: "twitter.fill") - .font(.broadcastHeadline) + .safeAreaInset(edge: .bottom, content: { + Group { + if twitterClient.user != nil { + ActionBarView(replying: $replying) + } else { + Button(action: { Task { await twitterClient.signIn() } }) { + Label("Sign In With Twitter", image: "twitter.fill") + .font(.broadcastHeadline) + } + .accessibilityIdentifier("loginButton") } - .buttonStyle(BroadcastButtonStyle()) - .accessibilityIdentifier("loginButton") } - } - .padding() - .animation(.springAnimation) - .background( - VisualEffectView(effect: UIBlurEffect(style: .regular)) - .ignoresSafeArea() - .opacity(twitterClient.user == nil ? 0 : 1) - ) - .gesture(DragGesture().onEnded({ _ in UIApplication.shared.endEditing() })) - } - .sheet(isPresented: $signOutScreenIsPresented) { - SignOutView() + .buttonStyle(BroadcastButtonStyle(isLoading: twitterClient.state != .idle)) + .padding() + .background(VisualEffectView(effect: UIBlurEffect(style: .regular)).ignoresSafeArea()) + .gesture(DragGesture().onEnded({ _ in UIApplication.shared.endEditing() })) + }) } .sheet(isPresented: $repliesSheetIsPresented) { RepliesListView(tweet: twitterClient.lastTweet) .accentColor(ThemeHelper.shared.color) .font(.broadcastBody) + .environmentObject(twitterClient) } .onAppear { UITextView.appearance().backgroundColor = .clear } - .onChange(of: replying) { _ in - twitterClient.revalidateAccount() - } .onPreferenceChange(ReplyBoxSizePreferenceKey.self) { newValue in - withAnimation(.easeInOut(duration: 0.1)) { replyBoxHeight = newValue } + withAnimation(.springAnimation) { replyBoxHeight = newValue + (captionSize / 2) } + } + .overlay { + if twitterClient.state == .initializing { + ZStack { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + }.background(.background) + } } } } diff --git a/Broadcast/DraftsModel.xcdatamodeld/DraftsModel.xcdatamodel/contents b/Broadcast/DraftsModel.xcdatamodeld/DraftsModel.xcdatamodel/contents index 96c750d..62bc906 100644 --- a/Broadcast/DraftsModel.xcdatamodeld/DraftsModel.xcdatamodel/contents +++ b/Broadcast/DraftsModel.xcdatamodeld/DraftsModel.xcdatamodel/contents @@ -1,12 +1,12 @@ - + - + - + \ No newline at end of file diff --git a/Broadcast/Extensions/EnvironmentKeys+CornerRadius.swift b/Broadcast/Extensions/EnvironmentKeys+CornerRadius.swift new file mode 100644 index 0000000..2111fed --- /dev/null +++ b/Broadcast/Extensions/EnvironmentKeys+CornerRadius.swift @@ -0,0 +1,20 @@ +// +// EnvironmentKeys+CornerRadius.swift +// Broadcast +// +// Created by Daniel Eden on 13/02/2022. +// + +import Foundation +import SwiftUI + +struct CornerRadiusKey: EnvironmentKey { + static let defaultValue: Double = 12 +} + +extension EnvironmentValues { + var cornerRadius: Double { + get { self[CornerRadiusKey.self] } + set { self[CornerRadiusKey.self] = newValue } + } +} diff --git a/Broadcast/Extensions/Notification.extension.swift b/Broadcast/Extensions/Notification.extension.swift deleted file mode 100644 index 392d1b9..0000000 --- a/Broadcast/Extensions/Notification.extension.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Notification.extension.swift -// Broadcast -// -// Created by Daniel Eden on 26/06/2021. -// - -import Foundation - -extension Notification.Name { - static let twitterCallback = Notification.Name(rawValue: "Twitter.CallbackNotification.Name") -} diff --git a/Broadcast/Extensions/TwitterClient+MockTweet.swift b/Broadcast/Extensions/TwitterClient+MockTweet.swift index 3736fae..3ef7eaa 100644 --- a/Broadcast/Extensions/TwitterClient+MockTweet.swift +++ b/Broadcast/Extensions/TwitterClient+MockTweet.swift @@ -6,27 +6,32 @@ // import Foundation +import Twift -extension TwitterClient.Tweet { - static var mockTweet: TwitterClient.Tweet { - TwitterClient.Tweet( - numericId: 0, - id: "0", - text: "just setting up my twttr", - likes: 420, - retweets: 69, - date: Date(), - author: .mockUser - ) +extension Tweet { + static var mockTweet: Tweet { + let jsonString = """ +{ + "id": "0", +"text": "just setting up my twttr", +"createdAt": \(Date().timeIntervalSince1970), +"authorId": "0" +} +""" + return try! JSONDecoder().decode(Tweet.self, from: jsonString.data(using: .utf8)!) } } -extension TwitterClient.User { - static var mockUser: TwitterClient.User { - TwitterClient.User( - id: "0", - screenName: "_dte", - originalProfileImageURL: URL(string: "https://pbs.twimg.com/profile_images/1337359860409790469/javRMXyG_x96.jpg")! - ) +extension User { + static var mockUser: User { + let jsonString = """ +{ + "id": "0", +"name": "Daniel Eden", +"username": "_dte", +"profileImageUrl": "https://pbs.twimg.com/profile_images/1337359860409790469/javRMXyG_x96.jpg" +} +""" + return try! JSONDecoder().decode(User.self, from: jsonString.data(using: .utf8)!) } } diff --git a/Broadcast/Extensions/TwitterClientManager+CompressMedia.swift b/Broadcast/Extensions/TwitterClientManager+CompressMedia.swift new file mode 100644 index 0000000..b716236 --- /dev/null +++ b/Broadcast/Extensions/TwitterClientManager+CompressMedia.swift @@ -0,0 +1,31 @@ +// +// TwitterClientManager+CompressMedia.swift +// Broadcast +// +// Created by Daniel Eden on 11/02/2022. +// + +import Foundation +import AVFoundation + +extension TwitterClientManager { + func compressVideo( + inputURL: URL, + outputURL: URL + ) async -> AVAssetExportSession? { + let urlAsset = AVURLAsset(url: inputURL, options: nil) + + guard let exportSession = AVAssetExportSession(asset: urlAsset, presetName: AVAssetExportPresetHighestQuality) else { + return nil + } + + exportSession.fileLengthLimit = 14 * 2^20 // 15mb limit + exportSession.timeRange = CMTimeRange(start: CMTime.zero, duration: urlAsset.duration) + exportSession.outputURL = outputURL + exportSession.outputFileType = AVFileType.mp4 + exportSession.shouldOptimizeForNetworkUse = true + + await exportSession.export() + return exportSession + } +} diff --git a/Broadcast/Extensions/UIImage.extension.swift b/Broadcast/Extensions/UIImage.extension.swift deleted file mode 100644 index ae31e1d..0000000 --- a/Broadcast/Extensions/UIImage.extension.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// UIImage.extension.swift -// Broadcast -// -// Created by Daniel Eden on 10/07/2021. -// - -import Foundation -import UIKit - -extension UIImage { - func fixOrientation(_ img: UIImage) -> UIImage { - if (img.imageOrientation == .up) { - return img - } - - UIGraphicsBeginImageContextWithOptions(img.size, false, img.scale) - let rect = CGRect(x: 0, y: 0, width: img.size.width, height: img.size.height) - img.draw(in: rect) - - let normalizedImage = UIGraphicsGetImageFromCurrentImageContext()! - UIGraphicsEndImageContext() - - return normalizedImage - } - - var fixedOrientation: UIImage { - return fixOrientation(self) - } -} diff --git a/Broadcast/Helper Views/ActionBarView.swift b/Broadcast/Helper Views/ActionBarView.swift index e3da473..8b7f07b 100644 --- a/Broadcast/Helper Views/ActionBarView.swift +++ b/Broadcast/Helper Views/ActionBarView.swift @@ -6,28 +6,66 @@ // import SwiftUI +import PhotosUI struct ActionBarView: View { @ScaledMetric var barHeight: CGFloat = 80 - @EnvironmentObject var twitterClient: TwitterClient + @EnvironmentObject var twitterClient: TwitterClientManager @Binding var replying: Bool @State private var photoPickerIsPresented = false + var loadingLabel: String? { + switch twitterClient.state { + case .busy(let label): return label + default: return nil + } + } + + private var pickerConfig: PHPickerConfiguration { + var config = PHPickerConfiguration(photoLibrary: .shared()) + + config.preferredAssetRepresentationMode = .compatible + config.selection = .ordered + + if moreMediaAllowed && !twitterClient.selectedMedia.isEmpty { + config.filter = .images + config.selectionLimit = 4 + config.preselectedAssetIdentifiers = twitterClient.selectedMedia.map(\.key) + } else { + config.filter = .any(of: [.images, .videos]) + } + + return config + } + + private var moreMediaAllowed: Bool { + if twitterClient.selectedMedia.contains(where: { $0.value.mediaType!.conforms(to: .movie) || $0.value.mediaType!.conforms(to: .video) }) { return false } + if twitterClient.selectedMedia.count == 4 { return false } + return true + } + var body: some View { publishingActions - .disabled(twitterClient.state == .busy) + .disabled(twitterClient.state.isBusy) .sheet(isPresented: $photoPickerIsPresented) { - ImagePicker(chosenImage: $twitterClient.draft.media) + ImagePicker(configuration: pickerConfig, selection: $twitterClient.selectedMedia) + .ignoresSafeArea() + } + .onLongPressGesture { + ThemeHelper.shared.rotateTheme() + Haptics.shared.sendStandardFeedback(feedbackType: .success) } } var publishingActions: some View { HStack { - if let replyId = twitterClient.lastTweet?.id { + if twitterClient.lastTweet != nil { Button(action: { if replying { - twitterClient.sendReply(to: replyId) + Task { + await twitterClient.sendTweet(asReply: true) + } } else { withAnimation(.springAnimation) { replying = true } } @@ -45,15 +83,18 @@ struct ActionBarView: View { BroadcastButtonStyle( prominence: replying ? .primary : .secondary, isFullWidth: replying, - isLoading: twitterClient.state == .busy && replying + isLoading: twitterClient.state.isBusy && replying, + loadingLabel: loadingLabel ) ) - .disabled(replying && !twitterClient.draft.isValid) + .disabled(replying && !twitterClient.draftIsValid()) } Button(action: { if !replying { - twitterClient.sendTweet() + Task { + await twitterClient.sendTweet() + } } else { withAnimation(.springAnimation) { replying = false } } @@ -67,15 +108,16 @@ struct ActionBarView: View { ) ) } + .accessibilityIdentifier("sendTweetButton") .buttonStyle( BroadcastButtonStyle( prominence: !replying ? .primary : .secondary, isFullWidth: !replying, - isLoading: twitterClient.state == .busy && !replying + isLoading: twitterClient.state.isBusy && !replying, + loadingLabel: loadingLabel ) ) - .disabled(!replying && !twitterClient.draft.isValid) - .accessibilityIdentifier("sendTweetButton") + .disabled(!replying && !twitterClient.draftIsValid()) Button(action: { photoPickerIsPresented.toggle() @@ -86,6 +128,7 @@ struct ActionBarView: View { } .buttonStyle(BroadcastButtonStyle(prominence: .tertiary, isFullWidth: false)) .accessibilityIdentifier("imagePickerButton") + .disabled(!moreMediaAllowed) } } } diff --git a/Broadcast/Helper Views/AttachmentThumbnail.swift b/Broadcast/Helper Views/AttachmentThumbnail.swift index b529ae0..f0d6fd1 100644 --- a/Broadcast/Helper Views/AttachmentThumbnail.swift +++ b/Broadcast/Helper Views/AttachmentThumbnail.swift @@ -6,42 +6,126 @@ // import SwiftUI +import PhotosUI + +extension String: Identifiable { + public var id: String { self } +} struct AttachmentThumbnail: View { - @Binding var image: UIImage? + @Environment(\.cornerRadius) var cornerRadius + @EnvironmentObject var twitterClient: TwitterClientManager + @Binding var media: [String: NSItemProvider] + @State private var altTextSheetIsPresented = false + @State private var selectedMediaId: String? var body: some View { - Group { - if let image = image { - ZStack(alignment: .topTrailing) { - Image(uiImage: image) - .resizable() - .aspectRatio(image.size, contentMode: .fill) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - Button(action: removeImage) { - Label("Remove Image", systemImage: "xmark.circle") - .labelStyle(IconOnlyLabelStyle()) - .font(.broadcastTitle.bold()) - .foregroundColor(.white) - .shadow(color: .black, radius: 8, x: 0, y: 4) + VStack { + if let media = media, !media.isEmpty { + ForEach(Array(media.keys), id: \.self) { key in + ZStack(alignment: .top) { + LocalMediaPreview(assetId: key, asset: media[key]!) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + + HStack { + Button(action: { removeImage(key) }) { + Label("Remove Image", systemImage: "xmark") + .labelStyle(.iconOnly) + } + .buttonStyle(BroadcastButtonStyle(paddingSize: 8, prominence: .tertiary, isFullWidth: false)) + .clipShape(Circle()) + .offset(x: 8, y: 8) + + Spacer() + + if let item = media[key], + item.allowsAltText { + Button(action: { selectedMediaId = key }) { + Label("Edit Alt Text", systemImage: "captions.bubble") + .labelStyle(.iconOnly) + } + .buttonStyle(BroadcastButtonStyle(paddingSize: 8, prominence: itemHasAltText(key) ? .primary : .tertiary, isFullWidth: false)) + .clipShape(Circle()) + .offset(x: -8, y: 8) + .sheet(item: $selectedMediaId) { id in + AltTextSheet(assetId: id, asset: item) + } + } + } } - .buttonStyle(BroadcastButtonStyle(paddingSize: -2, prominence: .tertiary, isFullWidth: false)) - .clipShape(Circle()) - .offset(x: -8, y: 8) - } + }.transition(.scale) } - }.transition(.opacity) + } + } + + func itemHasAltText(_ id: String) -> Bool { + return !(twitterClient.mediaAltText[id]?.isEmpty ?? true) + } + func removeImage(_ id: String) { + withAnimation { + _ = media.removeValue(forKey: id) + } } +} - func removeImage() { - withAnimation { image = nil } +fileprivate struct AltTextSheet: View { + @Environment(\.cornerRadius) var cornerRadius: Double + @Environment(\.presentationMode) var presentationMode + @EnvironmentObject var twitterClient: TwitterClientManager + + var assetId: String + var asset: NSItemProvider + + @State var altText = "" + + var body: some View { + NavigationView { + Form { + HStack { + Spacer() + LocalMediaPreview(assetId: assetId, asset: asset) + .cornerRadius(cornerRadius / 2) + .frame(minWidth: 0, maxWidth: 200, minHeight: 0, maxHeight: 200) + Spacer() + } + .padding() + .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + + Section(footer: Text("You can add a description, sometimes called alt-text, to your photos so they’re accessible to even more people, including people who are blind or have low vision. Good descriptions are concise, but present what’s in your photos accurately enough to understand their context.")) { + TextField("Enter Alt Text", text: $altText) + .onSubmit { + self.presentationMode.wrappedValue.dismiss() + } + } + } + .navigationTitle("Edit Alt Text") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + Button("Close") { + presentationMode.wrappedValue.dismiss() + } + } + .onDisappear { + withAnimation(.springAnimation) { + twitterClient.mediaAltText[assetId] = altText + } + } + .onAppear { + if let currentValue = twitterClient.mediaAltText[assetId] { + DispatchQueue.main.async { + altText = currentValue + } + } + } + } } } + struct ThumbnailFilmstrip_Previews: PreviewProvider { static var previews: some View { - AttachmentThumbnail(image: .constant(nil)) + AttachmentThumbnail(media: .constant([:])) } } diff --git a/Broadcast/Helper Views/BroadcastButtonStyle.swift b/Broadcast/Helper Views/BroadcastButtonStyle.swift index 79617e5..59ca665 100644 --- a/Broadcast/Helper Views/BroadcastButtonStyle.swift +++ b/Broadcast/Helper Views/BroadcastButtonStyle.swift @@ -28,6 +28,8 @@ struct BroadcastLabelStyle: LabelStyle { } struct BroadcastButtonStyle: ButtonStyle { + @Environment(\.isEnabled) var isEnabled + enum Prominence { case primary, secondary, tertiary, destructive } @@ -36,6 +38,7 @@ struct BroadcastButtonStyle: ButtonStyle { var prominence: Prominence = .primary var isFullWidth = true var isLoading = false + var loadingLabel: String? var background: some View { Group { @@ -43,11 +46,11 @@ struct BroadcastButtonStyle: ButtonStyle { case .primary: Color.accentColor case .secondary: - Color.accentColor.opacity(0.1) + Color.accentColor.opacity(0.1).background(.ultraThinMaterial) case .tertiary: - VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) + Color.clear.background(.ultraThinMaterial) case .destructive: - Color(.systemRed) + Color.red } } } @@ -57,7 +60,7 @@ struct BroadcastButtonStyle: ButtonStyle { case .secondary: return .accentColor case .tertiary: - return .primary + return isEnabled ? .primary : .secondary default: return .white } @@ -76,15 +79,23 @@ struct BroadcastButtonStyle: ButtonStyle { } .padding(paddingSize) .background(background.padding(-paddingSize)) - .foregroundColor(foregroundColor) + .background(.regularMaterial) + .foregroundStyle(foregroundColor) .overlay( Group { if isLoading { - ProgressView() + HStack(spacing: 8) { + ProgressView().tint(foregroundColor) + if let loadingLabel = loadingLabel { + Text(loadingLabel) + .foregroundColor(foregroundColor) + .font(.broadcastBody.bold()) + } + } } } ) - .clipShape(Capsule()) + .clipShape(RoundedRectangle(cornerRadius: 100, style: .continuous)) .scaleEffect(configuration.isPressed ? 0.95 : 1) .animation(.interactiveSpring(), value: configuration.isPressed) .onChange(of: configuration.isPressed) { isPressed in diff --git a/Broadcast/Helper Views/ComposerView.swift b/Broadcast/Helper Views/ComposerView.swift index cb05aa8..d81bb39 100644 --- a/Broadcast/Helper Views/ComposerView.swift +++ b/Broadcast/Helper Views/ComposerView.swift @@ -7,6 +7,7 @@ import SwiftUI import TwitterText +import Twift fileprivate let placeholderCandidates: [String] = [ "Wh—what’s going on?", @@ -19,10 +20,10 @@ fileprivate let placeholderCandidates: [String] = [ ] struct ComposerView: View { + @Environment(\.cornerRadius) var cornerRadius: Double let debouncer = Debouncer(timeInterval: 0.3) - @Binding var signOutScreenIsPresented: Bool - @EnvironmentObject var twitterClient: TwitterClient + @EnvironmentObject var twitterClient: TwitterClientManager @ScaledMetric private var minComposerHeight: CGFloat = 120 @ScaledMetric private var captionSize: CGFloat = 14 @ScaledMetric private var leftOffset: CGFloat = 4 @@ -30,6 +31,7 @@ struct ComposerView: View { @State private var placeholder: String = placeholderCandidates.randomElement() @State private var draftListVisible = false + @State private var dropActive = false private let mentioningRegex = NSRegularExpression("@[a-z0-9_]+$", options: .caseInsensitive) @@ -45,7 +47,7 @@ struct ComposerView: View { TwitterText.tweetLength(text: tweetText) } - private var mentionCandidates: [TwitterClient.User]? { + private var mentionCandidates: [User]? { twitterClient.userSearchResults } @@ -78,17 +80,15 @@ struct ComposerView: View { ZStack(alignment: .bottom) { VStack(alignment: .trailing) { HStack(alignment: .top) { - if let profileImageURL = twitterClient.user?.profileImageURL { - RemoteImage(url: profileImageURL, placeholder: { ProgressView() }) - .aspectRatio(contentMode: .fill) - .frame(width: 36, height: 36) - .cornerRadius(36) - .onTapGesture { - signOutScreenIsPresented = true - UIApplication.shared.endEditing() - } - .accessibilityIdentifier("profilePhotoButton") - } + Menu { + Section { + Button(role: .destructive, action: {twitterClient.signOut()}) { + Label("Sign Out", systemImage: "person.badge.minus") + }.accessibilityIdentifier("logoutButton") + } + } label: { + UserAvatar(avatarUrl: twitterClient.user?.profileImageUrlLarger) + }.accessibilityIdentifier("profilePhotoButton") ZStack(alignment: .topLeading) { Text(tweetText.isEmpty ? placeholder : tweetText) @@ -104,17 +104,19 @@ struct ComposerView: View { .keyboardType(.twitter) .padding(.top, (verticalPadding / 3) * -1) .accessibilityIdentifier("tweetComposer") + .disabled(dropActive) } .font(.broadcastTitle3) - }.transition(.scale) + } Divider() + .padding(.bottom, verticalPadding) - HStack(alignment: .top) { + HStack(alignment: .firstTextBaseline) { Menu { Button(action: { twitterClient.saveDraft() }) { Label("Save Draft", systemImage: "square.and.pencil") - }.disabled(!twitterClient.draft.isValid) + }.disabled(!twitterClient.draftIsValid()) Button(action: { draftListVisible = true }) { Label("View Drafts", systemImage: "doc.on.doc") @@ -132,14 +134,14 @@ struct ComposerView: View { .multilineTextAlignment(.trailing) } } - .disabled(twitterClient.state == .busy) + .disabled(twitterClient.state.isBusy) .padding() - .background(Color(.tertiarySystemGroupedBackground)) + .background(.thinMaterial) .onShake { rotatePlaceholder() Haptics.shared.sendStandardFeedback(feedbackType: .success) } - .onChange(of: twitterClient.draft.isValid) { isValid in + .onChange(of: twitterClient.draftIsValid()) { isValid in if !isValid && charCount > 280 { Haptics.shared.sendStandardFeedback(feedbackType: .warning) } @@ -153,10 +155,36 @@ struct ComposerView: View { if let screenName = value { debouncer.renewInterval() debouncer.handler = { - self.twitterClient.searchScreenNames(screenName) + Task { + await self.twitterClient.searchScreenNames(screenName) + } + } + } + } + .overlay { + if dropActive { + ZStack { + Color.clear + + VStack { + Image(systemName: "photo.on.rectangle.angled") + Text("Add media attachment") + } + .foregroundStyle(.primary) + .padding() } + .background(.ultraThinMaterial) + .background(.tertiary) + .foregroundStyle(.tint) } } + .onDrop( + of: [.image], + delegate: AttachmentDropDelegate( + dropActive: $dropActive, + twitterClient: twitterClient + ) + ) if let users = mentionCandidates, !users.isEmpty, @@ -166,12 +194,12 @@ struct ComposerView: View { completeMention(user) } } - }.cornerRadius(captionSize) + }.cornerRadius(cornerRadius) } - func completeMention(_ user: TwitterClient.User) { + func completeMention(_ user: User) { let textToComplete = mentioningRegex.firstMatchAsString(tweetText) ?? "" - let draft = twitterClient.draft.text?.replacingOccurrences(of: textToComplete, with: "@\(user.screenName) ") + let draft = twitterClient.draft.text?.replacingOccurrences(of: textToComplete, with: "@\(user.username) ") twitterClient.draft.text = draft } @@ -188,6 +216,6 @@ struct ComposerView: View { struct ComposerView_Previews: PreviewProvider { static var previews: some View { - ComposerView(signOutScreenIsPresented: .constant(false)) + ComposerView() } } diff --git a/Broadcast/Helper Views/DraftsListView.swift b/Broadcast/Helper Views/DraftsListView.swift index bd75f7d..5f9c420 100644 --- a/Broadcast/Helper Views/DraftsListView.swift +++ b/Broadcast/Helper Views/DraftsListView.swift @@ -15,7 +15,7 @@ struct DraftsListView: View { @FetchRequest(entity: Draft.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \Draft.date, ascending: true)]) var drafts: FetchedResults - @EnvironmentObject var twitterClient: TwitterClient + @EnvironmentObject var twitterClient: TwitterClientManager @EnvironmentObject var themeHelper: ThemeHelper var body: some View { @@ -40,17 +40,6 @@ struct DraftsListView: View { Text("Empty Draft").foregroundColor(.secondary) } } - - Spacer() - - if let imageData = draft.media, - let image = UIImage(data: imageData) { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: thumbnailSize, height: thumbnailSize) - .cornerRadius(8) - } } .contentShape(Rectangle()) .onTapGesture { diff --git a/Broadcast/Helper Views/EngagementCountersView.swift b/Broadcast/Helper Views/EngagementCountersView.swift index 26594a2..7da8a7f 100644 --- a/Broadcast/Helper Views/EngagementCountersView.swift +++ b/Broadcast/Helper Views/EngagementCountersView.swift @@ -6,17 +6,18 @@ // import SwiftUI +import Twift struct EngagementCountersView: View { - var tweet: TwitterClient.Tweet + var tweet: Tweet var repliesString: String { - let replyCount = tweet.replies?.count ?? 0 + let replyCount = tweet.publicMetrics?.replyCount ?? 0 switch replyCount { case 0: - return "No replies" + return "No Replies" case 1: - return "1 Reply" + return "\(replyCount) Reply" default: return "\(replyCount) Replies" } @@ -28,9 +29,3 @@ struct EngagementCountersView: View { .foregroundColor(.accentColor) } } - -struct EngagementCountersView_Previews: PreviewProvider { - static var previews: some View { - EngagementCountersView(tweet: TwitterClient.Tweet(likes: 420, retweets: 69, replies: [])) - } -} diff --git a/Broadcast/Helper Views/LastTweetReplyView.swift b/Broadcast/Helper Views/LastTweetReplyView.swift index 818ccfa..a96b2e4 100644 --- a/Broadcast/Helper Views/LastTweetReplyView.swift +++ b/Broadcast/Helper Views/LastTweetReplyView.swift @@ -6,10 +6,12 @@ // import SwiftUI +import Twift struct LastTweetReplyView: View { + @Environment(\.cornerRadius) var cornerRadius: Double @ScaledMetric var spacing: CGFloat = 4 - var lastTweet: TwitterClient.Tweet + var lastTweet: Tweet var body: some View { VStack(alignment: .leading, spacing: spacing) { @@ -37,12 +39,12 @@ struct LastTweetReplyView: View { } .padding(spacing * 2) .background(Color.accentColor.opacity(0.1)) - .cornerRadius(spacing * 2) + .cornerRadius(cornerRadius) } } -struct LastTweetReplyView_Previews: PreviewProvider { - static var previews: some View { - LastTweetReplyView(lastTweet: TwitterClient.Tweet(text: "Example tweet")) - } -} +//struct LastTweetReplyView_Previews: PreviewProvider { +// static var previews: some View { +// LastTweetReplyView(lastTweet: TwitterClient.Tweet(text: "Example tweet")) +// } +//} diff --git a/Broadcast/Helper Views/LocalMediaPreview.swift b/Broadcast/Helper Views/LocalMediaPreview.swift new file mode 100644 index 0000000..adbb092 --- /dev/null +++ b/Broadcast/Helper Views/LocalMediaPreview.swift @@ -0,0 +1,127 @@ +// +// AsyncLocalMediaPreview.swift +// Broadcast +// +// Created by Daniel Eden on 29/01/2022. +// + +import SwiftUI +import PhotosUI +import AVKit +import QuickLook + +struct LocalMediaPreview: View { + private enum PreviewLoadingState { + case loadedImage(_ image: UIImage) + case loadedVideo(_ video: AVPlayer) + case failed + case loading + case loadingWithProgress(_ progress: Progress) + + var finished: Bool { + switch self { + case .loadedImage(_), .loadedVideo(_), .failed: + return true + default: + return false + } + } + } + var assetId: String + var asset: NSItemProvider + + @State private var state: PreviewLoadingState = .loading + @State var loadingProgress: Progress? + @State private var showLoadingErrorAlert = false + + var body: some View { + Group { + switch state { + case .loadedImage(let image): + Image(uiImage: image) + .resizable() + .scaledToFit() + case .loadedVideo(let player): + VideoPlayer(player: player) + .scaledToFit() + case .failed: + Label("Cannot Load Preview", systemImage: "eye.slash") + .foregroundStyle(.secondary) + .padding() + case .loading: + ProgressView("Loading Preview") + .padding() + case .loadingWithProgress(let progress): + ProgressView(value: progress.fractionCompleted) + .padding() + + } + } + .frame(maxWidth: .infinity, minHeight: 80) + .background(.thinMaterial) + .task { await loadPreview() } + .onChange(of: loadingProgress) { value in + if let value = value, + !value.isFinished { + withAnimation { self.state = .loadingWithProgress(value) } + } + } + } + + func loadPreview() async { + let itemProvider = asset + + guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.last, + let utType = UTType(typeIdentifier) + else { return self.state = .failed } + + if utType.conforms(to: .image) { + if itemProvider.canLoadObject(ofClass: UIImage.self) { + loadingProgress = itemProvider.loadObject(ofClass: UIImage.self) { image, error in + if let image = image as? UIImage { + self.state = .loadedImage(image) + } else { + self.state = .failed + } + } + } + } else if utType.conforms(to: .movie) { + let url: URL? = await withUnsafeContinuation { continuation in + itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in + if let error = error { + print(error.localizedDescription) + } + + guard let url = url else { return continuation.resume(returning: nil) } + + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + guard let targetURL = documentsDirectory?.appendingPathComponent(url.lastPathComponent) else { + return continuation.resume(returning: nil) + } + + do { + if FileManager.default.fileExists(atPath: targetURL.path) { + try FileManager.default.removeItem(at: targetURL) + } + + try FileManager.default.copyItem(at: url, to: targetURL) + + continuation.resume(returning: targetURL) + } catch { + continuation.resume(returning: nil) + } + } + } + + guard let url = url else { + return state = .failed + } + + let player = AVPlayer(url: url) + + state = .loadedVideo(player) + } else { + state = .failed + } + } +} diff --git a/Broadcast/Helper Views/MentionBar.swift b/Broadcast/Helper Views/MentionBar.swift index 2881e87..4c14e87 100644 --- a/Broadcast/Helper Views/MentionBar.swift +++ b/Broadcast/Helper Views/MentionBar.swift @@ -6,10 +6,12 @@ // import SwiftUI +import Twift struct MentionBar: View { - var users: [TwitterClient.User] - var tapHandler: (TwitterClient.User) -> Void = { _ in } + @Environment(\.cornerRadius) var cornerRadius: Double + var users: [User] + var tapHandler: (User) -> Void = { _ in } var body: some View { ScrollView(.horizontal) { @@ -17,8 +19,8 @@ struct MentionBar: View { ForEach(users, id: \.id) { user in UserView(user: user) .padding(8) - .background(VisualEffectView(effect: UIBlurEffect(style: .systemMaterial))) - .cornerRadius(6) + .background(.regularMaterial) + .cornerRadius(cornerRadius / 2) .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 6) .onTapGesture { tapHandler(user) @@ -26,12 +28,6 @@ struct MentionBar: View { } }.padding(12) } - .background(VisualEffectView(effect: UIBlurEffect(style: .prominent))) + .background(.thinMaterial) } } - -struct MentionBar_Previews: PreviewProvider { - static var previews: some View { - MentionBar(users: [.mockUser]) - } -} diff --git a/Broadcast/Helper Views/PhotoPicker.swift b/Broadcast/Helper Views/PhotoPicker.swift index 4c3a680..93c5f08 100644 --- a/Broadcast/Helper Views/PhotoPicker.swift +++ b/Broadcast/Helper Views/PhotoPicker.swift @@ -6,20 +6,41 @@ // import Foundation -import UIKit +import PhotosUI import SwiftUI +import Twift + +extension NSItemProvider { + var mediaType: UTType? { + for typeIdentifier in registeredTypeIdentifiers { + if let type = UTType(typeIdentifier), + type.preferredMIMEType != nil { + return type + } + } + + return nil + } + + var allowsAltText: Bool { + return (mediaType?.conforms(to: .image) ?? false) + } +} struct ImagePicker: UIViewControllerRepresentable { @Environment(\.presentationMode) var presentationMode - @Binding var chosenImage: UIImage? + var configuration: PHPickerConfiguration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) - func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { - let picker = UIImagePickerController() - picker.delegate = context.coordinator - return picker + @Binding var selection: [String: NSItemProvider] + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> PHPickerViewController { + let controller = PHPickerViewController(configuration: configuration) + controller.delegate = context.coordinator + + return controller } - func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) { + func updateUIViewController(_ uiViewController: PHPickerViewController, context: UIViewControllerRepresentableContext) { } @@ -27,19 +48,22 @@ struct ImagePicker: UIViewControllerRepresentable { Coordinator(self) } - class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + class Coordinator: PHPickerViewControllerDelegate { let parent: ImagePicker init(_ parent: ImagePicker) { self.parent = parent } - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { - if let image = info[.originalImage] as? UIImage { - parent.chosenImage = image + func picker(_: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + for result in results { + // Prevent overriding PHPickerResults for items previously selected + if self.parent.selection[result.assetIdentifier!] == nil { + self.parent.selection[result.assetIdentifier!] = result.itemProvider + } } - parent.presentationMode.wrappedValue.dismiss() + self.parent.presentationMode.wrappedValue.dismiss() } } } diff --git a/Broadcast/Helper Views/RemoteImage.swift b/Broadcast/Helper Views/RemoteImage.swift deleted file mode 100644 index ce56fbc..0000000 --- a/Broadcast/Helper Views/RemoteImage.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// RemoteImage.swift -// Broadcast -// -// Created by Daniel Eden on 28/06/2021. -// - -import SwiftUI -import Combine -import UIKit - -class ImageLoader: ObservableObject { - @Published var image: UIImage? - private let url: URL - - init(url: URL) { - self.url = url - } - - deinit { - cancel() - } - - private var cancellable: AnyCancellable? - - func load() { - cancellable = URLSession.shared.dataTaskPublisher(for: url) - .map { UIImage(data: $0.data) } - .replaceError(with: nil) - .receive(on: DispatchQueue.main) - .sink { [weak self] image in - withAnimation { self?.image = image } - } - } - - func cancel() { - cancellable?.cancel() - } -} - -struct RemoteImage: View { - @StateObject private var loader: ImageLoader - private let placeholder: Placeholder - - init(url: URL, @ViewBuilder placeholder: () -> Placeholder) { - self.placeholder = placeholder() - _loader = StateObject(wrappedValue: ImageLoader(url: url)) - } - - var body: some View { - content - .onAppear(perform: loader.load) - } - - private var content: some View { - Group { - if loader.image != nil { - Image(uiImage: loader.image!) - .resizable() - } else { - placeholder - } - } - } -} diff --git a/Broadcast/Helper Views/RepliesListView.swift b/Broadcast/Helper Views/RepliesListView.swift index 03c9b35..727f438 100644 --- a/Broadcast/Helper Views/RepliesListView.swift +++ b/Broadcast/Helper Views/RepliesListView.swift @@ -6,35 +6,34 @@ // import SwiftUI +import Twift struct RepliesListView: View { + @EnvironmentObject var twitterClient: TwitterClientManager @Environment(\.presentationMode) var presentationMode - var tweet: TwitterClient.Tweet? + var tweet: Tweet? + @State var replies: [(tweet: Tweet, author: User)] = [] var body: some View { NavigationView { Group { - if let tweet = tweet, let replies = tweet.replies, !replies.isEmpty { - List { - ForEach(replies, id: \.id) { reply in - TweetView(tweet: reply) - .onTapGesture { - guard let screenName = reply.author?.screenName, - let tweetId = reply.id else { return } - let url = URL(string: "https://twitter.com/\(screenName)/status/\(tweetId)") - - UIApplication.shared.open(url!) - } - } + if !replies.isEmpty { + List(replies, id: \.tweet.id) { reply in + TweetView(tweet: reply.tweet, author: reply.author) } } else { NullStateView(type: .replies) } - }.navigationTitle("Replies") - .toolbar { - Button("Close") { - presentationMode.wrappedValue.dismiss() + } + .navigationTitle("Replies") + .toolbar { + Button("Close") { + presentationMode.wrappedValue.dismiss() + } } + }.task { + if let tweet = tweet { + replies = await twitterClient.getReplies(for: tweet.id) } } } diff --git a/Broadcast/Helper Views/SafariView.swift b/Broadcast/Helper Views/SafariView.swift deleted file mode 100644 index 0752050..0000000 --- a/Broadcast/Helper Views/SafariView.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// SafariView.swift -// Broadcast -// -// Created by Daniel Eden on 26/06/2021. -// - -import SwiftUI -import SafariServices - -struct SafariView: UIViewControllerRepresentable { - class SafariViewControllerWrapper: UIViewController { - private var safariViewController: SFSafariViewController? - - var url: URL? { - didSet { - if let safariViewController = safariViewController { - safariViewController.willMove(toParent: self) - safariViewController.view.removeFromSuperview() - safariViewController.removeFromParent() - self.safariViewController = nil - } - - guard let url = url else { return } - - let newSafariViewController = SFSafariViewController(url: url) - addChild(newSafariViewController) - newSafariViewController.view.frame = view.frame - view.addSubview(newSafariViewController.view) - newSafariViewController.didMove(toParent: self) - self.safariViewController = newSafariViewController - } - } - - override func viewDidLoad() { - super.viewDidLoad() - self.url = nil - } - } - - typealias UIViewControllerType = SafariViewControllerWrapper - - @Binding var url: URL? - - func makeUIViewController(context: UIViewControllerRepresentableContext) -> SafariViewControllerWrapper { - return SafariViewControllerWrapper() - } - - func updateUIViewController(_ safariViewControllerWrapper: SafariViewControllerWrapper, - context: UIViewControllerRepresentableContext) { - safariViewControllerWrapper.url = url - } -} diff --git a/Broadcast/Helper Views/TweetView.swift b/Broadcast/Helper Views/TweetView.swift index d7058f2..f81a24b 100644 --- a/Broadcast/Helper Views/TweetView.swift +++ b/Broadcast/Helper Views/TweetView.swift @@ -6,11 +6,13 @@ // import SwiftUI +import Twift struct TweetView: View { @ScaledMetric private var avatarSize: CGFloat = 36 @ScaledMetric private var padding: CGFloat = 4 - var tweet: TwitterClient.Tweet + var tweet: Tweet + var author: User var formatter: RelativeDateTimeFormatter { let formatter = RelativeDateTimeFormatter() @@ -23,19 +25,12 @@ struct TweetView: View { var body: some View { HStack(alignment: .top) { - if let imageUrl = tweet.author?.profileImageURL { - RemoteImage(url: imageUrl, placeholder: { ProgressView() }) - .aspectRatio(contentMode: .fill) - .frame(width: avatarSize, height: avatarSize) - .cornerRadius(36) - } + UserAvatar(avatarUrl: author.profileImageUrlLarger) VStack(alignment: .leading, spacing: 4) { HStack { - if let tweetAuthorName = tweet.author?.name, - let screenName = tweet.author?.screenName, - let date = tweet.date { - Text("\(Text(tweetAuthorName).fontWeight(.bold).foregroundColor(.primary)) \(Text("@\(screenName)")) • \(Text(formatter.localizedString(for: date, relativeTo: Date())))") + if let date = tweet.createdAt { + Text("\(Text(author.name).fontWeight(.bold).foregroundColor(.primary)) \(Text("@\(author.username)")) • \(date.formatted(.relative(presentation: .named)))") .foregroundColor(.secondary) } } @@ -50,9 +45,3 @@ struct TweetView: View { .padding(.vertical, padding) } } - -struct TweetView_Previews: PreviewProvider { - static var previews: some View { - TweetView(tweet: TwitterClient.Tweet.mockTweet) - } -} diff --git a/Broadcast/Helper Views/UserAvatar.swift b/Broadcast/Helper Views/UserAvatar.swift new file mode 100644 index 0000000..8652848 --- /dev/null +++ b/Broadcast/Helper Views/UserAvatar.swift @@ -0,0 +1,33 @@ +// +// UserAvatar.swift +// Broadcast +// +// Created by Daniel Eden on 30/01/2022. +// + +import SwiftUI + +struct UserAvatar: View { + var avatarUrl: URL? + @ScaledMetric var size = 36 + var body: some View { + AsyncImage(url: avatarUrl) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .cornerRadius(size) + .overlay(Circle().stroke(Color.primary.opacity(0.1), lineWidth: 1)) + } placeholder: { + ProgressView() + } + .background(.regularMaterial) + .clipShape(Circle()) + .frame(width: size, height: size) + } +} + +struct UserAvatar_Previews: PreviewProvider { + static var previews: some View { + UserAvatar() + } +} diff --git a/Broadcast/Helper Views/UserView.swift b/Broadcast/Helper Views/UserView.swift index 70a6693..a12d816 100644 --- a/Broadcast/Helper Views/UserView.swift +++ b/Broadcast/Helper Views/UserView.swift @@ -6,19 +6,15 @@ // import SwiftUI +import Twift struct UserView: View { @ScaledMetric var avatarSize: CGFloat = 24 - var user: TwitterClient.User + var user: User var body: some View { HStack { - if let imageUrl = user.profileImageURL { - RemoteImage(url: imageUrl, placeholder: { ProgressView() }) - .aspectRatio(contentMode: .fill) - .frame(width: avatarSize, height: avatarSize) - .cornerRadius(36) - } + UserAvatar(avatarUrl: user.profileImageUrlLarger, size: avatarSize) VStack(alignment: .leading) { if let name = user.name { @@ -26,7 +22,7 @@ struct UserView: View { .fontWeight(.bold) } - Text("@\(user.screenName)") + Text("@\(user.username)") .foregroundColor(.secondary) } }.font(.broadcastFootnote) diff --git a/Broadcast/Helpers/AttachmentDropDelegate.swift b/Broadcast/Helpers/AttachmentDropDelegate.swift new file mode 100644 index 0000000..db8413d --- /dev/null +++ b/Broadcast/Helpers/AttachmentDropDelegate.swift @@ -0,0 +1,83 @@ +// +// AttachmentDropDelegate.swift +// Broadcast +// +// Created by Daniel Eden on 09/02/2022. +// + +import Foundation +import SwiftUI + +struct AttachmentDropDelegate: DropDelegate { + @Binding var dropActive: Bool + @ObservedObject var twitterClient: TwitterClientManager + + func dropEntered(info: DropInfo) { + withAnimation(.springAnimation) { + if dropActive == false { + dropActive = true + } + } + } + + func dropExited(info: DropInfo) { + withAnimation(.springAnimation) { + if dropActive == true { + dropActive = false + } + } + } + + func performDrop(info: DropInfo) -> Bool { + withAnimation(.springAnimation) { dropActive = false } + let videoProviders = info.itemProviders(for: [.movie]) + let imageProviders = info.itemProviders(for: [.image]) + + guard videoProviders.count <= 1 else { return false } + guard imageProviders.count <= 4 else { return false } + + guard (!videoProviders.isEmpty && imageProviders.isEmpty) || + (videoProviders.isEmpty && !imageProviders.isEmpty) else { + return false + } + + for provider in info.itemProviders(for: [.image]) { + guard let mediaType = provider.mediaType else { return false } + + if twitterClient.selectedMedia.count < 4 { + let id = UUID().uuidString + provider.loadItem(forTypeIdentifier: mediaType.identifier, options: nil) { result, error in + if let error = error { + print(error) + } else if let result = result { + withAnimation { + DispatchQueue.main.async { + twitterClient.selectedMedia[id] = NSItemProvider(item: result, typeIdentifier: mediaType.identifier) + } + } + } + } + } + } + + for provider in videoProviders { + guard let mediaType = provider.mediaType else { return false } + if twitterClient.selectedMedia.count < 1 { + let id = UUID().uuidString + provider.loadItem(forTypeIdentifier: mediaType.identifier, options: nil) { result, error in + if let error = error { + print(error) + } else if let result = result { + withAnimation { + DispatchQueue.main.async { + twitterClient.selectedMedia[id] = NSItemProvider(item: result, typeIdentifier: mediaType.identifier) + } + } + } + } + } + } + + return true + } +} diff --git a/Broadcast/Helpers/AttributeScopes+TwitterEntities.swift b/Broadcast/Helpers/AttributeScopes+TwitterEntities.swift new file mode 100644 index 0000000..4a69a3c --- /dev/null +++ b/Broadcast/Helpers/AttributeScopes+TwitterEntities.swift @@ -0,0 +1,34 @@ +// +// AttributeScopes+TwitterEntities.swift +// Broadcast +// +// Created by Daniel Eden on 28/01/2022. +// + +import Foundation + +struct Entity: Hashable, Codable { + let value: String +} + +struct EntityAttribute: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey { + typealias Value = Entity + + static var name: String = "entity" + +} + +extension AttributeScopes { + struct BroadcastAttributes: AttributeScope { + let entity: EntityAttribute + let swiftUI: SwiftUIAttributes + } + + var broadcast: BroadcastAttributes.Type { BroadcastAttributes.self } +} + +extension AttributeDynamicLookup { + subscript(dynamicMember keyPath: KeyPath) -> T { + self[T.self] + } +} diff --git a/Broadcast/Helpers/TwitterClient.swift b/Broadcast/Helpers/TwitterClient.swift deleted file mode 100644 index 5688fa2..0000000 --- a/Broadcast/Helpers/TwitterClient.swift +++ /dev/null @@ -1,462 +0,0 @@ -// -// TwitterClient.swift -// Broadcast -// -// Created by Daniel Eden on 30/06/2021. -// - -import Foundation -import Combine -import Swifter -import TwitterText -import UIKit -import AuthenticationServices -import SwiftKeychainWrapper -import SwiftUI - -let typeaheadToken = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" - -class TwitterClient: NSObject, ObservableObject { - let draftsStore = PersistanceController.shared - @Published var user: User? - @Published var draft = Tweet() - @Published var state: State = .idle - @Published var lastTweet: Tweet? - - private var client = Swifter.init(consumerKey: ClientCredentials.apiKey, consumerSecret: ClientCredentials.apiSecret) - - override init() { - super.init() - - if let storedCredentials = retreiveCredentials() { - self.client.client.credential = .init(accessToken: storedCredentials) - if let userId = storedCredentials.userID, - let screenName = storedCredentials.screenName { - self.user = .init(id: userId, screenName: screenName) - self.revalidateAccount() - } - } - } - - func signIn() { - DispatchQueue.main.async { self.state = .busy } - client.authorize(withProvider: self, callbackURL: ClientCredentials.callbackURL) { credentials, response in - guard let credentials = credentials, - let id = credentials.userID, - let screenName = credentials.screenName else { - self.state = .error("Yikes, something when wrong when trying to sign in") - return - } - - self.storeCredentials(credentials: credentials) - - DispatchQueue.main.async { - self.state = .idle - self.user = User(id: id, screenName: screenName) - self.revalidateAccount() - } - } - } - - func revalidateAccount() { - guard let userId = user?.id else { - self.signOut() - return - } - - client.showUser(.id(userId), tweetMode: .extended) { json in - /** If the `showUser` call was successful, we can reuse the result to update the user’s profile photo */ - guard let urlString = json["profile_image_url_https"].string else { - return - } - - withAnimation { - self.user?.originalProfileImageURL = URL(string: urlString.replacingOccurrences(of: "_normal", with: "")) - } - - self.updateLastTweet(from: json["status"]) - } failure: { error in - self.signOut() - self.updateState(.error("Yikes; there was a problem signing in to Twitter. You’ll have to try signing in again.")) - } - } - - func signOut() { - self.user = nil - self.draft = .init() - self.lastTweet = nil - KeychainWrapper.standard.remove(forKey: "broadcast-credentials") - } - - func storeCredentials(credentials: Credential.OAuthAccessToken) { - guard let data = credentials.data else { - return - } - - KeychainWrapper.standard.set(data, forKey: "broadcast-credentials") - } - - func retreiveCredentials() -> Credential.OAuthAccessToken? { - if isTestEnvironment { - return .init(queryString: ClientCredentials.__authQueryString) - } - - guard let data = KeychainWrapper.standard.data(forKey: "broadcast-credentials") else { - return nil - } - - return .init(from: data) - } - - private func sendTweetCallback(response: JSON? = nil, error: Error? = nil) { - if let json = response { - self.updateLastTweet(from: json) - self.updateState(.idle) - self.draft = .init() - Haptics.shared.sendStandardFeedback(feedbackType: .success) - } else if let error = error { - print(error.localizedDescription) - - if draft.media != nil { - self.updateState(.genericTextAndMediaError) - } else { - self.updateState(.genericTextError) - } - - Haptics.shared.sendStandardFeedback(feedbackType: .error) - } - } - - func sendTweet() { - updateState(.busy) - - if let mediaData = draft.media?.jpegData(compressionQuality: 0.8) { - client.postTweet(status: draft.text ?? "", media: mediaData) { json in - self.sendTweetCallback(response: json) - } failure: { error in - print(error.localizedDescription) - self.sendTweetCallback(error: error) - } - } else if let status = draft.text { - client.postTweet(status: status) { json in - self.sendTweetCallback(response: json) - } failure: { error in - print(error.localizedDescription) - self.sendTweetCallback(error: error) - } - } - } - - func sendReply(to id: String) { - updateState(.busy) - - if let mediaData = draft.media?.jpegData(compressionQuality: 0.8) { - client.postTweet(status: draft.text ?? "", media: mediaData, inReplyToStatusID: id) { json in - self.sendTweetCallback(response: json) - } failure: { error in - self.sendTweetCallback(error: error) - } - } else if let status = draft.text { - client.postTweet(status: status, inReplyToStatusID: id) { json in - self.sendTweetCallback(response: json) - Haptics.shared.sendStandardFeedback(feedbackType: .success) - } failure: { error in - self.sendTweetCallback(error: error) - } - } - } - - private func updateLastTweet(from json: JSON) { - guard let id = json["id_str"].string else { return } - var lastTweet = Tweet(id: id) - lastTweet.text = json["full_text"].string ?? json["text"].string - lastTweet.likes = json["favorite_count"].integer - lastTweet.retweets = json["retweet_count"].integer - lastTweet.numericId = json["id"].integer - - if !isTestEnvironment { - self.getReplies(for: lastTweet) { replies in - lastTweet.replies = replies - self.lastTweet = lastTweet - } - } - } - - /// Asynchronously update client state on the main thread - /// - Parameter newState: The new state for the client - private func updateState(_ newState: State) { - DispatchQueue.main.async { - self.state = newState - } - } - - @Published var userSearchResults: [User]? - private var userSearchCancellables = [AnyCancellable]() - func searchScreenNames(_ screenName: String) { - let url = URL(string: "https://twitter.com/i/search/typeahead.json?count=10&q=%23\(screenName)&result_type=users")! - - var headers = [ - "Authorization": typeaheadToken - ] - - if let userId = user?.id, - let token = client.client.credential?.accessToken?.key { - headers["Cookie"] = "twid=u%3D\(userId);auth_token=\(token)" - } - - var request = URLRequest(url: url) - request.allHTTPHeaderFields = headers - request.httpShouldHandleCookies = true - - URLSession.shared.dataTaskPublisher(for: request) - .tryMap() { element -> Data in - guard let httpResponse = element.response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw URLError(.badServerResponse) - } - return element.data - } - .decode(type: TypeaheadResponse.self, decoder: JSONDecoder()) - .sink { completion in - switch completion { - case .failure(let error): - print(error.localizedDescription) - default: - return - } - } receiveValue: { result in - DispatchQueue.main.async { - self.userSearchResults = result.users - } - }.store(in: &userSearchCancellables) - } - - /// Asynchronously provides up to 200 replies for the given tweet. This method works by fetching the - /// most recent 200 @mentions for the user and filters the result to those replying to the provided tweet. - /// - Parameters: - /// - tweet: The tweet to fetch replies for - /// - completion: A callback for handling the replies - private func getReplies(for tweet: Tweet, completion: @escaping ([Tweet]) -> Void = { _ in }) { - let formatter = DateFormatter() - formatter.dateFormat = "EE MMM dd HH:mm:ss Z yyyy" - - guard let tweetId = tweet.id else { return } - - client.getMentionsTimelineTweets(count: 200, tweetMode: .extended) { json in - guard let repliesResult = json.array else { return } - let repliesToThisTweet: [Tweet?] = repliesResult.filter { json in - guard let replyId = json["in_reply_to_status_id"].integer else { return false } - return replyId == tweet.numericId - }.map { json in - guard let id = json["id_str"].string, - let text = json["full_text"].string, - let dateString = json["created_at"].string, - let date = formatter.date(from: dateString) else { - return nil - } - - let user = User(from: json["user"]) - return Tweet(id: id, text: text, date: date, author: user) - } - - completion(repliesToThisTweet.compactMap { $0 }) - - } failure: { error in - print("Error fetching replies for Tweet with ID \(tweetId)") - print(error.localizedDescription) - } - } -} - -extension TwitterClient: ASWebAuthenticationPresentationContextProviding { - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - return ASPresentationAnchor() - } -} - -/* MARK: Drafts */ -extension TwitterClient { - /// Saves the current draft to CoreData for later retrieval. This method also resets/clears the current draft. - func saveDraft() { - guard draft.isValid else { return } - let copy = draft - - DispatchQueue.global(qos: .default).async { - let newDraft = Draft.init(context: self.draftsStore.context) - newDraft.date = Date() - newDraft.text = copy.text - newDraft.media = copy.media?.fixedOrientation.pngData() - newDraft.id = UUID() - - self.draftsStore.save() - } - - withAnimation { - self.draft = .init() - self.state = .idle - } - } - - /// Retrieve the specified draft from CoreData, storing it in memory and deleting it from the CoreData database - /// - Parameter draft: The chosen draft for retrieval and deletion - func retreiveDraft(draft: Draft) { - withAnimation { - self.draft = Tweet(text: draft.text) - - if let media = draft.media { - self.draft.media = UIImage(data: media) - } - } - - let managedObjectContext = PersistanceController.shared.context - managedObjectContext.delete(draft) - - PersistanceController.shared.save() - } -} - -// MARK: Models -extension TwitterClient { - enum State: Equatable { - case idle, busy - case error(_: String? = nil) - - static var genericTextError = State.error("Oh man, something went wrong sending that tweet. It might be too long.") - static var genericTextAndMediaError = State.error("Oh man, something went wrong sending that tweet. Maybe it’s too long, or your chosen media is causing a problem.") - } - - struct ClientCredentials { - static private var plist: NSDictionary? { - guard let filePath = Bundle.main.path(forResource: "TwitterAPI-Info", ofType: "plist") else { - fatalError("Couldn't find file 'TwitterAPI-Info.plist'.") - } - // 2 - return NSDictionary(contentsOfFile: filePath) - } - - static var apiKey: String { - guard let value = plist?.object(forKey: "API_KEY") as? String else { - fatalError("Couldn't find key 'API_KEY' in 'TwitterAPI-Info.plist'.") - } - - return value - } - - static var apiSecret: String { - guard let value = plist?.object(forKey: "API_SECRET") as? String else { - fatalError("Couldn't find key 'API_KEY' in 'TwitterAPI-Info.plist'.") - } - - return value - } - - static var __authQueryString: String { - guard let value = plist?.object(forKey: "__TEST_AUTH_QUERY_STRING") as? String else { - fatalError("Couldn't find key '__TEST_AUTH_QUERY_STRING' in 'TwitterAPI-Info.plist'.") - } - - return value - } - - static var callbackProtocol = "twitter-broadcast://" - static var callbackURL: URL { - URL(string: callbackProtocol)! - } - } - - struct User: Decodable { - var id: String - var screenName: String - var name: String? - var originalProfileImageURL: URL? - var profileImageURL: URL? { - if let urlString = originalProfileImageURL?.absoluteString.replacingOccurrences(of: "_normal", with: "_x96") { - return URL(string: urlString) - } else { - return originalProfileImageURL - } - } - - enum CodingKeys: String, CodingKey { - case screenName = "screen_name" - case originalProfileImageURL = "profile_image_url_https" - case id = "id_str" - case name - } - } - - struct Tweet { - var numericId: Int? - var id: String? - var text: String? - var media: UIImage? - - var likes: Int? - var retweets: Int? - var replies: [Tweet]? - - var date: Date? - - var length: Int { - TwitterText.tweetLength(text: text ?? "") - } - - var isValid: Bool { - if media != nil { - return true - } - - return 1...280 ~= length && !(text ?? "").isBlank - } - - var author: User? - } -} - -extension TwitterClient.User { - init(from json: JSON) { - self.name = json["name"].string - self.screenName = json["screen_name"].string ?? "TwitterUser" - self.id = json["id_str"].string ?? "" - let imageUrlString = json["profile_image_url_https"].string ?? "" - self.originalProfileImageURL = URL(string: imageUrlString) - } -} - -extension Credential.OAuthAccessToken { - var data: Data? { - let dict = [ - "key": key, - "secret": secret, - "userId": userID, - "screenName": screenName - ] - - return try? JSONSerialization.data(withJSONObject: dict, options: []) - } - - init?(from data: Data) { - do { - let dict = try JSONDecoder().decode([String: String].self, from: data) - guard let key = dict["key"], - let secret = dict["secret"] else { - return nil - } - - let screenName = dict["screenName"] - let userId = dict["userId"] - - let queryString = "oauth_token=\(key)&oauth_token_secret=\(secret)&screen_name=\(screenName ?? "")&user_id=\(userId ?? "")" - - self.init(queryString: queryString) - } catch let error { - print(error.localizedDescription) - return nil - } - } -} - -struct TypeaheadResponse: Decodable { - var num_results: Int - var users: [TwitterClient.User]? -} diff --git a/Broadcast/Helpers/TwitterClientManager.swift b/Broadcast/Helpers/TwitterClientManager.swift new file mode 100644 index 0000000..22a2ef0 --- /dev/null +++ b/Broadcast/Helpers/TwitterClientManager.swift @@ -0,0 +1,494 @@ +// +// TwitterClientManager.swift +// Broadcast +// +// Created by Daniel Eden on 30/06/2021. +// + +import Foundation +import Combine +import Twift +import TwitterText +import UIKit +import AuthenticationServices +import SwiftKeychainWrapper +import SwiftUI +import PhotosUI +import CryptoKit + +let typeaheadToken = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" + +typealias AuthenticatedIds = [User.ID] + +extension AuthenticatedIds: RawRepresentable { + public typealias RawValue = String + public init?(rawValue: RawValue) { + guard let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode(AuthenticatedIds.self, from: data) else { + return nil + } + + self = result + } + + public var rawValue: RawValue { + guard let encoded = try? JSONEncoder().encode(self), + let result = String(data: encoded, encoding: .utf8) else { + return "[]" + } + + return result + } +} + +class TwitterClientManager: ObservableObject { + let draftsStore = PersistanceController.shared + @Published var user: User? + @Published var draft: MutableTweet = .init() + @Published var state: State = .initializing + @Published var lastTweet: Tweet? + @Published var client: Twift? + + @Published var selectedMedia: [String: NSItemProvider] = [:] + @Published var mediaAltText: [String: String] = [:] + + @Published var uploadProgress = Progress() + + @MainActor + init() { + if let storedCredentials = self.retreiveCredentials() { + let newClient = Twift(.userAccessTokens( + clientCredentials: ClientCredentials.credentials, + userCredentials: storedCredentials + )) + + Task(priority: .userInitiated) { + await self.updateClient(newClient) + withAnimation(.springAnimation) { self.state = .idle } + } + } else { + withAnimation(.springAnimation) { self.state = .idle } + } + } + + @MainActor + private func updateClient(_ client: Twift?, animated: Bool = false) async { + guard let client = client else { return } + + self.client = client + let user = try? await client.getMe(fields: [\.profileImageUrl]).data + let lastTweet = try? await client.userTimeline(fields: [\.createdAt, \.publicMetrics]).data.first + + if animated { + withAnimation { + self.user = user + self.lastTweet = lastTweet + } + } else { + self.user = user + self.lastTweet = lastTweet + } + } + + @MainActor + func signIn() async { + self.updateState(.busy()) + + let client: Twift? = await withUnsafeContinuation { continuation in + Twift.Authentication().requestUserCredentials(clientCredentials: ClientCredentials.credentials, + callbackURL: ClientCredentials.callbackURL) { (userCredentials, error) in + if let userCredentials = userCredentials { + let newClient = Twift(.userAccessTokens(clientCredentials: ClientCredentials.credentials, userCredentials: userCredentials)) + self.storeCredentials(credentials: userCredentials) + continuation.resume(returning: newClient) + } else if let error = error { + print(error) + continuation.resume(returning: nil) + } + } + } + + await self.updateClient(client, animated: true) + self.updateState(.idle) + } + + @MainActor + func signOut() { + self.user = nil + self.draft = .init() + self.lastTweet = nil + self.client = nil + KeychainWrapper.standard.remove(forKey: "broadcast-credentials") + } + + func storeCredentials(credentials: OAuthCredentials) { + guard let data = try? JSONEncoder().encode(credentials) else { + return + } + + KeychainWrapper.standard.set(data, forKey: "broadcast-credentials") + } + + func retreiveCredentials() -> OAuthCredentials? { + if isTestEnvironment { + return ClientCredentials.userCredentials + } + guard let data = KeychainWrapper.standard.data(forKey: "broadcast-credentials") else { + return nil + } + + return try? JSONDecoder().decode(OAuthCredentials.self, from: data) + } + + private func sendTweetCallback(response: TwitterAPIData? = nil, error: Error? = nil) { + if response != nil { + self.updateState(.idle) + self.draft = .init() + withAnimation { self.selectedMedia = [:] } + Haptics.shared.sendStandardFeedback(feedbackType: .success) + } else if let error = error { + print(error.localizedDescription) + + if !self.selectedMedia.isEmpty { + self.updateState(.genericTextAndMediaError) + } else { + self.updateState(.genericTextError) + } + + Haptics.shared.sendStandardFeedback(feedbackType: .error) + } + } + + @MainActor + func sendTweet(asReply: Bool = false) async { + guard let client = self.client else { + return + } + + updateState(.busy()) + + do { + if asReply, let lastTweet = lastTweet { + draft.reply = .init(inReplyToTweetId: lastTweet.id) + } + + var mediaStrings: [String] = [] + for (key, media) in selectedMedia { + let media: (Data?, URL?, String)? = await withUnsafeContinuation { continuation in + var utType: UTType + + if media.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + utType = media.mediaType ?? .image + } else if media.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + utType = media.mediaType ?? .movie + } else { + return continuation.resume(returning: nil) + } + + guard let mimeTypeString = utType.preferredMIMEType else { + return continuation.resume(returning: nil) + } + + if utType.conforms(to: .movie) { + media.loadFileRepresentation(forTypeIdentifier: utType.identifier) { url, error in + if let error = error { + return self.sendTweetCallback(response: nil, error: error) + } + + if let url = url { + let outputUrl = URL(fileURLWithPath: NSTemporaryDirectory() + UUID().uuidString + ".mp4") + let fileData = try? Data(contentsOf: url) + try? fileData?.write(to: outputUrl) + continuation.resume(returning: (nil, outputUrl, mimeTypeString)) + } else { + return self.updateState(.genericTextAndMediaError) + } + } + } else { + media.loadDataRepresentation(forTypeIdentifier: utType.identifier) { data, error in + if let error = error { + return self.sendTweetCallback(response: nil, error: error) + } + + if let data = data { + continuation.resume(returning: (data, nil, mimeTypeString)) + } else { + return self.updateState(.error("There was a problem Tweeting the attached media because it's in an unusual format.")) + } + } + } + } + + guard let (data, url, mimeType) = media else { + return updateState(.genericTextAndMediaError) + } + + var finalData: Data! + if let url = url { + self.updateState(.busy("Compressing")) + let outputUrl = URL(fileURLWithPath: NSTemporaryDirectory() + UUID().uuidString + ".mp4") + let exportSession: AVAssetExportSession? = await compressVideo(inputURL: url, outputURL: outputUrl) + + guard let url = exportSession?.outputURL, + let data = try? Data(contentsOf: url) else { + return self.updateState(.genericTextAndMediaError) + } + finalData = data + } else if let data = data { + finalData = data + } + + var category: MediaCategory + + if mimeType.contains("gif") { + category = .tweetGif + } else if mimeType.contains("video") { + category = .tweetVideo + } else { + category = .tweetImage + } + + self.updateState(.busy("Uploading")) + let result = try await client.upload(mediaData: finalData, mimeType: mimeType, category: category) + + if let altText = mediaAltText[key] { + try await client.addAltText(to: result.mediaIdString, text: altText) + } + + if result.processingInfo != nil { + _ = try await client.checkMediaUploadSuccessful(result.mediaIdString) + } + + mediaStrings.append(result.mediaIdString) + } + + if !mediaStrings.isEmpty { + draft.media = MutableMedia(mediaIds: mediaStrings) + } + + self.updateState(.busy("Posting")) + let result = try await client.postTweet(draft) + self.lastTweet = try await client.getTweet(result.data.id).data + sendTweetCallback(response: result, error: nil) + } catch { + sendTweetCallback(response: nil, error: error) + } + } + + /// Asynchronously update client state on the main thread + /// - Parameter newState: The new state for the client + private func updateState(_ newState: State) { + DispatchQueue.main.async { + self.state = newState + } + } + + @Published var userSearchResults: [User]? + + @MainActor + func searchScreenNames(_ screenName: String) async { + let url = URL(string: "https://twitter.com/i/search/typeahead.json?count=10&q=%23\(screenName)&result_type=users")! + + var headers = [ + "Authorization": typeaheadToken + ] + + guard case .userAccessTokens(_, let userCredentials) = client?.authenticationType else { + return + } + + if let userId = user?.id { + headers["Cookie"] = "twid=u%3D\(userId);auth_token=\(userCredentials.key)" + } + + var request = URLRequest(url: url) + request.allHTTPHeaderFields = headers + request.httpShouldHandleCookies = true + + do { + let (result, _) = try await URLSession.shared.data(for: request) + let decodedResult = try JSONDecoder().decode(TypeaheadResponse.self, from: result) + withAnimation(.easeInOut(duration: 0.2)) { + self.userSearchResults = decodedResult.users?.compactMap { $0.toUser() } + } + } catch { + print(error) + } + } + + /// Asynchronously provides up to 200 replies for the given tweet. This method works by fetching the + /// most recent 200 @mentions for the user and filters the result to those replying to the provided tweet. + /// - Parameters: + /// - tweet: The tweet to fetch replies for + /// - completion: A callback for handling the replies + public func getReplies(for tweetId: Tweet.ID) async -> [(tweet: Tweet, author: User)] { + let mentions = try? await client?.userMentions(fields: [\.authorId, \.publicMetrics, \.createdAt, \.referencedTweets], + expansions: [.authorId(userFields: [\.profileImageUrl])], + sinceId: tweetId, + maxResults: 100) + + let repliesToTweet = mentions?.data + .filter { $0.referencedTweets?.contains(where: { $0.id == tweetId }) ?? false } ?? [] + let replyAuthors = mentions?.includes?.users? + .filter { user in repliesToTweet.contains(where: { $0.authorId == user.id }) } ?? [] + + return repliesToTweet.map { tweet in + (tweet: tweet, author: replyAuthors.first(where: { $0.id == tweet.authorId! })!) + } + } +} + +fileprivate struct V1User: Codable { + let id_str: String + let name: String + let screen_name: String + let profile_image_url_https: URL + + func toUser() -> User? { + let jsonString = """ + { + "id": "\(id_str)", + "profileImageUrl": "\(profile_image_url_https)", + "name": "\(name)", + "username": "\(screen_name)" + } +""" + do { + return try JSONDecoder().decode(User.self, from: jsonString.data(using: .utf8)!) + } catch { + print(error) + return nil + } + } +} + +/* MARK: Drafts */ +extension TwitterClientManager { + public func draftIsValid() -> Bool { + if let text = draft.text, !text.isEmpty && !text.isBlank { + return TwitterText.tweetLength(text: text) <= 280 + } else if !selectedMedia.isEmpty { + return true + } else { + return false + } + } + /// Saves the current draft to CoreData for later retrieval. This method also resets/clears the current draft. + func saveDraft() { + guard draftIsValid() else { return } + + let copy = draft + DispatchQueue.global(qos: .default).async { + let newDraft = Draft.init(context: self.draftsStore.context) + newDraft.date = Date() + newDraft.text = copy.text + // TODO: Fix draft media + //newDraft.media = copy.media?.fixedOrientation.pngData() + newDraft.id = UUID() + + self.draftsStore.save() + } + + withAnimation { + self.draft = .init() + self.state = .idle + } + } + + /// Retrieve the specified draft from CoreData, storing it in memory and deleting it from the CoreData database + /// - Parameter draft: The chosen draft for retrieval and deletion + func retreiveDraft(draft: Draft) { + withAnimation { + self.draft = MutableTweet(text: draft.text) + + // TODO: Fix draft media + // if let media = draft.media { + // self.draft.media = UIImage(data: media) + // } + } + + let managedObjectContext = PersistanceController.shared.context + managedObjectContext.delete(draft) + + PersistanceController.shared.save() + } +} + +// MARK: Models +extension TwitterClientManager { + enum State: Equatable { + case idle, initializing + case busy(_ label: String? = nil) + case error(_: String? = nil) + + static var genericTextError = State.error("Oh man, something went wrong sending that tweet. It might be too long.") + static var genericTextAndMediaError = State.error("Oh man, something went wrong sending that tweet. Maybe it’s too long, or your chosen media is causing a problem.") + + var isBusy: Bool { + switch self { + case .busy(_): return true + default: return false + } + } + } + + struct ClientCredentials { + static private var plist: NSDictionary? { + guard let filePath = Bundle.main.path(forResource: "TwitterAPI-Info", ofType: "plist") else { + fatalError("Couldn't find file 'TwitterAPI-Info.plist'.") + } + // 2 + return NSDictionary(contentsOfFile: filePath) + } + + static var apiKey: String { + guard let value = plist?.object(forKey: "TWITTER_CONSUMER_KEY") as? String else { + fatalError("Couldn't find key 'TWITTER_CONSUMER_KEY' in 'TwitterAPI-Info.plist'.") + } + + return value + } + + static var apiSecret: String { + guard let value = plist?.object(forKey: "TWITTER_CONSUMER_SECRET") as? String else { + fatalError("Couldn't find key 'TWITTER_CONSUMER_SECRET' in 'TwitterAPI-Info.plist'.") + } + + return value + } + + static var credentials: OAuthCredentials { + .init(key: apiKey, secret: apiSecret) + } + + static var callbackProtocol = "twitter-broadcast://" + static var callbackURL: URL { + URL(string: callbackProtocol)! + } + + static var accessKey: String { + guard let value = plist?.object(forKey: "TWITTER_ACCESS_KEY") as? String else { + fatalError("Couldn't find key 'TWITTER_ACCESS_KEY' in 'TwitterAPI-Info.plist'.") + } + + return value + } + + static var accessSecret: String { + guard let value = plist?.object(forKey: "TWITTER_ACCESS_SECRET") as? String else { + fatalError("Couldn't find key 'TWITTER_ACCESS_SECRET' in 'TwitterAPI-Info.plist'.") + } + + return value + } + + static var userCredentials: OAuthCredentials { + .init(key: accessKey, secret: accessSecret) + } + } +} + +fileprivate struct TypeaheadResponse: Decodable { + var num_results: Int + var users: [V1User]? +} diff --git a/Broadcast/SignOutView.swift b/Broadcast/SignOutView.swift deleted file mode 100644 index f0555e6..0000000 --- a/Broadcast/SignOutView.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// SignOutView.swift -// Broadcast -// -// Created by Daniel Eden on 27/06/2021. -// - -import SwiftUI -import CoreHaptics - -struct SignOutView: View { - @Environment(\.colorScheme) var colorScheme - @Environment(\.presentationMode) var presentationMode - @EnvironmentObject var twitterClient: TwitterClient - @EnvironmentObject var themeHelper: ThemeHelper - - @State private var offset = CGSize.zero - @State private var willDelete = false - - @ScaledMetric var size: CGFloat = 88 - - var labelOpacity: Double { - Double(1 - abs(offset.height) / 200) - } - - @State private var animating = false - - var body: some View { - VStack { - Spacer() - if let screenName = twitterClient.user?.screenName { - Label("Drag to sign out @\(screenName)", systemImage: "arrow.down.circle") - .font(.broadcastBody.bold()) - .foregroundColor(.secondary) - .padding() - .opacity(labelOpacity) - } - - VStack { - Group { - if let profileImageURL = twitterClient.user?.profileImageURL { - RemoteImage(url: profileImageURL, placeholder: { ProgressView() }) - .aspectRatio(contentMode: .fill) - .clipShape(Circle()) - } else { - Image(systemName: "person.crop.circle.fill") - .resizable() - } - } - .shadow( - color: (willDelete || colorScheme == .dark) ? .black.opacity(0.2) : .accentColor, - radius: 8, x: 0, y: 4 - ) - .foregroundColor(.white) - .padding(8) - .frame(width: size, height: size) - .background(willDelete - ? Color(.secondarySystemBackground) - : .accentColor.opacity(colorScheme == .dark ? 0.9 : 0.5) - ) - .clipShape(Circle()) - .onTapGesture { - themeHelper.rotateTheme() - Haptics.shared.sendStandardFeedback(feedbackType: .success) - } - .offset(offset) - .highPriorityGesture( - DragGesture() - .onChanged { gesture in - withAnimation { self.offset.height = min(gesture.translation.height, 200 + size) } - - withAnimation(.interactiveSpring()) { willDelete = self.offset.height >= 200 } - } - - .onEnded { _ in - if self.offset.height >= 200 { - startSignOut() - } else { - withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.6, blendDuration: 0.4)) { - self.offset = .zero - willDelete = false - } - } - } - ) - .accessibilityIdentifier("logoutProfilePhotoHandle") - - Color.clear.frame(height: 180) - - Image(systemName: "trash") - .resizable() - .aspectRatio(contentMode: .fit) - .padding(size * 0.3) - .frame(width: size, height: size) - .background(willDelete ? Color(.systemRed) : Color(.secondarySystemBackground)) - .foregroundColor(willDelete ? .white : .primary) - .clipShape(Circle()) - .accessibilityIdentifier("logoutTarget") - } - Spacer() - - Button(action: { presentationMode.wrappedValue.dismiss() }) { - Text("Close") - }.buttonStyle(BroadcastButtonStyle(prominence: .tertiary)) - .opacity(labelOpacity) - } - .padding() - .onChange(of: willDelete) { willDelete in - let v: Float = willDelete ? 1 : 0.3 - Haptics.shared.sendFeedback(intensity: v, sharpness: v) - } - .accentColor(themeHelper.color) - } - - func startSignOut() { - twitterClient.signOut() - presentationMode.wrappedValue.dismiss() - } -} - -struct SignOutView_Previews: PreviewProvider { - static var previews: some View { - SignOutView() - } -} diff --git a/Broadcast/TwitterAPI-Info.example.plist b/Broadcast/TwitterAPI-Info.example.plist index 92705a7..2aa68bc 100644 --- a/Broadcast/TwitterAPI-Info.example.plist +++ b/Broadcast/TwitterAPI-Info.example.plist @@ -2,11 +2,13 @@ - __TEST_AUTH_QUERY_STRING - Enter auth query string here (necessary for tests) - API_SECRET + TWITTER_ACCESS_KEY + (Required for tests only) Enter user access token + TWITTER_ACCESS_SECRET + (Required for tests only) Enter user access secret + TWITTER_CONSUMER_KEY Enter API Secret Here - API_KEY + TWITTER_CONSUMER_SECRET Enter API Key Here diff --git a/BroadcastUITests/BroadcastUITests.swift b/BroadcastUITests/BroadcastUITests.swift index e9f6f19..155f049 100644 --- a/BroadcastUITests/BroadcastUITests.swift +++ b/BroadcastUITests/BroadcastUITests.swift @@ -6,6 +6,7 @@ // import XCTest +import Twift extension XCUIApplication { static func initWithLaunchParameters() -> XCUIApplication { @@ -87,16 +88,13 @@ class BroadcastUITests: XCTestCase { let app = XCUIApplication.initWithLaunchParameters() app.launch() - let profilePhoto = app.images["profilePhotoButton"] + let profilePhoto = app.buttons["profilePhotoButton"] profilePhoto.tap() - let logoutHandle = app.descendants(matching: .any).matching(identifier: "logoutProfilePhotoHandle").firstMatch - let logoutTarget = app.images["logoutTarget"] - logoutHandle.press(forDuration: 0.1, thenDragTo: logoutTarget) - - _ = XCTWaiter.wait(for: [expectation(description: "Wait for .5 seconds")], timeout: 0.5) + let logoutHandle = app.descendants(matching: .any).matching(identifier: "logoutButton").firstMatch + logoutHandle.tap() let loginButton = app.buttons["loginButton"] - XCTAssert(loginButton.exists) + XCTAssert(loginButton.waitForExistence(timeout: 1)) } }