diff --git a/Pachyderm/Client.swift b/Pachyderm/Client.swift index 370cf4c5eb..bd02bd2b4c 100644 --- a/Pachyderm/Client.swift +++ b/Pachyderm/Client.swift @@ -314,12 +314,26 @@ public class Client { } extension Client { - public enum Error: Swift.Error { + public enum Error: LocalizedError { case unknownError case invalidRequest case invalidResponse case invalidModel case mastodonError(String) + public var localizedDescription: String { + switch self { + case .unknownError: + return "Unknown Error" + case .invalidRequest: + return "Invalid Request" + case .invalidResponse: + return "Invalid Response" + case .invalidModel: + return "Invalid Model" + case .mastodonError(let error): + return "Server Error: \(error)" + } + } } } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index ebf730ad20..512628508c 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -20,7 +20,6 @@ 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; }; 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; }; 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; }; - D60309B52419D4F100A465FF /* ComposeAttachmentsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */; }; D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; }; D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */; }; D60E2F272442372B005F8713 /* StatusMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F232442372B005F8713 /* StatusMO.swift */; }; @@ -73,10 +72,16 @@ D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; }; D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; }; D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; }; - D620483223D2A6A3008A63EF /* CompositionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483123D2A6A3008A63EF /* CompositionState.swift */; }; D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; }; D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; }; D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; }; + D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */; }; + D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757724EE133700B82A16 /* ComposeAssetPicker.swift */; }; + D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */; }; + D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622759F24F1677200B82A16 /* ComposeHostingController.swift */; }; + D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A524F1C81800B82A16 /* ComposeReplyView.swift */; }; + D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; }; + D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A924F1E01C00B82A16 /* ComposeTextView.swift */; }; D626493323BD751600612E6E /* ShowCameraCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D626493123BD751600612E6E /* ShowCameraCollectionViewCell.xib */; }; D626493523BD94CE00612E6E /* CompositionAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */; }; D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */; }; @@ -111,10 +116,6 @@ D63569E023908A8D003DD353 /* StatusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60A4FFB238B726A008AC647 /* StatusState.swift */; }; D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; }; D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */; }; - D63F9C66241C4CC3004C03CF /* AddAttachmentTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */; }; - D63F9C68241C4F79004C03CF /* AddAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */; }; - D63F9C6B241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */; }; - D63F9C6C241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D63F9C6A241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib */; }; D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */; }; D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */; }; D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */ = {isa = PBXBuildFile; fileRef = D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */; }; @@ -150,7 +151,6 @@ D663626221360B1900C9CBA2 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626121360B1900C9CBA2 /* Preferences.swift */; }; D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626321360D2300C9CBA2 /* AvatarStyle.swift */; }; D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; }; - D66362712136338600C9CBA2 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66362702136338600C9CBA2 /* ComposeViewController.swift */; }; D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66362742137068A00C9CBA2 /* Visibility+Helpers.swift */; }; D667383C23299340000A2373 /* InstanceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667383B23299340000A2373 /* InstanceType.swift */; }; D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; }; @@ -162,6 +162,10 @@ D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7B2157E01900721E32 /* XCBManager.swift */; }; D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */; }; D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6757A812157E8FA00721E32 /* XCBSession.swift */; }; + D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; }; + D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; }; + D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */; }; + D677284E24ECC01D00C732D3 /* Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284D24ECC01D00C732D3 /* Draft.swift */; }; D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */; }; D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */; }; D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D679C09E215850EF00DA27FE /* XCBActions.swift */; }; @@ -177,6 +181,7 @@ D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D6246E32290053414F /* StatusActivityItemSource.swift */; }; D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */; }; D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */; }; + D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; }; D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; }; D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* SearchViewController.swift */; }; D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; }; @@ -206,7 +211,6 @@ D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */; }; D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */; }; D6A5BB3123BBAD87003BF21D /* JSONResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB3023BBAD87003BF21D /* JSONResponse.swift */; }; - D6A5FAF1217B7E05003DB2D9 /* ComposeViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A5FAF0217B7E05003DB2D9 /* ComposeViewController.xib */; }; D6AC956723C4347E008C9946 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* SceneDelegate.swift */; }; D6ACE1AC240C3BAD004EA8E2 /* Ambassador.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F613023AE99E000F3CFD3 /* Ambassador.framework */; }; D6ACE1AD240C3BAD004EA8E2 /* Embassy.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F612D23AE990C00F3CFD3 /* Embassy.framework */; }; @@ -239,8 +243,13 @@ D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */; }; D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D862139E62700CB5196 /* LargeImageViewController.swift */; }; D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D882139E6EC00CB5196 /* AttachmentView.swift */; }; + D6C99FC724FACFAB005C74D3 /* ActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */; }; + D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; }; D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; }; D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; }; + D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; }; + D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */; }; + D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; }; D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */; }; D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD6212518A200E1C4BB /* Assets.xcassets */; }; D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */; }; @@ -331,7 +340,6 @@ 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = ""; }; 04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; 04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = ""; }; - D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsViewController.swift; sourceTree = ""; }; D60A4FFB238B726A008AC647 /* StatusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusState.swift; sourceTree = ""; }; D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = ""; }; D60E2F232442372B005F8713 /* StatusMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMO.swift; sourceTree = ""; }; @@ -385,10 +393,16 @@ D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = ""; }; D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = ""; }; D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = ""; }; - D620483123D2A6A3008A63EF /* CompositionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionState.swift; sourceTree = ""; }; D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = ""; }; D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = ""; }; D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = ""; }; + D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsList.swift; sourceTree = ""; }; + D622757724EE133700B82A16 /* ComposeAssetPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAssetPicker.swift; sourceTree = ""; }; + D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentRow.swift; sourceTree = ""; }; + D622759F24F1677200B82A16 /* ComposeHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeHostingController.swift; sourceTree = ""; }; + D62275A524F1C81800B82A16 /* ComposeReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyView.swift; sourceTree = ""; }; + D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = ""; }; + D62275A924F1E01C00B82A16 /* ComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextView.swift; sourceTree = ""; }; D626493123BD751600612E6E /* ShowCameraCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ShowCameraCollectionViewCell.xib; sourceTree = ""; }; D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentData.swift; sourceTree = ""; }; D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllPhotosTableViewCell.swift; sourceTree = ""; }; @@ -422,10 +436,6 @@ D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = ""; }; D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = ""; }; D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Tusker.xcdatamodel; sourceTree = ""; }; - D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AddAttachmentTableViewCell.xib; sourceTree = ""; }; - D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAttachmentTableViewCell.swift; sourceTree = ""; }; - D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentTableViewCell.swift; sourceTree = ""; }; - D63F9C6A241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeAttachmentTableViewCell.xib; sourceTree = ""; }; D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachment.swift; sourceTree = ""; }; D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectImageButton.swift; sourceTree = ""; }; D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = DomainBlocks.plist; sourceTree = ""; }; @@ -465,7 +475,6 @@ D663626121360B1900C9CBA2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; D663626321360D2300C9CBA2 /* AvatarStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStyle.swift; sourceTree = ""; }; D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = ""; }; - D66362702136338600C9CBA2 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; D66362742137068A00C9CBA2 /* Visibility+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Visibility+Helpers.swift"; sourceTree = ""; }; D667383B23299340000A2373 /* InstanceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceType.swift; sourceTree = ""; }; D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcutItems.swift; sourceTree = ""; }; @@ -477,6 +486,10 @@ D6757A7B2157E01900721E32 /* XCBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBManager.swift; sourceTree = ""; }; D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequestSpec.swift; sourceTree = ""; }; D6757A812157E8FA00721E32 /* XCBSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBSession.swift; sourceTree = ""; }; + D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; + D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = ""; }; + D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = ""; }; + D677284D24ECC01D00C732D3 /* Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Draft.swift; sourceTree = ""; }; D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = ""; }; D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = ""; }; D679C09E215850EF00DA27FE /* XCBActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActions.swift; sourceTree = ""; }; @@ -492,6 +505,7 @@ D681E4D6246E32290053414F /* StatusActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityItemSource.swift; sourceTree = ""; }; D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountActivityItemSource.swift; sourceTree = ""; }; D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = ""; }; + D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = ""; }; D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = ""; }; D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = ""; }; @@ -520,7 +534,6 @@ D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = ""; }; D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = ""; }; D6A5BB3023BBAD87003BF21D /* JSONResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONResponse.swift; sourceTree = ""; }; - D6A5FAF0217B7E05003DB2D9 /* ComposeViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeViewController.xib; sourceTree = ""; }; D6AC956623C4347E008C9946 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivity+Types.swift"; sourceTree = ""; }; D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMesasgeActivity.swift; sourceTree = ""; }; @@ -549,8 +562,13 @@ D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentsContainerView.swift; sourceTree = ""; }; D6C94D862139E62700CB5196 /* LargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageViewController.swift; sourceTree = ""; }; D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = ""; }; + D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorView.swift; sourceTree = ""; }; + D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = ""; }; D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = ""; }; D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = ""; }; + D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = ""; }; + D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeContainerView.swift; sourceTree = ""; }; + D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = ""; }; D6D4DDCC212518A000E1C4BB /* Tusker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tusker.app; sourceTree = BUILT_PRODUCTS_DIR; }; D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; D6D4DDD6212518A200E1C4BB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -754,24 +772,14 @@ path = "Draft Cell"; sourceTree = ""; }; - D61959D1241E844900A37B8E /* Attachment Cells */ = { - isa = PBXGroup; - children = ( - D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */, - D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */, - D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */, - D63F9C6A241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib */, - ); - path = "Attachment Cells"; - sourceTree = ""; - }; D61959D2241E846D00A37B8E /* Models */ = { isa = PBXGroup; children = ( D6285B5221EA708700FE4B39 /* StatusFormat.swift */, - D620483123D2A6A3008A63EF /* CompositionState.swift */, D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */, D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */, + D677284D24ECC01D00C732D3 /* Draft.swift */, + D627FF75217E923E00CC0648 /* DraftsManager.swift */, ); path = Models; sourceTree = ""; @@ -959,10 +967,20 @@ D641C787213DD862004B4513 /* Compose */ = { isa = PBXGroup; children = ( - D6A5FAF0217B7E05003DB2D9 /* ComposeViewController.xib */, - D66362702136338600C9CBA2 /* ComposeViewController.swift */, - D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */, D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */, + D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */, + D622759F24F1677200B82A16 /* ComposeHostingController.swift */, + D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */, + D677284724ECBCB100C732D3 /* ComposeView.swift */, + D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */, + D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */, + D62275A924F1E01C00B82A16 /* ComposeTextView.swift */, + D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */, + D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */, + D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */, + D622757724EE133700B82A16 /* ComposeAssetPicker.swift */, + D62275A524F1C81800B82A16 /* ComposeReplyView.swift */, + D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */, ); path = Compose; sourceTree = ""; @@ -1081,6 +1099,7 @@ D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */, D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */, D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */, + D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */, ); path = Extensions; sourceTree = ""; @@ -1230,10 +1249,11 @@ D627943123A5466600D38C68 /* SelectableTableViewCell.swift */, D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */, D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */, + D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */, + D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */, D67C57A721E2649B00C3118B /* Account Detail */, D67C57B021E28F9400C3118B /* Compose Status Reply */, D626494023C122C800612E6E /* Asset Picker */, - D61959D1241E844900A37B8E /* Attachment Cells */, D61959D0241E842400A37B8E /* Draft Cell */, D641C78A213DD926004B4513 /* Status */, D6C7D27B22B6EBE200071952 /* Attachments */, @@ -1308,7 +1328,6 @@ D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */, D6AC956623C4347E008C9946 /* SceneDelegate.swift */, D64D0AAC2128D88B005A6F37 /* LocalData.swift */, - D627FF75217E923E00CC0648 /* DraftsManager.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */, D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */, @@ -1518,7 +1537,7 @@ }; D6D4DDCB212518A000E1C4BB = { CreatedOnToolsVersion = 10.0; - LastSwiftMigration = 1020; + LastSwiftMigration = 1200; }; D6D4DDDF212518A200E1C4BB = { CreatedOnToolsVersion = 10.0; @@ -1604,11 +1623,8 @@ D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */, D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */, D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */, - D63F9C6C241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib in Resources */, D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */, - D63F9C66241C4CC3004C03CF /* AddAttachmentTableViewCell.xib in Resources */, D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */, - D6A5FAF1217B7E05003DB2D9 /* ComposeViewController.xib in Resources */, D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1723,6 +1739,7 @@ D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */, 0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */, D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */, + D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */, D60E2F292442372B005F8713 /* AccountMO.swift in Sources */, D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */, D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */, @@ -1738,10 +1755,13 @@ D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */, D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */, D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */, + D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */, 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */, D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */, D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */, D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */, + D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */, + D6C99FC724FACFAB005C74D3 /* ActivityIndicatorView.swift in Sources */, D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */, 0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */, D6412B0724B0237700F5412E /* ProfileStatusesViewController.swift in Sources */, @@ -1750,7 +1770,6 @@ D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */, D627943723A552C200D38C68 /* BookmarkStatusActivity.swift in Sources */, D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */, - D66362712136338600C9CBA2 /* ComposeViewController.swift in Sources */, D6AC956723C4347E008C9946 /* SceneDelegate.swift in Sources */, D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */, D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */, @@ -1760,13 +1779,13 @@ D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */, D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */, D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */, - D60309B52419D4F100A465FF /* ComposeAttachmentsViewController.swift in Sources */, D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */, D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */, D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */, D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */, D60E2F272442372B005F8713 /* StatusMO.swift in Sources */, D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */, + D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */, D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */, D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */, D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */, @@ -1777,6 +1796,7 @@ D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */, D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */, D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */, + D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */, 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, D627FF7B217E951500CC0648 /* DraftsTableViewController.swift in Sources */, D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */, @@ -1785,11 +1805,12 @@ D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */, D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */, 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */, + D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */, D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */, D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */, D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */, - D63F9C6B241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift in Sources */, D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */, + D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */, D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */, D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */, D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */, @@ -1803,6 +1824,7 @@ D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */, D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */, D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */, + D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */, D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */, D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */, 0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */, @@ -1815,6 +1837,7 @@ D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */, D627943E23A564D400D38C68 /* ExploreViewController.swift in Sources */, D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */, + D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */, D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */, D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */, D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */, @@ -1823,6 +1846,7 @@ D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */, D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */, D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */, + D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, D620483423D3801D008A63EF /* LinkTextView.swift in Sources */, D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */, D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */, @@ -1830,10 +1854,11 @@ D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */, D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */, D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */, + D622757824EE133700B82A16 /* ComposeAssetPicker.swift in Sources */, D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */, D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */, D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */, - D63F9C68241C4F79004C03CF /* AddAttachmentTableViewCell.swift in Sources */, + D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */, D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */, D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */, D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */, @@ -1852,9 +1877,10 @@ D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */, D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */, D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */, + D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */, + D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */, D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */, 04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */, - D620483223D2A6A3008A63EF /* CompositionState.swift in Sources */, D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */, D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */, D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */, @@ -1872,7 +1898,9 @@ D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */, D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */, D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */, + D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */, D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */, + D677284E24ECC01D00C732D3 /* Draft.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */, D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */, diff --git a/Tusker/Activities/Account Activities/SendMesasgeActivity.swift b/Tusker/Activities/Account Activities/SendMesasgeActivity.swift index ea67c0b02d..da34dd1216 100644 --- a/Tusker/Activities/Account Activities/SendMesasgeActivity.swift +++ b/Tusker/Activities/Account Activities/SendMesasgeActivity.swift @@ -28,7 +28,9 @@ class SendMessageActivity: AccountActivity { override var activityViewController: UIViewController? { guard let account = account else { return nil } - return UINavigationController(rootViewController: ComposeViewController(mentioningAcct: account.acct, mastodonController: mastodonController)) + let draft = mastodonController.createDraft(mentioningAcct: account.acct) + let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController) + return UINavigationController(rootViewController: compose) } } diff --git a/Tusker/Controllers/MastodonController.swift b/Tusker/Controllers/MastodonController.swift index 31dd97b8b2..38da3a1649 100644 --- a/Tusker/Controllers/MastodonController.swift +++ b/Tusker/Controllers/MastodonController.swift @@ -116,3 +116,6 @@ class MastodonController { } } + +// ObservableObject so that SwiftUI views can receive it through @EnvironmentObject +extension MastodonController: ObservableObject {} diff --git a/Tusker/DraftsManager.swift b/Tusker/DraftsManager.swift deleted file mode 100644 index e1b408891a..0000000000 --- a/Tusker/DraftsManager.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// DraftsManager.swift -// Tusker -// -// Created by Shadowfacts on 10/22/18. -// Copyright © 2018 Shadowfacts. All rights reserved. -// - -import Foundation - -class DraftsManager: Codable { - - private(set) static var shared: DraftsManager = load() - - private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - private static var archiveURL = DraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") - - static func save() { - DispatchQueue.global(qos: .userInitiated).async { - let encoder = PropertyListEncoder() - let data = try? encoder.encode(shared) - try? data?.write(to: archiveURL, options: .noFileProtection) - } - } - - static func load() -> DraftsManager { - let decoder = PropertyListDecoder() - if let data = try? Data(contentsOf: archiveURL), - let draftsManager = try? decoder.decode(DraftsManager.self, from: data) { - return draftsManager - } - return DraftsManager() - } - - private init() {} - - var drafts: [Draft] = [] - var sorted: [Draft] { - return drafts.sorted(by: { $0.lastModified > $1.lastModified }) - } - - func create(accountID: String, text: String, contentWarning: String?, inReplyToID: String?, attachments: [CompositionAttachment]) -> Draft { - let draft = Draft(accountID: accountID, text: text, contentWarning: contentWarning, inReplyToID: inReplyToID, attachments: attachments) - drafts.append(draft) - return draft - } - - func remove(_ draft: Draft) { - let index = drafts.firstIndex(of: draft)! - drafts.remove(at: index) - } - -} - -extension DraftsManager { - class Draft: Codable, Equatable { - let id: UUID - private(set) var accountID: String - private(set) var text: String - private(set) var contentWarning: String? - var attachments: [CompositionAttachment] - private(set) var inReplyToID: String? - private(set) var lastModified: Date - - init(accountID: String, text: String, contentWarning: String?, inReplyToID: String?, attachments: [CompositionAttachment], lastModified: Date = Date()) { - self.id = UUID() - self.accountID = accountID - self.text = text - self.contentWarning = contentWarning - self.inReplyToID = inReplyToID - self.attachments = attachments - self.lastModified = lastModified - } - - func update(accountID: String, text: String, contentWarning: String?, attachments: [CompositionAttachment]) { - self.accountID = accountID - self.text = text - self.contentWarning = contentWarning - self.lastModified = Date() - self.attachments = attachments - } - - static func ==(lhs: Draft, rhs: Draft) -> Bool { - return lhs.id == rhs.id - } - } -} diff --git a/Tusker/Extensions/View+ConditionalModifier.swift b/Tusker/Extensions/View+ConditionalModifier.swift new file mode 100644 index 0000000000..dfeb7cf5f4 --- /dev/null +++ b/Tusker/Extensions/View+ConditionalModifier.swift @@ -0,0 +1,22 @@ +// +// View+ConditionalModifier.swift +// Tusker +// +// Created by Shadowfacts on 8/31/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +extension View { + + @ViewBuilder + func conditionally(_ condition: Bool, modifier: (Self) -> Modified) -> some View { + if condition { + modifier(self) + } else { + self + } + } + +} diff --git a/Tusker/Models/CompositionAttachment.swift b/Tusker/Models/CompositionAttachment.swift index 13f0f5ec01..0db4c3a7ae 100644 --- a/Tusker/Models/CompositionAttachment.swift +++ b/Tusker/Models/CompositionAttachment.swift @@ -10,22 +10,48 @@ import Foundation import UIKit import MobileCoreServices -final class CompositionAttachment: NSObject, Codable { +final class CompositionAttachment: NSObject, Codable, ObservableObject { static let typeIdentifier = "space.vaccor.Tusker.composition-attachment" - let data: CompositionAttachmentData - var attachmentDescription: String + let id: UUID + @Published var data: CompositionAttachmentData + @Published var attachmentDescription: String init(data: CompositionAttachmentData, description: String = "") { + self.id = UUID() self.data = data self.attachmentDescription = description } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(UUID.self, forKey: .id) + self.data = try container.decode(CompositionAttachmentData.self, forKey: .data) + self.attachmentDescription = try container.decode(String.self, forKey: .attachmentDescription) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(data, forKey: .data) + try container.encode(attachmentDescription, forKey: .attachmentDescription) + } + static func ==(lhs: CompositionAttachment, rhs: CompositionAttachment) -> Bool { - return lhs.data == rhs.data + return lhs.id == rhs.id + } + + enum CodingKeys: String, CodingKey { + case id + case data + case attachmentDescription } } +extension CompositionAttachment: Identifiable {} + private let imageType = kUTTypeImage as String private let mp4Type = kUTTypeMPEG4 as String private let quickTimeType = kUTTypeQuickTimeMovie as String diff --git a/Tusker/Models/CompositionState.swift b/Tusker/Models/CompositionState.swift deleted file mode 100644 index 950ff821bd..0000000000 --- a/Tusker/Models/CompositionState.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// CompositionState.swift -// Tusker -// -// Created by Shadowfacts on 1/17/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import Foundation - -struct CompositionState: OptionSet { - let rawValue: Int - - static let currentlyPosting = CompositionState(rawValue: 1 << 0) - static let tooManyCharacters = CompositionState(rawValue: 1 << 1) - static let requiresAttachmentDescriptions = CompositionState(rawValue: 1 << 2) - - static let valid: CompositionState = [] - - var isValid: Bool { - isEmpty - } -} diff --git a/Tusker/Models/Draft.swift b/Tusker/Models/Draft.swift new file mode 100644 index 0000000000..ac9089fc93 --- /dev/null +++ b/Tusker/Models/Draft.swift @@ -0,0 +1,142 @@ +// +// Draft.swift +// Tusker +// +// Created by Shadowfacts on 8/18/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import Foundation +import Pachyderm + +class Draft: Codable, ObservableObject { + let id: UUID + var lastModified: Date + + @Published var accountID: String + @Published var text: String + @Published var contentWarningEnabled: Bool + @Published var contentWarning: String + @Published var attachments: [CompositionAttachment] + @Published var inReplyToID: String? + @Published var visibility: Status.Visibility + + var initialText: String + + var hasContent: Bool { + (!text.isEmpty && text != initialText) || + (contentWarningEnabled && !contentWarning.isEmpty) || + attachments.count > 0 + } + + init(accountID: String) { + self.id = UUID() + self.lastModified = Date() + + self.accountID = accountID + self.text = "" + self.contentWarningEnabled = false + self.contentWarning = "" + self.attachments = [] + self.inReplyToID = nil + self.visibility = Preferences.shared.defaultPostVisibility + + self.initialText = "" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(UUID.self, forKey: .id) + self.lastModified = try container.decode(Date.self, forKey: .lastModified) + + self.accountID = try container.decode(String.self, forKey: .accountID) + self.text = try container.decode(String.self, forKey: .text) + if let enabled = try? container.decode(Bool.self, forKey: .contentWarningEnabled) { + self.contentWarningEnabled = enabled + self.contentWarning = try container.decode(String.self, forKey: .contentWarning) + } else { + // todo: temporary until migration away from old drafts manager is complete + let cw = try container.decode(String?.self, forKey: .contentWarning) + if let cw = cw { + self.contentWarningEnabled = !cw.isEmpty + self.contentWarning = cw + } else { + self.contentWarningEnabled = false + self.contentWarning = "" + } + } + self.attachments = try container.decode([CompositionAttachment].self, forKey: .attachments) + self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID) + self.visibility = try container.decode(Status.Visibility.self, forKey: .visibility) + + self.initialText = try container.decode(String.self, forKey: .initialText) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(lastModified, forKey: .lastModified) + + try container.encode(accountID, forKey: .accountID) + try container.encode(text, forKey: .text) + try container.encode(contentWarningEnabled, forKey: .contentWarningEnabled) + try container.encode(contentWarning, forKey: .contentWarning) + try container.encode(attachments, forKey: .attachments) + try container.encode(inReplyToID, forKey: .inReplyToID) + try container.encode(visibility, forKey: .visibility) + + try container.encode(initialText, forKey: .initialText) + } +} + +extension Draft: Equatable { + static func ==(lhs: Draft, rhs: Draft) -> Bool { + return lhs.id == rhs.id + } +} + +extension Draft { + enum CodingKeys: String, CodingKey { + case id + case lastModified + + case accountID + case text + case contentWarningEnabled + case contentWarning + case attachments + case inReplyToID + case visibility + + case initialText + } +} + +extension MastodonController { + + func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil) -> Draft { + var acctsToMention = [String]() + + if let inReplyToID = inReplyToID, + let inReplyTo = persistentContainer.status(for: inReplyToID) { + acctsToMention.append(inReplyTo.account.acct) + acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct)) + } + if let mentioningAcct = mentioningAcct { + acctsToMention.append(mentioningAcct) + } + if let ownAccount = self.account { + acctsToMention.removeAll(where: { $0 == ownAccount.acct }) + } + acctsToMention = acctsToMention.uniques() + + let draft = Draft(accountID: accountInfo!.id) + draft.inReplyToID = inReplyToID + draft.text = acctsToMention.map { "@\($0) " }.joined() + draft.initialText = draft.text + return draft + } + +} diff --git a/Tusker/Models/DraftsManager.swift b/Tusker/Models/DraftsManager.swift new file mode 100644 index 0000000000..3982310751 --- /dev/null +++ b/Tusker/Models/DraftsManager.swift @@ -0,0 +1,50 @@ +// +// DraftsManager.swift +// Tusker +// +// Created by Shadowfacts on 10/22/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import Foundation + +class DraftsManager: Codable { + + private(set) static var shared: DraftsManager = load() + + private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + private static var archiveURL = DraftsManager.documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") + + static func save() { + DispatchQueue.global(qos: .utility).async { + let encoder = PropertyListEncoder() + let data = try? encoder.encode(shared) + try? data?.write(to: archiveURL, options: .noFileProtection) + } + } + + static func load() -> DraftsManager { + let decoder = PropertyListDecoder() + if let data = try? Data(contentsOf: archiveURL), + let draftsManager = try? decoder.decode(DraftsManager.self, from: data) { + return draftsManager + } + return DraftsManager() + } + + private init() {} + + var drafts: [Draft] = [] + var sorted: [Draft] { + return drafts.sorted(by: { $0.lastModified > $1.lastModified }) + } + + func add(_ draft: Draft) { + drafts.append(draft) + } + + func remove(_ draft: Draft) { + drafts.removeAll { $0 == draft } + } + +} diff --git a/Tusker/Screens/Asset Picker/AssetPickerViewController.swift b/Tusker/Screens/Asset Picker/AssetPickerViewController.swift index c5732e736e..4bcad31412 100644 --- a/Tusker/Screens/Asset Picker/AssetPickerViewController.swift +++ b/Tusker/Screens/Asset Picker/AssetPickerViewController.swift @@ -9,14 +9,14 @@ import UIKit import Photos -protocol AssetPickerViewControllerDelegate { +protocol AssetPickerViewControllerDelegate: class { func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData]) } class AssetPickerViewController: UINavigationController { - var assetPickerDelegate: AssetPickerViewControllerDelegate? + weak var assetPickerDelegate: AssetPickerViewControllerDelegate? var currentCollectionSelectedAssets: [CompositionAttachmentData] { if let vc = visibleViewController as? AssetCollectionViewController { diff --git a/Tusker/Screens/Compose/ComposeAssetPicker.swift b/Tusker/Screens/Compose/ComposeAssetPicker.swift new file mode 100644 index 0000000000..12b3cb507d --- /dev/null +++ b/Tusker/Screens/Compose/ComposeAssetPicker.swift @@ -0,0 +1,29 @@ +// +// ComposeAssetPicker.swift +// Tusker +// +// Created by Shadowfacts on 8/19/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct ComposeAssetPicker: UIViewControllerRepresentable { + typealias UIViewControllerType = AssetPickerViewController + + @ObservedObject var draft: Draft + let delegate: AssetPickerViewControllerDelegate? + + @EnvironmentObject var mastodonController: MastodonController + + func makeUIViewController(context: Context) -> AssetPickerViewController { + let vc = AssetPickerViewController() + vc.assetPickerDelegate = delegate + vc.preferredContentSize = CGSize(width: 400, height: 600) + return vc + } + + func updateUIViewController(_ uiViewController: AssetPickerViewController, context: Context) { + } + +} diff --git a/Tusker/Screens/Compose/ComposeAttachmentRow.swift b/Tusker/Screens/Compose/ComposeAttachmentRow.swift new file mode 100644 index 0000000000..759cd2ad8c --- /dev/null +++ b/Tusker/Screens/Compose/ComposeAttachmentRow.swift @@ -0,0 +1,206 @@ +// +// ComposeAttachmentRow.swift +// Tusker +// +// Created by Shadowfacts on 8/19/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Photos +import AVFoundation +import Vision + +struct ComposeAttachmentRow: View { + @ObservedObject var draft: Draft + @ObservedObject var attachment: CompositionAttachment + let heightChanged: (CGFloat) -> Void + + @EnvironmentObject var uiState: ComposeUIState + @State private var mode: Mode = .allowEntry + @State private var image: UIImage? = nil + @State private var imageContentMode: ContentMode = .fill + @State private var imageBackgroundColor: Color = .black + @State private var isShowingTextRecognitionFailedAlert = false + @State private var textRecognitionErrorMessage: String? = nil + + @Environment(\.colorScheme) private var colorScheme: ColorScheme + + var body: some View { + HStack(alignment: .center, spacing: 4) { + imageView + .frame(width: 80, height: 80) + .cornerRadius(8) + .contextMenu { + if case .drawing(_) = attachment.data { + Button(action: self.editDrawing) { + if #available(iOS 14.0, *) { + Label("Edit Drawing", systemImage: "hand.draw") + } else { + HStack { + Text("Edit Drawing") + Image(systemName: "hand.draw") + } + } + } + } else if attachment.data.type == .image { + Button(action: self.recognizeText) { + if #available(iOS 14.0, *) { + Label("Recognize Text", systemImage: "doc.text.viewfinder") + } else { + HStack { + Text("Recognize Text") + Image(systemName: "doc.text.viewfinder") + } + } + } + } + } + + switch mode { + case .allowEntry: + ComposeTextView(text: $attachment.attachmentDescription, placeholder: Text("Describe for the visually impaired…"), minHeight: 80) + .heightDidChange(self.heightChanged) + .backgroundColor(.clear) + .fontSize(17) + + case .recognizingText: + if #available(iOS 14.0, *) { + ProgressView() + } else { + ActivityIndicatorView() + } + } + + + // todo: find a way to make this button not activated when the list row is selected, see FB8595628 +// Button(action: self.removeAttachment) { +// Image(systemName: "xmark.circle.fill") +// .foregroundColor(.blue) +// } + } + .onAppear(perform: self.loadImage) + .onReceive(attachment.$attachmentDescription) { (newDesc) in + if newDesc.isEmpty { + uiState.attachmentsMissingDescriptions.insert(attachment.id) + } else { + uiState.attachmentsMissingDescriptions.remove(attachment.id) + } + } + .alert(isPresented: $isShowingTextRecognitionFailedAlert) { + Alert( + title: Text("Text Recognition Failed"), + message: Text(self.textRecognitionErrorMessage ?? ""), + dismissButton: .default(Text("OK")) + ) + } + } + + @ViewBuilder + private var imageView: some View { + if let image = image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: imageContentMode) + .background(imageBackgroundColor) + } else { + Image(systemName: placeholderImageName) + } + } + + private var placeholderImageName: String { + switch colorScheme { + case .dark: + return "photo.fill" + case .light: + return "photo" + @unknown default: + return "photo" + } + } + + private func loadImage() { + switch attachment.data { + case let .image(image): + self.image = image + case let .asset(asset): + let size = CGSize(width: 80, height: 80) + PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in + DispatchQueue.main.async { + self.image = image + } + } + case let .video(url): + let asset = AVURLAsset(url: url) + let imageGenerator = AVAssetImageGenerator(asset: asset) + if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) { + self.image = UIImage(cgImage: cgImage) + } + case let .drawing(drawing): + image = drawing.imageInLightMode(from: drawing.bounds) + imageContentMode = .fit + imageBackgroundColor = .white + } + } + + private func removeAttachment() { + draft.attachments.removeAll { $0.id == attachment.id } + } + + private func editDrawing() { + uiState.composeDrawingMode = .edit(id: attachment.id) + uiState.delegate?.presentComposeDrawing() + } + + private func recognizeText() { + mode = .recognizingText + + DispatchQueue.global(qos: .userInitiated).async { + self.attachment.data.getData { (data, mimeType) in + let handler = VNImageRequestHandler(data: data, options: [:]) + let request = VNRecognizeTextRequest { (request, error) in + DispatchQueue.main.async { + if let results = request.results as? [VNRecognizedTextObservation] { + var text = "" + for observation in results { + let result = observation.topCandidates(1).first! + text.append(result.string) + text.append("\n") + } + self.attachment.attachmentDescription = text + } + + self.mode = .allowEntry + } + } + request.recognitionLevel = .accurate + request.usesLanguageCorrection = true + DispatchQueue.global(qos: .userInitiated).async { + do { + try handler.perform([request]) + } catch { + // The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for. + guard (error as NSError).code != 1 else { return } + DispatchQueue.main.async { + self.mode = .allowEntry + self.isShowingTextRecognitionFailedAlert = true + self.textRecognitionErrorMessage = error.localizedDescription + } + } + } + } + } + } +} + +extension ComposeAttachmentRow { + enum Mode { + case allowEntry, recognizingText + } +} + +//struct ComposeAttachmentRow_Previews: PreviewProvider { +// static var previews: some View { +// ComposeAttachmentRow() +// } +//} diff --git a/Tusker/Screens/Compose/ComposeAttachmentsList.swift b/Tusker/Screens/Compose/ComposeAttachmentsList.swift new file mode 100644 index 0000000000..baad38c735 --- /dev/null +++ b/Tusker/Screens/Compose/ComposeAttachmentsList.swift @@ -0,0 +1,170 @@ +// +// ComposeAttachmentsList.swift +// Tusker +// +// Created by Shadowfacts on 8/19/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct ComposeAttachmentsList: View { + private let cellHeight: CGFloat = 80 + private let cellPadding: CGFloat = 12 + + @ObservedObject var draft: Draft + + @EnvironmentObject var mastodonController: MastodonController + @EnvironmentObject var uiState: ComposeUIState + @State var isShowingAssetPickerPopover = false + @State var isShowingCreateDrawing = false + @State var rowHeights = [UUID: CGFloat]() + + @Environment(\.colorScheme) var colorScheme: ColorScheme + @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? + + var body: some View { + List { + ForEach(draft.attachments) { (attachment) in + ComposeAttachmentRow( + draft: draft, + attachment: attachment + ) { (newHeight) in + // in case height changed callback is called after atachment is removed but before view hierarchy is updated + if draft.attachments.contains(where: { $0.id == attachment.id }) { + rowHeights[attachment.id] = newHeight + } + } + .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) + } + .onMove(perform: self.moveAttachments) + .onDelete(perform: self.deleteAttachments) + .conditionally(canAddAttachment) { + $0.onInsert(of: CompositionAttachment.readableTypeIdentifiersForItemProvider, perform: self.insertAttachments) + } + + Button(action: self.addAttachment) { + HStack { + addButtonImage + Text("Add image or video") + } + } + .foregroundColor(.blue) + .disabled(!canAddAttachment) + .frame(height: cellHeight) + .popover(isPresented: $isShowingAssetPickerPopover, content: self.assetPickerPopover) + .contextMenu { + Button(action: self.createDrawing) { + if #available(iOS 14.0, *) { + Label("Draw Something", systemImage: "hand.draw") + } else { + HStack { + Text("Draw Something") + Image(systemName: "hand.draw") + } + } + } + .disabled(!canAddAttachment) + } + } + .frame(height: totalListHeight) + .onAppear(perform: self.didAppear) + .onReceive(draft.$attachments, perform: self.attachmentsChanged) + } + + private var addButtonImage: Image { + let name: String + switch colorScheme { + case .dark: + name = "photo.fill" + case .light: + name = "photo" + @unknown default: + name = "photo" + } + return Image(systemName: name) + } + + private var canAddAttachment: Bool { + switch mastodonController.instance.instanceType { + case .pleroma: + return true + case .mastodon: + // todo: this technically allows invalid image/video combinations + return draft.attachments.count < 4 + } + } + + private var totalListHeight: CGFloat { + let totalRowHeights = rowHeights.values.reduce(0, +) + let totalPadding = CGFloat(draft.attachments.count) * cellPadding + let addButtonHeight = cellHeight + cellPadding + return totalRowHeights + totalPadding + addButtonHeight + } + + private func didAppear() { + let proxy = UITableView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self]) + // enable drag and drop to reorder on iPhone + proxy.dragInteractionEnabled = true + } + + private func attachmentsChanged(attachments: [CompositionAttachment]) { + var copy = rowHeights + for k in copy.keys where !attachments.contains(where: { k == $0.id }) { + copy.removeValue(forKey: k) + } + for attachment in attachments where !copy.keys.contains(attachment.id) { + copy[attachment.id] = cellHeight + } + self.rowHeights = copy + } + + private func assetPickerPopover() -> some View { + ComposeAssetPicker(draft: draft, delegate: uiState.delegate?.assetPickerDelegate) + .onDisappear { + self.isShowingAssetPickerPopover = false + } + .environment(\.colorScheme, .dark) + .edgesIgnoringSafeArea(.bottom) + } + + private func addAttachment() { + if horizontalSizeClass == .regular { + isShowingAssetPickerPopover = true + } else { + uiState.delegate?.presentAssetPickerSheet() + } + } + + private func moveAttachments(from source: IndexSet, to destination: Int) { + draft.attachments.move(fromOffsets: source, toOffset: destination) + } + + private func deleteAttachments(at indices: IndexSet) { + draft.attachments.remove(atOffsets: indices) + } + + private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) { + for provider in itemProviders where provider.canLoadObject(ofClass: CompositionAttachment.self) { + guard canAddAttachment else { break } + + provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in + guard let attachment = object as? CompositionAttachment else { return } + DispatchQueue.main.async { + self.draft.attachments.insert(attachment, at: offset) + } + } + } + } + + private func createDrawing() { + uiState.composeDrawingMode = .createNew + uiState.delegate?.presentComposeDrawing() + } +} + +//struct ComposeAttachmentsList_Previews: PreviewProvider { +// static var previews: some View { +// ComposeAttachmentsList() +// } +//} diff --git a/Tusker/Screens/Compose/ComposeAttachmentsViewController.swift b/Tusker/Screens/Compose/ComposeAttachmentsViewController.swift deleted file mode 100644 index d24b91e6e7..0000000000 --- a/Tusker/Screens/Compose/ComposeAttachmentsViewController.swift +++ /dev/null @@ -1,563 +0,0 @@ -// -// ComposeAttachmentsViewController.swift -// Tusker -// -// Created by Shadowfacts on 3/11/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import UIKit -import Pachyderm -import MobileCoreServices -import PencilKit -import Photos - -protocol ComposeAttachmentsViewControllerDelegate: class { - func composeSelectedAttachmentsDidChange() - func composeRequiresAttachmentDescriptionsDidChange() -} - -class ComposeAttachmentsViewController: UITableViewController { - - weak var mastodonController: MastodonController! - weak var delegate: ComposeAttachmentsViewControllerDelegate? - - private var heightConstraint: NSLayoutConstraint! - - var attachments: [CompositionAttachment] = [] { - didSet { - delegate?.composeSelectedAttachmentsDidChange() - delegate?.composeRequiresAttachmentDescriptionsDidChange() - updateAddAttachmentsButtonEnabled() - } - } - - var requiresAttachmentDescriptions: Bool { - if Preferences.shared.requireAttachmentDescriptions { - return attachments.contains { $0.attachmentDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - } else { - return false - } - } - - private var currentlyEditedDrawingIndex: Int? - - init(attachments: [CompositionAttachment], mastodonController: MastodonController) { - self.attachments = attachments - self.mastodonController = mastodonController - super.init(style: .plain) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 96 - - tableView.register(UINib(nibName: "AddAttachmentTableViewCell", bundle: .main), forCellReuseIdentifier: "addAttachment") - tableView.register(UINib(nibName: "ComposeAttachmentTableViewCell", bundle: .main), forCellReuseIdentifier: "composeAttachment") - - // you would think the table view could handle this itself, but no, using a constraint on the table view's contentLayoutGuide doesn't work - // add extra space, so when dropping items, the add attachment cell doesn't disappear - heightConstraint = tableView.heightAnchor.constraint(equalToConstant: tableView.contentSize.height + 80) - heightConstraint.isActive = true - - // prevents extra separator lines from appearing when the height of the table view is greater than the height of the content - tableView.tableFooterView = UIView() - - pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self) - - // enable dragging on iPhone to allow reordering - tableView.dragInteractionEnabled = true - tableView.dragDelegate = self - tableView.dropDelegate = self - - if mastodonController.instance == nil { - mastodonController.getOwnInstance { [weak self] (_) in - guard let self = self else { return } - DispatchQueue.main.async { - self.updateAddAttachmentsButtonEnabled() - } - } - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - updateHeightConstraint() - } - - func setAttachments(_ attachments: [CompositionAttachment]) { - tableView.performBatchUpdates({ - tableView.deleteRows(at: self.attachments.indices.map { IndexPath(row: $0, section: 0) }, with: .automatic) - self.attachments = attachments - tableView.insertRows(at: self.attachments.indices.map { IndexPath(row: $0, section: 0) }, with: .automatic) - }) - updateHeightConstraint() - delegate?.composeRequiresAttachmentDescriptionsDidChange() - } - - private func updateHeightConstraint() { - // add extra space, so when dropping items, the add attachment cell doesn't disappear - heightConstraint.constant = tableView.contentSize.height + 80 - } - - private func isAddAttachmentsButtonEnabled() -> Bool { - switch mastodonController.instance?.instanceType { - case nil: - return false - case .pleroma: - return true - case .mastodon: - return !attachments.contains(where: { $0.data.type == .video }) && attachments.count < 4 - } - } - - private func updateAddAttachmentsButtonEnabled() { - guard let cell = tableView.cellForRow(at: IndexPath(row: 0, section: 1)) as? AddAttachmentTableViewCell else { return } - cell.setEnabled(isAddAttachmentsButtonEnabled()) - } - - override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { - guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { - return false - } - - switch mastodonController.instance.instanceType { - case .pleroma: - return true - case .mastodon: - return itemProviders.count + attachments.count <= 4 - } - } - - override func paste(itemProviders: [NSItemProvider]) { - for provider in itemProviders { - provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in - if let error = error { - fatalError("Couldn't load image from NSItemProvider: \(error)") - } - guard let attachment = object as? CompositionAttachment else { - fatalError("Couldn't convert object from NSItemProvider to CompositionAttachment") - } - - DispatchQueue.main.async { - self.attachments.append(attachment) - self.tableView.insertRows(at: [IndexPath(row: self.attachments.count - 1, section: 0)], with: .automatic) - self.updateHeightConstraint() - } - } - } - } - - func presentComposeDrawingViewController(editingAttachmentAt attachmentIndex: Int? = nil) { - let drawingVC: ComposeDrawingViewController - - if let index = attachmentIndex, - case let .drawing(drawing) = attachments[index].data { - drawingVC = ComposeDrawingViewController(editing: drawing) - currentlyEditedDrawingIndex = index - } else { - drawingVC = ComposeDrawingViewController() - } - - drawingVC.delegate = self - let nav = UINavigationController(rootViewController: drawingVC) - nav.modalPresentationStyle = .fullScreen - present(nav, animated: true) - } - - func uploadAll(stepProgress: @escaping () -> Void, completion: @escaping (_ success: Bool, _ uploadedAttachments: [Attachment]) -> Void) { - let group = DispatchGroup() - - var anyFailed = false - var uploadedAttachments: [Result?] = [] - - for (index, compAttachment) in attachments.enumerated() { - group.enter() - - uploadedAttachments.append(nil) - - compAttachment.data.getData { (data, mimeType) in - stepProgress() - - let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file") - let request = Client.upload(attachment: formAttachment, description: compAttachment.attachmentDescription) - self.mastodonController.run(request) { (response) in - switch response { - case let .failure(error): - uploadedAttachments[index] = .failure(error) - anyFailed = true - case let .success(attachment, _): - uploadedAttachments[index] = .success(attachment) - } - - stepProgress() - group.leave() - } - } - } - - group.notify(queue: .main) { - if anyFailed { - let errors: [(Int, Error)] = uploadedAttachments.enumerated().compactMap { (index, result) in - switch result { - case let .failure(error): - return (index, error) - default: - return nil - } - - } - let title: String - var message: String - if errors.count == 1 { - title = NSLocalizedString("Could not upload attachment", comment: "single attachment upload failed alert title") - message = errors[0].1.localizedDescription - } else { - title = NSLocalizedString("Could not upload the following attachments", comment: "multiple attachment upload failures alert title") - message = "" - for (index, error) in errors { - message.append("Attachment \(index + 1): \(error.localizedDescription)") - } - } - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: { (_) in - completion(false, []) - })) - } else { - let uploadedAttachments: [Attachment] = uploadedAttachments.compactMap { - switch $0 { - case let .success(attachment): - return attachment - default: - return nil - } - } - completion(true, uploadedAttachments) - } - } - } - - // MARK: Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - return 2 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch section { - case 0: - return attachments.count - case 1: - return 1 - default: - fatalError("invalid section \(section)") - } - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - switch indexPath.section { - case 0: - let attachment = attachments[indexPath.row] - let cell = tableView.dequeueReusableCell(withIdentifier: "composeAttachment", for: indexPath) as! ComposeAttachmentTableViewCell - cell.delegate = self - cell.updateUI(for: attachment) - cell.setEnabled(true) - return cell - case 1: - let cell = tableView.dequeueReusableCell(withIdentifier: "addAttachment", for: indexPath) as! AddAttachmentTableViewCell - cell.setEnabled(isAddAttachmentsButtonEnabled()) - return cell - default: - fatalError("invalid section \(indexPath.section)") - } - } - - override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { - guard sourceIndexPath != destinationIndexPath, sourceIndexPath.section == 0, destinationIndexPath.section == 0 else { return } - - attachments.insert(attachments.remove(at: sourceIndexPath.row), at: destinationIndexPath.row) - } - - // MARK: Table view delegate - - override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { - if indexPath.section == 1, isAddAttachmentsButtonEnabled() { - return indexPath - } - return nil - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - - if indexPath.section == 1 { - addAttachmentPressed() - } - } - - override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - if indexPath.section == 0 { - let attachment = attachments[indexPath.row] - // cast to NSIndexPath because identifier needs to conform to NSCopying - return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { () -> UIViewController? in - return AssetPreviewViewController(attachment: attachment.data) - }) { (_) -> UIMenu? in - var actions = [UIAction]() - - switch attachment.data { - case .drawing(_): - actions.append(UIAction(title: "Edit Drawing", image: UIImage(systemName: "hand.draw"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in - self.presentComposeDrawingViewController(editingAttachmentAt: indexPath.row) - })) - case .asset(_), .image(_): - if attachment.data.type == .image, - let cell = tableView.cellForRow(at: indexPath) as? ComposeAttachmentTableViewCell { - - let title = NSLocalizedString("Recognize Text", comment: "recognize image attachment text menu item title") - actions.append(UIAction(title: title, image: UIImage(systemName: "doc.text.viewfinder"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in - cell.recognizeTextFromImage() - })) - } - default: - break - } - - if actions.isEmpty { - return nil - } else { - return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions) - } - } - } else if indexPath.section == 1 { - guard isAddAttachmentsButtonEnabled() else { - return nil - } - // show context menu for drawing/file uploads - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) -> UIMenu? in - return UIMenu(title: "", image: nil, identifier: nil, options: [], children: [ - UIAction(title: "Draw Something", image: UIImage(systemName: "hand.draw"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in - self.presentComposeDrawingViewController() - }) - ]) - } - } else { - return nil - } - } - - private func targetedPreview(forConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - if let indexPath = (configuration.identifier as? NSIndexPath) as IndexPath?, - indexPath.section == 0, - let cell = tableView.cellForRow(at: indexPath) as? ComposeAttachmentTableViewCell { - let parameters = UIPreviewParameters() - parameters.backgroundColor = .black - return UITargetedPreview(view: cell.assetImageView, parameters: parameters) - } else { - return nil - } - } - - override func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - return targetedPreview(forConfiguration: configuration) - } - - override func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - return targetedPreview(forConfiguration: configuration) - } - - // MARK: Interaction - - func addAttachmentPressed() { - PHPhotoLibrary.requestAuthorization { (status) in - guard status == .authorized else { return } - DispatchQueue.main.async { - if self.traitCollection.horizontalSizeClass == .compact { - let sheetContainer = AssetPickerSheetContainerViewController() - sheetContainer.assetPicker.assetPickerDelegate = self - self.present(sheetContainer, animated: true) - } else { - let picker = AssetPickerViewController() - picker.assetPickerDelegate = self - picker.overrideUserInterfaceStyle = .dark - picker.modalPresentationStyle = .popover - self.present(picker, animated: true) - if let presentationController = picker.presentationController as? UIPopoverPresentationController { - presentationController.sourceView = self.tableView.cellForRow(at: IndexPath(row: 0, section: 1)) - } - } - } - } - } - -} - -extension ComposeAttachmentsViewController: UITableViewDragDelegate { - func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { - guard indexPath.section == 0 else { return [] } - - let attachment = attachments[indexPath.row] - let provider = NSItemProvider(object: attachment) - let dragItem = UIDragItem(itemProvider: provider) - dragItem.localObject = attachment - return [dragItem] - } - - func tableView(_ tableView: UITableView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] { - guard indexPath.section == 0 else { return [] } - - let attachment = attachments[indexPath.row] - let provider = NSItemProvider(object: attachment) - let dragItem = UIDragItem(itemProvider: provider) - dragItem.localObject = attachment - return [dragItem] - } - - func tableView(_ tableView: UITableView, dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? { - guard indexPath.section == 0 else { return nil } - - let cell = tableView.cellForRow(at: indexPath) as! ComposeAttachmentTableViewCell - let rect = cell.convert(cell.assetImageView.bounds, from: cell.assetImageView) - let path = UIBezierPath(roundedRect: rect, cornerRadius: cell.assetImageView.layer.cornerRadius) - let params = UIDragPreviewParameters() - params.visiblePath = path - return params - } -} - -extension ComposeAttachmentsViewController: UITableViewDropDelegate { - func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool { - return session.canLoadObjects(ofClass: CompositionAttachment.self) - } - - func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal { - // if items were dragged out of ourself, then the items are only being moved - if tableView.hasActiveDrag { - // todo: should moving multiple items actually be prohibited? - if session.items.count > 1 { - return UITableViewDropProposal(operation: .cancel) - } else { - return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) - } - } else { - return UITableViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath) - } - } - - func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) { - let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(row: attachments.count, section: 0) - - // we don't need to handle local items here, when the .move operation is used returned from the tableView(_:dropSessionDidUpdate:withDestinationIndexPath:) method, - // the table view will handle animating and call the normal data source tableView(_:moveRowAt:to:) - - for (index, item) in coordinator.items.enumerated() { - let provider = item.dragItem.itemProvider - - if provider.canLoadObject(ofClass: CompositionAttachment.self) { - let indexPath = IndexPath(row: destinationIndexPath.row + index, section: 0) - let placeholder = UITableViewDropPlaceholder(insertionIndexPath: indexPath, reuseIdentifier: "composeAttachment", rowHeight: 96) - placeholder.cellUpdateHandler = { (cell) in - let cell = cell as! ComposeAttachmentTableViewCell - cell.setEnabled(false) - } - let placeholderContext = coordinator.drop(item.dragItem, to: placeholder) - - provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in - DispatchQueue.main.async { - if let attachment = object as? CompositionAttachment { - placeholderContext.commitInsertion { (insertionIndexPath) in - self.attachments.insert(attachment, at: insertionIndexPath.row) - } - } else { - placeholderContext.deletePlaceholder() - } - } - } - } - } - - updateHeightConstraint() - } -} - -extension ComposeAttachmentsViewController: AssetPickerViewControllerDelegate { - func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool { - switch mastodonController.instance.instanceType { - case .pleroma: - return true - case .mastodon: - if (type == .video && attachments.count > 0) || - attachments.contains(where: { $0.data.type == .video }) || - assetPicker.currentCollectionSelectedAssets.contains(where: { $0.type == .video }) { - return false - } - return attachments.count + assetPicker.currentCollectionSelectedAssets.count < 4 - } - } - - func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData]) { - let attachments = attachments.map { - CompositionAttachment(data: $0) - } - let indexPaths = attachments.indices.map { IndexPath(row: $0 + self.attachments.count, section: 0) } - self.attachments.append(contentsOf: attachments) - tableView.insertRows(at: indexPaths, with: .automatic) - updateHeightConstraint() - } -} - -extension ComposeAttachmentsViewController: ComposeAttachmentTableViewCellDelegate { - func composeAttachment(_ cell: ComposeAttachmentTableViewCell, present viewController: UIViewController, animated: Bool) { - self.present(viewController, animated: animated) - } - - func removeAttachment(_ cell: ComposeAttachmentTableViewCell) { - guard let indexPath = tableView.indexPath(for: cell) else { return } - attachments.remove(at: indexPath.row) - tableView.performBatchUpdates({ - tableView.deleteRows(at: [indexPath], with: .automatic) - }, completion: { (_) in - // when removing cells, we don't trigger the container height update until after the animation has completed - // otherwise, during the animation, the height is too short and the last row briefly disappears - self.updateHeightConstraint() - }) - } - - func attachmentDescriptionChanged(_ cell: ComposeAttachmentTableViewCell) { - delegate?.composeRequiresAttachmentDescriptionsDidChange() - } - - func composeAttachmentDescriptionHeightChanged(_ cell: ComposeAttachmentTableViewCell) { - tableView.performBatchUpdates(nil) { (_) in - self.updateHeightConstraint() - } - } -} - -extension ComposeAttachmentsViewController: ComposeDrawingViewControllerDelegate { - func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) { - dismiss(animated: true) - currentlyEditedDrawingIndex = nil - } - - func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) { - let newAttachment = CompositionAttachment(data: .drawing(drawing)) - - if let currentlyEditedDrawingIndex = currentlyEditedDrawingIndex { - attachments[currentlyEditedDrawingIndex] = newAttachment - tableView.reloadRows(at: [IndexPath(row: currentlyEditedDrawingIndex, section: 0)], with: .automatic) - } else { - attachments.append(newAttachment) - tableView.insertRows(at: [IndexPath(row: self.attachments.count - 1, section: 0)], with: .automatic) - updateHeightConstraint() - } - - dismiss(animated: true) - currentlyEditedDrawingIndex = nil - } -} diff --git a/Tusker/Screens/Compose/ComposeAvatarImageView.swift b/Tusker/Screens/Compose/ComposeAvatarImageView.swift new file mode 100644 index 0000000000..f0d466d409 --- /dev/null +++ b/Tusker/Screens/Compose/ComposeAvatarImageView.swift @@ -0,0 +1,61 @@ +// +// ComposeAvatarImageView.swift +// Tusker +// +// Created by Shadowfacts on 8/18/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct ComposeAvatarImageView: View { + let url: URL + @State var request: ImageCache.Request? = nil + @State var avatarImage: UIImage? = nil + @ObservedObject var preferences = Preferences.shared + + var body: some View { + image + .resizable() + .frame(width: 50, height: 50) + .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50) + .onAppear(perform: self.loadImage) + .onDisappear(perform: self.cancelRequest) + } + + private var image: Image { + if let avatarImage = avatarImage { + return Image(uiImage: avatarImage) + } else { + let imageName: String + switch preferences.avatarStyle { + case .circle: + imageName = "person.crop.circle" + case .roundRect: + imageName = "person.crop.square" + } + return Image(systemName: imageName) + } + } + + private func loadImage() { + request = ImageCache.avatars.get(url) { (data) in + DispatchQueue.main.async { + self.request = nil + if let data = data, let image = UIImage(data: data) { + self.avatarImage = image + } + } + } + } + + private func cancelRequest() { + request?.cancel() + } +} + +struct ComposeAvatarImageView_Previews: PreviewProvider { + static var previews: some View { + ComposeAvatarImageView(url: URL(string: "https://social.shadowfacts.net/media/4b481afc591a8f3d11d0f5732e5cb320422dec72d7f223ebb5f35d5d0e821a9c.png")!) + } +} diff --git a/Tusker/Screens/Compose/ComposeContainerView.swift b/Tusker/Screens/Compose/ComposeContainerView.swift new file mode 100644 index 0000000000..556ba6d3f4 --- /dev/null +++ b/Tusker/Screens/Compose/ComposeContainerView.swift @@ -0,0 +1,41 @@ +// +// ComposeContainerView.swift +// Tusker +// +// Created by Shadowfacts on 8/24/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Combine + +struct ComposeContainerView: View { + let mastodonController: MastodonController + let vcWidthSubject: PassthroughSubject + @ObservedObject var uiState: ComposeUIState + + init( + mastodonController: MastodonController, + vcWidthSubject: PassthroughSubject, + uiState: ComposeUIState + ) { + self.mastodonController = mastodonController + self.vcWidthSubject = vcWidthSubject + self.uiState = uiState + } + + var body: some View { + ComposeView( + draft: uiState.draft, + vcWidthSubject: vcWidthSubject + ) + .environmentObject(mastodonController) + .environmentObject(uiState) + } +} + +//struct ComposeContainerView_Previews: PreviewProvider { +// static var previews: some View { +// ComposeContainerView() +// } +//} diff --git a/Tusker/Screens/Compose/ComposeCurrentAccount.swift b/Tusker/Screens/Compose/ComposeCurrentAccount.swift new file mode 100644 index 0000000000..1bc0b8db1a --- /dev/null +++ b/Tusker/Screens/Compose/ComposeCurrentAccount.swift @@ -0,0 +1,40 @@ +// +// ComposeCurrentAccount.swift +// Tusker +// +// Created by Shadowfacts on 8/18/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Pachyderm + +struct ComposeCurrentAccount: View { + @EnvironmentObject var mastodonController: MastodonController + + var account: Account { + mastodonController.account! + } + + var body: some View { + HStack(alignment: .top) { + ComposeAvatarImageView(url: account.avatar) + VStack(alignment: .leading) { + Text(verbatim: account.displayName) + .font(.system(size: 20, weight: .semibold)) + .lineLimit(1) + + Text(verbatim: "@\(account.acct)") + .font(.system(size: 17, weight: .light)) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } +} + +//struct ComposeCurrentAccount_Previews: PreviewProvider { +// static var previews: some View { +// ComposeCurrentAccount(account: ) +// } +//} diff --git a/Tusker/Screens/Compose/ComposeDrawingViewController.swift b/Tusker/Screens/Compose/ComposeDrawingViewController.swift index b9058a758e..9396040582 100644 --- a/Tusker/Screens/Compose/ComposeDrawingViewController.swift +++ b/Tusker/Screens/Compose/ComposeDrawingViewController.swift @@ -175,9 +175,3 @@ extension ComposeDrawingViewController: PKToolPickerObserver { updateLayout(for: toolPicker) } } - -extension ComposeDrawingViewController: UIGestureRecognizerDelegate { - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - return otherGestureRecognizer == canvasView.drawingGestureRecognizer - } -} diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift new file mode 100644 index 0000000000..bd717ac6d6 --- /dev/null +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -0,0 +1,370 @@ +// +// ComposeHostingController.swift +// Tusker +// +// Created by Shadowfacts on 8/22/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Combine +import Pachyderm +import PencilKit + +class ComposeHostingController: UIHostingController { + + let mastodonController: MastodonController + + let uiState: ComposeUIState + // storing the width in the UI state and having SwiftUI listen to it via @ObservedObject doesn't work + // it ends up spinning forever + let widthSubject = PassthroughSubject() + + var draft: Draft { uiState.draft } + + private var cancellables = [AnyCancellable]() + + private var mainToolbar: UIToolbar! + private var inputAccessoryToolbar: UIToolbar! + private var visibilityBarButtonItems = [UIBarButtonItem]() + + override var inputAccessoryView: UIView? { inputAccessoryToolbar } + + init(draft: Draft? = nil, mastodonController: MastodonController) { + self.mastodonController = mastodonController + let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id) + DraftsManager.shared.add(realDraft) + + self.uiState = ComposeUIState(draft: realDraft) + + // we need our own environment object wrapper so that we can set the mastodon controller as an + // environment object and setup the draft change listener while still having a concrete type + // to use as the UIHostingController type parameter + let container = ComposeContainerView( + mastodonController: mastodonController, + vcWidthSubject: widthSubject, + uiState: uiState + ) + super.init(rootView: container) + + self.uiState.delegate = self + + // main toolbar is shown at the bottom of the screen, the input accessory is attached to the keyboard while editing + mainToolbar = createToolbar() + inputAccessoryToolbar = createToolbar() + + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil) + + // add the height of the toolbar itself to the bottom of the safe area so content inside SwiftUI ScrollView doesn't underflow it + additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: 44, right: 0) + + pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self) + + userActivity = UserActivityManager.newPostActivity() + + self.uiState.$draft + .flatMap(\.$visibility) + .sink(receiveValue: self.visibilityChanged) + .store(in: &cancellables) + + self.uiState.$draft + .flatMap(\.objectWillChange) + .debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility)) + .sink { + DraftsManager.save() + } + .store(in: &cancellables) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + widthSubject.send(view.bounds.width) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // can't do this in viewDidLoad because viewDidLoad isn't called for UIHostingController + if mainToolbar.superview == nil { + view.addSubview(mainToolbar) + NSLayoutConstraint.activate([ + mainToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + mainToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + // use the top anchor of the toolbar so our additionalSafeAreaInsets (which has the bottom as the toolbar height) don't affect it + mainToolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ]) + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if !draft.hasContent { + DraftsManager.shared.remove(draft) + } + DraftsManager.save() + } + + private func createToolbar() -> UIToolbar { + let toolbar = UIToolbar() + toolbar.translatesAutoresizingMaskIntoConstraints = false + + let visibilityItem = UIBarButtonItem(image: UIImage(systemName: draft.visibility.imageName), style: .plain, target: self, action: #selector(visibilityButtonPressed)) + visibilityItem.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName) + visibilityBarButtonItems.append(visibilityItem) + + toolbar.items = [ + UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)), + visibilityItem, + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), + UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPresed)) + ] + return toolbar + } + + + @objc private func keyboardWillShow(_ notification: Foundation.Notification) { + keyboardWillShow(accessoryView: inputAccessoryToolbar, notification: notification) + } + + func keyboardWillShow(accessoryView: UIView, notification: Foundation.Notification) { + mainToolbar.isHidden = true + + accessoryView.alpha = 1 + accessoryView.isHidden = false + } + + @objc private func keyboardWillHide(_ notification: Foundation.Notification) { + keyboardWillHide(accessoryView: inputAccessoryToolbar, notification: notification) + } + + func keyboardWillHide(accessoryView: UIView, notification: Foundation.Notification) { + mainToolbar.isHidden = false + + let userInfo = notification.userInfo! + let durationObj = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as! NSNumber + let duration = TimeInterval(durationObj.doubleValue) + let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as! NSNumber + let curve = UIView.AnimationCurve(rawValue: curveValue.intValue)! + let curveOption: UIView.AnimationOptions + switch curve { + case .easeInOut: + curveOption = .curveEaseInOut + case .easeIn: + curveOption = .curveEaseIn + case .easeOut: + curveOption = .curveEaseOut + case .linear: + curveOption = .curveLinear + @unknown default: + curveOption = .curveLinear + } + UIView.animate(withDuration: duration, delay: 0, options: curveOption) { + accessoryView.alpha = 0 + } completion: { (finished) in + accessoryView.alpha = 1 + } + } + + @objc private func keyboardDidHide(_ notification: Foundation.Notification) { + keyboardDidHide(accessoryView: inputAccessoryToolbar, notification: notification) + } + + func keyboardDidHide(accessoryView: UIView, notification: Foundation.Notification) { + accessoryView.isHidden = true + } + + private func visibilityChanged(_ newVisibility: Status.Visibility) { + for item in visibilityBarButtonItems { + item.image = UIImage(systemName: newVisibility.imageName) + item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName) + } + } + + override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { + guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false } + switch mastodonController.instance.instanceType { + case .pleroma: + return true + case .mastodon: + // todo: this technically allows invalid video/image combinations + return itemProviders.count + draft.attachments.count <= 4 + } + } + + override func paste(itemProviders: [NSItemProvider]) { + for provider in itemProviders where provider.canLoadObject(ofClass: CompositionAttachment.self) { + provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in + guard let attachment = object as? CompositionAttachment else { return } + DispatchQueue.main.async { + self.draft.attachments.append(attachment) + } + } + } + } + + // MARK: Interaction + + @objc func cwButtonPressed() { + draft.contentWarningEnabled = !draft.contentWarningEnabled + } + + @objc func visibilityButtonPressed(_ sender: UIBarButtonItem) { + let alertController = UIAlertController(currentVisibility: draft.visibility) { (visibility) in + guard let visibility = visibility else { return } + self.draft.visibility = visibility + } + alertController.popoverPresentationController?.barButtonItem = sender + present(alertController, animated: true) + } + + @objc func draftsButtonPresed() { + let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!, exclude: draft) + draftsVC.delegate = self + present(UINavigationController(rootViewController: draftsVC), animated: true) + } + +} + +extension ComposeHostingController { + struct EnvironmentWrappingView: View { + let content: Content + let environmentObject: EnvironmentObject + + var body: some View { + content.environmentObject(environmentObject) + } + } +} + +extension ComposeHostingController: ComposeUIStateDelegate { + var assetPickerDelegate: AssetPickerViewControllerDelegate? { self } + + func dismissCompose() { + self.dismiss(animated: true) + } + + func presentAssetPickerSheet() { + let sheetContainer = AssetPickerSheetContainerViewController() + sheetContainer.assetPicker.assetPickerDelegate = self + self.present(sheetContainer, animated: true) + } + + func presentComposeDrawing() { + let drawing: PKDrawing + + if case let .edit(id) = uiState.composeDrawingMode, + let attachment = draft.attachments.first(where: { $0.id == id }), + case let .drawing(existingDrawing) = attachment.data { + drawing = existingDrawing + } else { + drawing = PKDrawing() + } + + let drawingVC = ComposeDrawingViewController(editing: drawing) + drawingVC.delegate = self + let nav = UINavigationController(rootViewController: drawingVC) + nav.modalPresentationStyle = .fullScreen + present(nav, animated: true) + } +} + +extension ComposeHostingController: AssetPickerViewControllerDelegate { + func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool { + switch mastodonController.instance.instanceType { + case .pleroma: + return true + case .mastodon: + if (type == .video && draft.attachments.count > 0) || + draft.attachments.contains(where: { $0.data.type == .video }) || + assetPicker.currentCollectionSelectedAssets.contains(where: { $0.type == .video }) { + return false + } + return draft.attachments.count + assetPicker.currentCollectionSelectedAssets.count < 4 + } + } + + func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData]) { + let attachments = attachments.map { + CompositionAttachment(data: $0) + } + draft.attachments.append(contentsOf: attachments) + } +} + +extension ComposeHostingController: DraftsTableViewControllerDelegate { + func draftSelectionCanceled() { + } + + func shouldSelectDraft(_ draft: Draft, completion: @escaping (Bool) -> Void) { + if draft.inReplyToID != self.draft.inReplyToID, + self.draft.hasContent { + let alertController = UIAlertController(title: "Different Reply", message: "The selected draft is a reply to a different status, do you wish to use it?", preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in + completion(false) + })) + alertController.addAction(UIAlertAction(title: "Restore Draft", style: .default, handler: { (_) in + completion(true) + })) + // we can't present the laert ourselves since the compose VC is already presenting the draft selector + // but presenting on the presented view controller seems hacky, is there a better way to do this? + presentedViewController!.present(alertController, animated: true) + } else { + completion(true) + } + } + + func draftSelected(_ draft: Draft) { + if self.draft.hasContent { + DraftsManager.save() + } else { + DraftsManager.shared.remove(self.draft) + } + + uiState.draft = draft + } + + func draftSelectionCompleted() { + } +} + +extension ComposeHostingController: UIAdaptivePresentationControllerDelegate { + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + return Preferences.shared.automaticallySaveDrafts || !draft.hasContent + } + + func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { + uiState.isShowingSaveDraftSheet = true + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + DraftsManager.save() + } +} + +extension ComposeHostingController: ComposeDrawingViewControllerDelegate { + func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) { + dismiss(animated: true) + } + + func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) { + switch uiState.composeDrawingMode { + case nil, .createNew: + let attachment = CompositionAttachment(data: .drawing(drawing)) + draft.attachments.append(attachment) + + case let .edit(id): + let existing = draft.attachments.first { $0.id == id } + existing?.data = .drawing(drawing) + } + + dismiss(animated: true) + } +} diff --git a/Tusker/Screens/Compose/ComposeHostingViewController.swift b/Tusker/Screens/Compose/ComposeHostingViewController.swift new file mode 100644 index 0000000000..1ac4e18414 --- /dev/null +++ b/Tusker/Screens/Compose/ComposeHostingViewController.swift @@ -0,0 +1,14 @@ +// +// ComposeHostingController.swift +// Tusker +// +// Created by Shadowfacts on 8/18/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI +import UIKit + +class ComposeHostingController: UIHostingController { + +} diff --git a/Tusker/Screens/Compose/ComposeReplyContentView.swift b/Tusker/Screens/Compose/ComposeReplyContentView.swift new file mode 100644 index 0000000000..1ff458f257 --- /dev/null +++ b/Tusker/Screens/Compose/ComposeReplyContentView.swift @@ -0,0 +1,48 @@ +// +// ComposeReplyContentView.swift +// Tusker +// +// Created by Shadowfacts on 8/22/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct ComposeReplyContentView: UIViewRepresentable { + typealias UIViewType = ComposeReplyContentTextView + + let status: StatusMO + let maxWidth: CGFloat + + @EnvironmentObject var mastodonController: MastodonController + + func makeUIView(context: Context) -> ComposeReplyContentTextView { + let view = ComposeReplyContentTextView() + view.overrideMastodonController = mastodonController + view.setTextFrom(status: status) + view.isScrollEnabled = false + view.isUserInteractionEnabled = false + view.backgroundColor = .clear + view.maxWidth = maxWidth + return view + } + + func updateUIView(_ uiView: ComposeReplyContentTextView, context: Context) { + uiView.constraint.constant = maxWidth + } +} + +class ComposeReplyContentTextView: StatusContentTextView { + + var maxWidth: CGFloat! + var constraint: NSLayoutConstraint! + + override func didMoveToSuperview() { + super.didMoveToSuperview() + + translatesAutoresizingMaskIntoConstraints = false + constraint = widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth) + constraint.isActive = true + } + +} diff --git a/Tusker/Screens/Compose/ComposeReplyView.swift b/Tusker/Screens/Compose/ComposeReplyView.swift new file mode 100644 index 0000000000..06d3316098 --- /dev/null +++ b/Tusker/Screens/Compose/ComposeReplyView.swift @@ -0,0 +1,60 @@ +// +// ComposeReplyView.swift +// Tusker +// +// Created by Shadowfacts on 8/22/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct ComposeReplyView: View { + let status: StatusMO + let maxWidth: CGFloat + let stackPadding: CGFloat + + private let horizSpacing: CGFloat = 8 + + var body: some View { + HStack(alignment: .top, spacing: horizSpacing) { + GeometryReader(content: self.replyAvatarImage) + .frame(width: 50) + + VStack(alignment: .leading, spacing: 0) { + HStack { + Text(verbatim: status.account.displayName) + .font(.system(size: 17, weight: .semibold)) + .lineLimit(1) + .layoutPriority(1) + + Text(verbatim: "@\(status.account.acct)") + .font(.system(size: 17, weight: .light)) + .foregroundColor(.secondary) + .lineLimit(1) + + Spacer() + } + + ComposeReplyContentView(status: status, maxWidth: maxWidth - 50 - horizSpacing + 4) + .offset(x: -4, y: -8) + .padding(.bottom, -8) + } + .frame(minHeight: 50 + 8) + } + .padding(.bottom, -8) + } + + private func replyAvatarImage(geometry: GeometryProxy) -> some View { + let scrollOffset = geometry.frame(in: .named("outer")).minY - stackPadding + let offset = min(max(-scrollOffset, 0), geometry.size.height - 50 - 8) + return ComposeAvatarImageView(url: status.account.avatar) + .offset(x: 0, y: offset) + } + +} + +//struct ComposeReplyView_Previews: PreviewProvider { +// static var previews: some View { +// ComposeReplyView() +// } +//} diff --git a/Tusker/Screens/Compose/ComposeTextView.swift b/Tusker/Screens/Compose/ComposeTextView.swift new file mode 100644 index 0000000000..306907c85e --- /dev/null +++ b/Tusker/Screens/Compose/ComposeTextView.swift @@ -0,0 +1,125 @@ +// +// ComposeTextView.swift +// Tusker +// +// Created by Shadowfacts on 8/18/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct ComposeTextView: View { + @Binding private var text: String + private let placeholder: Text? + private let minHeight: CGFloat + + private var heightDidChange: ((CGFloat) -> Void)? + private var backgroundColor = UIColor.secondarySystemBackground + private var fontSize: CGFloat = 20 + + @State private var height: CGFloat? + + init(text: Binding, placeholder: Text?, minHeight: CGFloat = 150) { + self._text = text + self.placeholder = placeholder + self.minHeight = minHeight + } + + var body: some View { + ZStack(alignment: .topLeading) { + WrappedTextView( + text: $text, + textDidChange: self.textDidChange, + backgroundColor: backgroundColor, + font: .systemFont(ofSize: fontSize) + ) + .frame(height: height ?? minHeight) + + if text.isEmpty, let placeholder = placeholder { + placeholder + .font(.system(size: fontSize)) + .foregroundColor(.secondary) + .offset(x: 4, y: 8) + } + } + } + + private func textDidChange(textView: UITextView) { + height = max(minHeight, textView.contentSize.height) + heightDidChange?(height!) + } + + func heightDidChange(_ callback: @escaping (CGFloat) -> Void) -> Self { + var copy = self + copy.heightDidChange = callback + return copy + } + + func backgroundColor(_ color: UIColor) -> Self { + var copy = self + copy.backgroundColor = color + return copy + } + + func fontSize(_ size: CGFloat) -> Self { + var copy = self + copy.fontSize = size + return copy + } +} + +struct WrappedTextView: UIViewRepresentable { + typealias UIViewType = UITextView + + @Binding var text: String + var textDidChange: ((UITextView) -> Void)? + var backgroundColor = UIColor.secondarySystemBackground + var font = UIFont.systemFont(ofSize: 20) + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.delegate = context.coordinator + textView.isEditable = true + textView.backgroundColor = backgroundColor + textView.font = font + textView.textContainer.lineBreakMode = .byWordWrapping + return textView + } + + func updateUIView(_ uiView: UITextView, context: Context) { + uiView.text = text + context.coordinator.text = $text + context.coordinator.didChange = textDidChange + // wait until the next runloop iteration so that SwiftUI view updates have finished and + // the text view knows its new content size + DispatchQueue.main.async { + self.textDidChange?(uiView) + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(text: $text, didChange: textDidChange) + } + + class Coordinator: NSObject, UITextViewDelegate { + var text: Binding + var didChange: ((UITextView) -> Void)? + + init(text: Binding, didChange: ((UITextView) -> Void)?) { + self.text = text + self.didChange = didChange + } + + func textViewDidChange(_ textView: UITextView) { + text.wrappedValue = textView.text + didChange?(textView) + } + } +} + + +//struct ComposeTextView_Previews: PreviewProvider { +// static var previews: some View { +// ComposeTextView() +// } +//} diff --git a/Tusker/Screens/Compose/ComposeUIState.swift b/Tusker/Screens/Compose/ComposeUIState.swift new file mode 100644 index 0000000000..0b65e2ef10 --- /dev/null +++ b/Tusker/Screens/Compose/ComposeUIState.swift @@ -0,0 +1,44 @@ +// +// ComposeUIState.swift +// Tusker +// +// Created by Shadowfacts on 8/24/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +protocol ComposeUIStateDelegate: class { + var assetPickerDelegate: AssetPickerViewControllerDelegate? { get } + + func dismissCompose() + func presentAssetPickerSheet() + func presentComposeDrawing() + + func keyboardWillShow(accessoryView: UIView, notification: Notification) + func keyboardWillHide(accessoryView: UIView, notification: Notification) + func keyboardDidHide(accessoryView: UIView, notification: Notification) +} + +class ComposeUIState: ObservableObject { + + weak var delegate: ComposeUIStateDelegate? + + @Published var draft: Draft + @Published var isShowingSaveDraftSheet = false + @Published var attachmentsMissingDescriptions = Set() + + var composeDrawingMode: ComposeDrawingMode? + + init(draft: Draft) { + self.draft = draft + } + +} + +extension ComposeUIState { + enum ComposeDrawingMode { + case createNew + case edit(id: UUID) + } +} diff --git a/Tusker/Screens/Compose/ComposeView.swift b/Tusker/Screens/Compose/ComposeView.swift new file mode 100644 index 0000000000..2116c64ce3 --- /dev/null +++ b/Tusker/Screens/Compose/ComposeView.swift @@ -0,0 +1,292 @@ +// +// ComposeView.swift +// Tusker +// +// Created by Shadowfacts on 8/18/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Pachyderm +import Combine + +struct ComposeView: View { + @ObservedObject var draft: Draft + let vcWidthSubject: PassthroughSubject + + @EnvironmentObject var mastodonController: MastodonController + @State var viewControllerWidth: CGFloat = 0 + @EnvironmentObject var uiState: ComposeUIState + @State var isPosting = false + @State var postProgress: Double = 0 + @State var postTotalProgress: Double = 0 + @State var isShowingPostErrorAlert = false + @State var postError: Error? + + private let stackPadding: CGFloat = 8 + + init( + draft: Draft, + vcWidthSubject: PassthroughSubject + ) { + self.draft = draft + self.vcWidthSubject = vcWidthSubject + } + + var charactersRemaining: Int { + let limit = mastodonController.instance.maxStatusCharacters ?? 500 + let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0 + return limit - (cwCount + CharacterCounter.count(text: draft.text)) + } + + var requiresAttachmentDescriptions: Bool { + guard Preferences.shared.requireAttachmentDescriptions else { return false } + let attachmentIds = draft.attachments.map(\.id) + return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) } + } + + var postButtonEnabled: Bool { + draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions + } + + var body: some View { + // the pre-iOS 14 API does not result in the correct pointer interactions for nav bar buttons, see FB8595468 + if #available(iOS 14.0, *) { + mostOfTheBody.toolbar { + ToolbarItem(placement: .cancellationAction) { cancelButton } + ToolbarItem(placement: .confirmationAction) { postButton } + } + } else { + mostOfTheBody.navigationBarItems(leading: cancelButton, trailing: postButton) + } + } + + var mostOfTheBody: some View { + ZStack(alignment: .top) { + ScrollView(.vertical) { + mainStack + } + + // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 + WrappedProgressView(value: postProgress, total: postTotalProgress) + } + .coordinateSpace(name: "outer") + .onAppear(perform: self.didAppear) + .navigationBarTitle("Compose") + .onReceive(vcWidthSubject) { self.viewControllerWidth = $0 } + .actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet) + .alert(isPresented: $isShowingPostErrorAlert) { + Alert( + title: Text("Error Posting Status"), + message: Text(postError?.localizedDescription ?? ""), + dismissButton: .default(Text("OK")) + ) + } + } + + var mainStack: some View { + VStack(alignment: .leading, spacing: 8) { + if let id = draft.inReplyToID, + let status = mastodonController.persistentContainer.status(for: id) { + ComposeReplyView( + status: status, + maxWidth: viewControllerWidth - (2 * stackPadding), + stackPadding: stackPadding + ) + } + + header + + if draft.contentWarningEnabled { + TextField("Write your warning here", text: $draft.contentWarning) + .textFieldStyle(RoundedBorderTextFieldStyle()) + } + + MainComposeTextView( + draft: draft, + placeholder: Text("What's on your mind?") + ) + + ComposeAttachmentsList( + draft: draft + ) + // the list rows provide their own padding, so we cancel out the extra spacing from the VStack + .padding([.top, .bottom], -8) + } + .padding(stackPadding) + } + + private var header: some View { + HStack(alignment: .top) { + ComposeCurrentAccount() + Spacer() + Text(verbatim: charactersRemaining.description) + .foregroundColor(charactersRemaining < 0 ? .red : .secondary) + .font(Font.body.monospacedDigit()) + }.frame(height: 50) + } + + private var cancelButton: some View { + Button(action: self.cancel) { + Text("Cancel") + // otherwise all Buttons in the nav bar are made semibold + .font(.system(size: 17, weight: .regular)) + } + } + + private var postButton: some View { + Button(action: self.postStatus) { + Text("Post") + } + .disabled(!postButtonEnabled) + } + + private func didAppear() { + let proxy = UIScrollView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self]) + proxy.keyboardDismissMode = .interactive + } + + private func cancel() { + if Preferences.shared.automaticallySaveDrafts { + // draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear + uiState.delegate?.dismissCompose() + } else { + uiState.isShowingSaveDraftSheet = true + } + } + + private func saveAndCloseSheet() -> ActionSheet { + ActionSheet(title: Text("Do you want to save the current post as a draft?"), buttons: [ + .default(Text("Save Draft"), action: { + // draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear + uiState.isShowingSaveDraftSheet = false + uiState.delegate?.dismissCompose() + }), + .destructive(Text("Delete Draft"), action: { + DraftsManager.shared.remove(draft) + uiState.isShowingSaveDraftSheet = false + uiState.delegate?.dismissCompose() + }), + .cancel(), + ]) + } + + private func postStatus() { + guard draft.hasContent else { return } + + isPosting = true + + // save before posting, so if a crash occurs during network request, the status won't be lost + DraftsManager.save() + + let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil + let sensitive = contentWarning != nil + + // 2 steps (request data, then upload) for each attachment + postTotalProgress = Double(2 + (draft.attachments.count * 2)) + postProgress = 1 + + uploadAttachments { (result) in + switch result { + case let .failure(error): + self.isShowingPostErrorAlert = true + self.postError = error + + case let .success(uploadedAttachments): + let request = Client.createStatus(text: draft.text, + contentType: Preferences.shared.statusContentType, + inReplyTo: draft.inReplyToID, + media: uploadedAttachments, + sensitive: sensitive, + spoilerText: contentWarning, + visibility: draft.visibility, + language: nil) + self.mastodonController.run(request) { (response) in + switch response { + case let .failure(error): + self.isShowingPostErrorAlert = true + self.postError = error + + case .success(_, _): + self.postProgress += 1 + + DraftsManager.shared.remove(self.draft) + + // wait .25 seconds so the user can see the progress bar has completed + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { + self.uiState.delegate?.dismissCompose() + } + } + } + } + } + } + + private func uploadAttachments(_ completion: @escaping (Result<[Attachment], Error>) -> Void) { + let group = DispatchGroup() + + var anyFailed = false + var uploadedAttachments = [Result?]() + + for (index, compAttachment) in draft.attachments.enumerated() { + group.enter() + + uploadedAttachments.append(nil) + + compAttachment.data.getData { (data, mimeType) in + postProgress += 1 + + let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file") + let request = Client.upload(attachment: formAttachment, description: compAttachment.attachmentDescription) + self.mastodonController.run(request) { (response) in + postProgress += 1 + + switch response { + case let .failure(error): + uploadedAttachments[index] = .failure(error) + anyFailed = true + case let .success(attachment, _): + uploadedAttachments[index] = .success(attachment) + } + + group.leave() + } + } + } + + group.notify(queue: .main) { + if anyFailed { + let errors = uploadedAttachments.map { (result) -> Error? in + if case let .failure(error) = result { + return error + } else { + return nil + } + } + completion(.failure(AttachmentUploadError(errors: errors))) + } else { + let uploadedAttachments = uploadedAttachments.map { + try! $0!.get() + } + completion(.success(uploadedAttachments)) + } + } + } +} + +fileprivate struct AttachmentUploadError: LocalizedError { + let errors: [Error?] + + var localizedDescription: String { + return errors.enumerated().compactMap { (index, error) -> String? in + guard let error = error else { return nil } + return "Attachment \(index + 1): \(error.localizedDescription)" + }.joined(separator: ", ") + } +} + +//struct ComposeView_Previews: PreviewProvider { +// static var previews: some View { +// ComposeView() +// } +//} diff --git a/Tusker/Screens/Compose/ComposeViewController.swift b/Tusker/Screens/Compose/ComposeViewController.swift deleted file mode 100644 index 4e0b76b452..0000000000 --- a/Tusker/Screens/Compose/ComposeViewController.swift +++ /dev/null @@ -1,619 +0,0 @@ -// -// ComposeViewController.swift -// Tusker -// -// Created by Shadowfacts on 8/28/18. -// Copyright © 2018 Shadowfacts. All rights reserved. -// - -import UIKit -import Pachyderm -import Intents - -class ComposeViewController: UIViewController { - - weak var mastodonController: MastodonController! - - var inReplyToID: String? - var accountsToMention = [String]() - var initialText: String? - var contentWarningEnabled = false { - didSet { - contentWarningStateChanged() - } - } - var visibility: Status.Visibility! { - didSet { - visibilityChanged() - } - } - - var hasChanges = false - var currentDraft: DraftsManager.Draft? - // Weak so that if a new session is initiated (i.e. XCBManager.currentSession is changed) while the current one is in progress, this one will be released - weak var xcbSession: XCBSession? - var postedStatus: Status? - - var compositionState: CompositionState = .valid { - didSet { - postBarButtonItem.isEnabled = compositionState.isValid - } - } - - weak var postBarButtonItem: UIBarButtonItem! - var visibilityBarButtonItem: UIBarButtonItem! - var contentWarningBarButtonItem: UIBarButtonItem! - - @IBOutlet weak var scrollView: UIScrollView! - @IBOutlet weak var contentView: UIView! - @IBOutlet weak var stackView: UIStackView! - - var replyView: ComposeStatusReplyView? - var replyAvatarImageViewTopConstraint: NSLayoutConstraint? - - @IBOutlet weak var selfDetailView: LargeAccountDetailView! - - @IBOutlet weak var charactersRemainingLabel: UILabel! - @IBOutlet weak var statusTextView: UITextView! - @IBOutlet weak var placeholderLabel: UILabel! - - @IBOutlet weak var inReplyToContainer: UIView! - @IBOutlet weak var inReplyToLabel: UILabel! - @IBOutlet weak var contentWarningContainerView: UIView! - @IBOutlet weak var contentWarningTextField: UITextField! - - @IBOutlet weak var composeAttachmentsContainerView: UIView! - - @IBOutlet weak var postProgressView: SteppedProgressView! - - var composeAttachmentsViewController: ComposeAttachmentsViewController! - - init(inReplyTo inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil, mastodonController: MastodonController) { - self.mastodonController = mastodonController - - self.inReplyToID = inReplyToID - if let inReplyToID = inReplyToID, let inReplyTo = mastodonController.persistentContainer.status(for: inReplyToID) { - accountsToMention = [inReplyTo.account.acct] + inReplyTo.mentions.map { $0.acct } - } else { - accountsToMention = [] - } - if let mentioningAcct = mentioningAcct { - accountsToMention.append(mentioningAcct) - } - if let ownAccount = mastodonController.account { - accountsToMention.removeAll(where: { acct in ownAccount.acct == acct }) - } - accountsToMention = accountsToMention.uniques() - - super.init(nibName: "ComposeViewController", bundle: nil) - - title = "Compose" - tabBarItem.image = UIImage(systemName: "pencil") - - navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(showSaveAndClosePrompt)) - navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Post", style: .done, target: self, action: #selector(postButtonPressed)) - postBarButtonItem = navigationItem.rightBarButtonItem - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - scrollView.delegate = self - - statusTextView.delegate = self - statusTextView.becomeFirstResponder() - - let toolbar = UIToolbar() - contentWarningBarButtonItem = UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(contentWarningButtonPressed)) - contentWarningBarButtonItem.accessibilityLabel = NSLocalizedString("Add Content Warning", comment: "add CW accessibility label") - visibilityBarButtonItem = UIBarButtonItem(image: UIImage(systemName: Preferences.shared.defaultPostVisibility.imageName), style: .plain, target: self, action: #selector(visibilityButtonPressed)) - visibilityBarButtonItem.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), Preferences.shared.defaultPostVisibility.displayName) - toolbar.items = [ - contentWarningBarButtonItem, - visibilityBarButtonItem, - UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) - ] + createFormattingButtons() + [ - UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), - UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPressed)) - ] - toolbar.translatesAutoresizingMaskIntoConstraints = false - statusTextView.inputAccessoryView = toolbar - contentWarningTextField.inputAccessoryView = toolbar - - statusTextView.text = accountsToMention.map({ acct in "@\(acct) " }).joined() - initialText = statusTextView.text - - mastodonController.getOwnAccount { (account) in - DispatchQueue.main.async { - self.selfDetailView.update(account: account) - } - } - - updateInReplyTo() - - // we have to set the font here, because the monospaced digit font is not available in IB - charactersRemainingLabel.font = .monospacedDigitSystemFont(ofSize: 17, weight: .regular) - updatePlaceholder() - // if the compose screen is opened via the home screen shortcut and app isn't running, - // the msatodon instance may not have been loaded yet - mastodonController.getOwnInstance { (_) in - DispatchQueue.main.async { - self.updateCharactersRemaining() - } - } - - composeAttachmentsViewController = ComposeAttachmentsViewController(attachments: currentDraft?.attachments ?? [], mastodonController: mastodonController) - composeRequiresAttachmentDescriptionsDidChange() - composeAttachmentsViewController.delegate = self - composeAttachmentsViewController.tableView.isScrollEnabled = false - composeAttachmentsViewController.tableView.translatesAutoresizingMaskIntoConstraints = false - embedChild(composeAttachmentsViewController, in: composeAttachmentsContainerView) - - pasteConfiguration = composeAttachmentsViewController.pasteConfiguration - - NotificationCenter.default.addObserver(self, selector: #selector(contentWarningTextFieldDidChange), name: UITextField.textDidChangeNotification, object: contentWarningTextField) - } - - func updateInReplyTo() { - if let replyView = replyView { - replyView.removeFromSuperview() - } - - if let inReplyToID = inReplyToID { - if let status = mastodonController.persistentContainer.status(for: inReplyToID) { - updateInReplyTo(inReplyTo: status) - } else { - let loadingVC = LoadingViewController() - embedChild(loadingVC) - - let request = Client.getStatus(id: inReplyToID) - mastodonController.run(request) { (response) in - guard case let .success(status, _) = response else { return } - self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: true) { (status) in - DispatchQueue.main.async { - self.updateInReplyTo(inReplyTo: status) - loadingVC.removeViewAndController() - } - } - } - } - } else { - visibility = Preferences.shared.defaultPostVisibility - contentWarningEnabled = false - - inReplyToContainer.isHidden = true - } - } - - func updateInReplyTo(inReplyTo: StatusMO) { - visibility = inReplyTo.visibility - if Preferences.shared.contentWarningCopyMode == .doNotCopy { - contentWarningEnabled = false - contentWarningContainerView.isHidden = true - } else { - contentWarningEnabled = !inReplyTo.spoilerText.isEmpty - contentWarningContainerView.isHidden = !contentWarningEnabled - if Preferences.shared.contentWarningCopyMode == .prependRe, - !inReplyTo.spoilerText.lowercased().starts(with: "re:") { - contentWarningTextField.text = "re: \(inReplyTo.spoilerText)" - } else { - contentWarningTextField.text = inReplyTo.spoilerText - } - } - - let replyView = ComposeStatusReplyView.create() - replyView.mastodonController = mastodonController - replyView.updateUI(for: inReplyTo) - stackView.insertArrangedSubview(replyView, at: 0) - - self.replyView = replyView - - replyAvatarImageViewTopConstraint = replyView.avatarImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8) - replyAvatarImageViewTopConstraint!.isActive = true - - inReplyToContainer.isHidden = false - // todo: update to use managed objects - inReplyToLabel.text = "In reply to \(inReplyTo.account.displayName)" - } - - override func viewWillAppear(_ animated: Bool) { - NotificationCenter.default.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillHideNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) - NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillChangeFrameNotification, object: nil) - } - - func createFormattingButtons() -> [UIBarButtonItem] { - guard Preferences.shared.statusContentType != .plain else { - return [] - } - - var formatButtons = StatusFormat.allCases.map { (format) -> UIBarButtonItem in - let item: UIBarButtonItem - if let image = format.image { - item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(formatButtonPressed(_:))) - } else if let (str, attributes) = format.title { - item = UIBarButtonItem(title: str, style: .plain, target: self, action: #selector(formatButtonPressed(_:))) - item.setTitleTextAttributes(attributes, for: .normal) - item.setTitleTextAttributes(attributes, for: .highlighted) - } else { - fatalError("StatusFormat must have either an image or a title") - } - item.tag = StatusFormat.allCases.firstIndex(of: format)! - item.accessibilityLabel = format.accessibilityLabel - return item - } - - for i in (1.. Bool { - return composeAttachmentsViewController.canPaste(itemProviders) - } - - override func paste(itemProviders: [NSItemProvider]) { - composeAttachmentsViewController.paste(itemProviders: itemProviders) - } - - // MARK: - Interaction - - @objc func showSaveAndClosePrompt() { - guard statusTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) != initialText else { - close() - return - } - - if Preferences.shared.automaticallySaveDrafts { - saveDraft() - close() - return - } - - let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) - alert.addAction(UIAlertAction(title: "Save draft", style: .default, handler: { (_) in - self.saveDraft() - self.close() - })) - alert.addAction(UIAlertAction(title: "Delete draft", style: .destructive, handler: { (_) in - if let currentDraft = self.currentDraft { - DraftsManager.shared.remove(currentDraft) - } - self.close() - })) - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) - present(alert, animated: true) - } - - @objc func contentWarningButtonPressed() { - contentWarningEnabled = !contentWarningEnabled - if contentWarningEnabled { - contentWarningTextField.becomeFirstResponder() - } else { - statusTextView.becomeFirstResponder() - } - } - - @objc func contentWarningTextFieldDidChange() { - updateCharactersRemaining() - updateHasChanges() - } - - @objc func visibilityButtonPressed() { - let alertController = UIAlertController(currentVisibility: self.visibility) { (visibility) in - guard let visibility = visibility else { return } - self.visibility = visibility - } - alertController.popoverPresentationController?.barButtonItem = visibilityBarButtonItem - present(alertController, animated: true) - } - - @objc func formatButtonPressed(_ button: UIBarButtonItem) { - guard statusTextView.isFirstResponder else { - return - } - - let format = StatusFormat.allCases[button.tag] - guard let insertionResult = format.insertionResult else { - return - } - - let currentSelectedRange = statusTextView.selectedRange - if currentSelectedRange.length == 0 { - statusTextView.insertText(insertionResult.prefix + insertionResult.suffix) - statusTextView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0) - } else { - let start = statusTextView.text.index(statusTextView.text.startIndex, offsetBy: currentSelectedRange.lowerBound) - let end = statusTextView.text.index(statusTextView.text.startIndex, offsetBy: currentSelectedRange.upperBound) - let selectedText = statusTextView.text[start.. replyView.frame.height - replyView.avatarImageView.frame.height - 16 { - constant += replyView.frame.height - replyView.avatarImageView.frame.height - 16 - scrollView.contentOffset.y - } - - replyAvatarImageViewTopConstraint?.constant = constant - } -} - -extension ComposeViewController: UITextViewDelegate { - func textViewDidChange(_ textView: UITextView) { - updateCharactersRemaining() - updatePlaceholder() - updateHasChanges() - } -} - -extension ComposeViewController: ComposeAttachmentsViewControllerDelegate { - func composeSelectedAttachmentsDidChange() { - currentDraft?.attachments = composeAttachmentsViewController.attachments - } - - func composeRequiresAttachmentDescriptionsDidChange() { - if composeAttachmentsViewController.requiresAttachmentDescriptions { - compositionState.formUnion(.requiresAttachmentDescriptions) - } else { - compositionState.subtract(.requiresAttachmentDescriptions) - } - } -} - -extension ComposeViewController: DraftsTableViewControllerDelegate { - func draftSelectionCanceled() { - } - - func shouldSelectDraft(_ draft: DraftsManager.Draft, completion: @escaping (Bool) -> Void) { - if draft.inReplyToID != self.inReplyToID, hasChanges { - let alertController = UIAlertController(title: "Different Reply", message: "The selected draft is a reply to a different status, do you wish to use it?", preferredStyle: .alert) - alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in - completion(false) - })) - alertController.addAction(UIAlertAction(title: "Restore Draft", style: .default, handler: { (_) in - completion(true) - })) - // we can't present the alert ourselves, since the compose VC is already presenting the draft selector - // but presenting on the presented view controller seems hacky, is there a better way to do this? - presentedViewController!.present(alertController, animated: true) - } else { - completion(true) - } - } - - func draftSelected(_ draft: DraftsManager.Draft) { - if hasChanges { - saveDraft() - } - - self.currentDraft = draft - - inReplyToID = draft.inReplyToID - updateInReplyTo() - - statusTextView.text = draft.text - contentWarningEnabled = draft.contentWarning != nil - contentWarningTextField.text = draft.contentWarning - - updatePlaceholder() - updateCharactersRemaining() - - composeAttachmentsViewController.setAttachments(draft.attachments) - } - - func draftSelectionCompleted() { - // todo: I don't think this can actually happen any more? - // check that all the assets from the draft have been added - if let currentDraft = currentDraft, composeAttachmentsViewController.attachments.count < currentDraft.attachments.count { - // some of the assets in the draft weren't loaded, so notify the user - - let difference = currentDraft.attachments.count - composeAttachmentsViewController.attachments.count - // todo: localize me - let suffix = difference == 1 ? "" : "s" - let verb = difference == 1 ? "was" : "were" - let alertController = UIAlertController(title: "Missing Attachments", message: "\(difference) attachment\(suffix) \(verb) removed from the Photos Library and could not be loaded.", preferredStyle: .alert) - alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) - - present(alertController, animated: true) - } - } -} - -extension ComposeViewController: UIAdaptivePresentationControllerDelegate { - func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { - return Preferences.shared.automaticallySaveDrafts || !hasChanges - } - - func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { - showSaveAndClosePrompt() - } - - // when the compose screen is dismissed interactively, close() isn't called, so we make sure to - // complete the X-Callback-URL session and save the draft is automatic saving is enabled - // (if automatic saving is off, the draft will get saved/discarded by the user when didAttemptToDismiss is called - func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - if Preferences.shared.automaticallySaveDrafts { - saveDraft() - } - xcbSession?.complete(with: .cancel) - } -} - diff --git a/Tusker/Screens/Compose/ComposeViewController.xib b/Tusker/Screens/Compose/ComposeViewController.xib deleted file mode 100644 index 4b4e4a63e9..0000000000 --- a/Tusker/Screens/Compose/ComposeViewController.xib +++ /dev/null @@ -1,178 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Tusker/Screens/Compose/MainComposeTextView.swift b/Tusker/Screens/Compose/MainComposeTextView.swift new file mode 100644 index 0000000000..5bc32abab5 --- /dev/null +++ b/Tusker/Screens/Compose/MainComposeTextView.swift @@ -0,0 +1,193 @@ +// +// MainComposeTextView.swift +// Tusker +// +// Created by Shadowfacts on 8/29/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI +import Pachyderm + +struct MainComposeTextView: View { + @ObservedObject var draft: Draft + let placeholder: Text + + let minHeight: CGFloat = 150 + @State private var height: CGFloat? + @State private var becomeFirstResponder: Bool = false + @State private var hasFirstAppeared = false + + var body: some View { + ZStack(alignment: .topLeading) { + MainComposeWrappedTextView( + text: $draft.text, + visibility: draft.visibility, + becomeFirstResponder: $becomeFirstResponder + ) { (textView) in + self.height = max(textView.contentSize.height, minHeight) + } + .frame(height: height ?? minHeight) + + if draft.text.isEmpty { + placeholder + .font(.system(size: 20)) + .foregroundColor(.secondary) + .offset(x: 4, y: 8) + } + }.onAppear { + if !hasFirstAppeared { + hasFirstAppeared = true + becomeFirstResponder = true + } + } + } +} + +struct MainComposeWrappedTextView: UIViewRepresentable { + typealias UIViewType = UITextView + + @Binding var text: String + let visibility: Status.Visibility + @Binding var becomeFirstResponder: Bool + var textDidChange: (UITextView) -> Void + + @EnvironmentObject var uiState: ComposeUIState + @State var visibilityButton: UIBarButtonItem? + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.delegate = context.coordinator + textView.isEditable = true + textView.backgroundColor = .secondarySystemBackground + textView.font = .systemFont(ofSize: 20) + textView.textContainer.lineBreakMode = .byWordWrapping + context.coordinator.textView = textView + + let visibilityButton = UIBarButtonItem(image: UIImage(systemName: visibility.imageName), style: .plain, target: nil, action: #selector(ComposeHostingController.visibilityButtonPressed(_:))) + let toolbar = UIToolbar() + toolbar.translatesAutoresizingMaskIntoConstraints = false + toolbar.items = [ + UIBarButtonItem(title: "CW", style: .plain, target: nil, action: #selector(ComposeHostingController.cwButtonPressed)), + visibilityButton, + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), + ] + createFormattingButtons(coordinator: context.coordinator) + [ + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), + UIBarButtonItem(title: "Drafts", style: .plain, target: nil, action: #selector(ComposeHostingController.draftsButtonPresed)), + ] + textView.inputAccessoryView = toolbar + // can't modify @State during view update + DispatchQueue.main.async { + self.visibilityButton = visibilityButton + } + + NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) + NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil) + + return textView + } + + private func createFormattingButtons(coordinator: Coordinator) -> [UIBarButtonItem] { + guard Preferences.shared.statusContentType != .plain else { + return [] + } + + var formatButtons = StatusFormat.allCases.map { (format) -> UIBarButtonItem in + let item: UIBarButtonItem + if let image = format.image { + item = UIBarButtonItem(image: image, style: .plain, target: coordinator, action: #selector(Coordinator.formatButtonPressed(_:))) + } else if let (str, attributes) = format.title { + item = UIBarButtonItem(title: str, style: .plain, target: coordinator, action: #selector(Coordinator.formatButtonPressed(_:))) + item.setTitleTextAttributes(attributes, for: .normal) + item.setTitleTextAttributes(attributes, for: .highlighted) + } else { + fatalError("StatusFormat must have either an image or a title") + } + item.tag = StatusFormat.allCases.firstIndex(of: format)! + item.accessibilityLabel = format.accessibilityLabel + return item + } + + for i in (1.. Coordinator { + return Coordinator(text: $text, uiState: uiState, didChange: textDidChange) + } + + class Coordinator: NSObject, UITextViewDelegate { + weak var textView: UITextView? + var text: Binding + var didChange: (UITextView) -> Void + var uiState: ComposeUIState + + init(text: Binding, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) { + self.text = text + self.didChange = didChange + self.uiState = uiState + } + + func textViewDidChange(_ textView: UITextView) { + text.wrappedValue = textView.text + didChange(textView) + } + + @objc func formatButtonPressed(_ sender: UIBarButtonItem) { + guard let textView = textView, textView.isFirstResponder else { return } + let format = StatusFormat.allCases[sender.tag] + guard let insertionResult = format.insertionResult else { return } + + let currentSelectedRange = textView.selectedRange + if currentSelectedRange.length == 0 { + textView.insertText(insertionResult.prefix + insertionResult.suffix) + textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0) + } else { + let start = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.lowerBound) + let end = textView.text.index(textView.text.startIndex, offsetBy: currentSelectedRange.upperBound) + let selectedText = textView.text[start.. Void) - func draftSelected(_ draft: DraftsManager.Draft) + func shouldSelectDraft(_ draft: Draft, completion: @escaping (Bool) -> Void) + func draftSelected(_ draft: Draft) func draftSelectionCompleted() } class DraftsTableViewController: UITableViewController { let account: LocalData.UserAccountInfo + let excludedDraft: Draft? weak var delegate: DraftsTableViewControllerDelegate? - var drafts = [DraftsManager.Draft]() + var drafts = [Draft]() - init(account: LocalData.UserAccountInfo) { + init(account: LocalData.UserAccountInfo, exclude: Draft? = nil) { self.account = account + self.excludedDraft = exclude super.init(nibName: "DraftsTableViewController", bundle: nil) @@ -44,11 +46,11 @@ class DraftsTableViewController: UITableViewController { tableView.register(UINib(nibName: "DraftTableViewCell", bundle: nil), forCellReuseIdentifier: "draftCell") drafts = DraftsManager.shared.sorted.filter { (draft) in - draft.accountID == account.id + draft.accountID == account.id && draft != excludedDraft } } - func draft(for indexPath: IndexPath) -> DraftsManager.Draft { + func draft(for indexPath: IndexPath) -> Draft { return drafts[indexPath.row] } diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index 3d2071c8e1..5b6bd7d7d6 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -309,10 +309,10 @@ fileprivate extension MainSidebarViewController.Item { @available(iOS 14.0, *) extension MainSplitViewController: TuskerRootViewController { func presentCompose() { - let compose = ComposeViewController(mastodonController: mastodonController) - let navigationController = EnhancedNavigationViewController(rootViewController: compose) - navigationController.presentationController?.delegate = compose - present(navigationController, animated: true) + let vc = ComposeHostingController(mastodonController: mastodonController) + let nav = EnhancedNavigationViewController(rootViewController: vc) + nav.presentationController?.delegate = vc + present(nav, animated: true) } func select(tab: MainTabBarViewController.Tab) { diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index f06915661d..1f640d42a9 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -7,6 +7,7 @@ // import UIKit +import SwiftUI class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { @@ -101,7 +102,7 @@ extension MainTabBarViewController { case .notifications: return NotificationsPageViewController(mastodonController: mastodonController) case .compose: - return ComposeViewController(mastodonController: mastodonController) + return ComposeHostingController(mastodonController: mastodonController) case .explore: return ExploreViewController(mastodonController: mastodonController) case .myProfile: @@ -121,10 +122,10 @@ extension MainTabBarViewController { extension MainTabBarViewController: TuskerRootViewController { func presentCompose() { - let compose = ComposeViewController(mastodonController: mastodonController) - let navigationController = embedInNavigationController(compose) - navigationController.presentationController?.delegate = compose - present(navigationController, animated: true) + let vc = ComposeHostingController(mastodonController: mastodonController) + let nav = EnhancedNavigationViewController(rootViewController: vc) + nav.presentationController?.delegate = vc + present(nav, animated: true) } func select(tab: Tab) { diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index 0e7f7cb994..b02e22103d 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -40,7 +40,7 @@ extension MenuPreviewProvider { var actionsSection: [UIMenuElement] = [ createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { [weak self] (_) in guard let self = self else { return } - self.navigationDelegate?.compose(mentioning: account.acct) + self.navigationDelegate?.compose(mentioningAcct: account.acct) }), ] @@ -129,7 +129,7 @@ extension MenuPreviewProvider { var actionsSection = [ createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in guard let self = self else { return } - self.navigationDelegate?.reply(to: statusID) + self.navigationDelegate?.compose(inReplyToID: statusID) }), createAction(identifier: "bookmark", title: bookmarked ? "Unbookmark" : "Bookmark", systemImageName: bookmarked ? "bookmark.fill" : "bookmark", handler: { [weak self] (_) in guard let self = self else { return } diff --git a/Tusker/Shortcuts/UserActivityManager.swift b/Tusker/Shortcuts/UserActivityManager.swift index fc9fc381e8..545f0f48d4 100644 --- a/Tusker/Shortcuts/UserActivityManager.swift +++ b/Tusker/Shortcuts/UserActivityManager.swift @@ -7,6 +7,7 @@ // import UIKit +import Intents import Pachyderm class UserActivityManager { @@ -50,7 +51,8 @@ class UserActivityManager { static func handleNewPost(activity: NSUserActivity) { // TODO: check not currently showing compose screen let mentioning = activity.userInfo?["mentioning"] as? String - let composeVC = ComposeViewController(mentioningAcct: mentioning, mastodonController: mastodonController) + let draft = mastodonController.createDraft(mentioningAcct: mentioning) + let composeVC = ComposeHostingController(draft: draft, mastodonController: mastodonController) present(UINavigationController(rootViewController: composeVC)) } diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index d03425c98b..53ddd4550c 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -79,24 +79,10 @@ extension TuskerNavigationDelegate { show(ConversationTableViewController(for: statusID, state: state, mastodonController: apiController), sender: self) } - // protocols can't have parameter defaults, so this stub is necessary to fulfill the protocol req - func compose() { - compose(mentioning: nil) - } - - func compose(mentioning: String?) { - let compose = ComposeViewController(mentioningAcct: mentioning, mastodonController: apiController) - let vc = UINavigationController(rootViewController: compose) - vc.presentationController?.delegate = compose - present(vc, animated: true) - } - - func reply(to statusID: String) { - reply(to: statusID, mentioningAcct: nil) - } - - func reply(to statusID: String, mentioningAcct: String?) { - let compose = ComposeViewController(inReplyTo: statusID, mentioningAcct: mentioningAcct, mastodonController: apiController) + func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) { + let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct) + let compose = ComposeHostingController(draft: draft, mastodonController: apiController) + let vc = UINavigationController(rootViewController: compose) vc.presentationController?.delegate = compose present(vc, animated: true) diff --git a/Tusker/Views/ActivityIndicatorView.swift b/Tusker/Views/ActivityIndicatorView.swift new file mode 100644 index 0000000000..89c916dff9 --- /dev/null +++ b/Tusker/Views/ActivityIndicatorView.swift @@ -0,0 +1,23 @@ +// +// ActivityIndicatorView.swift +// Tusker +// +// Created by Shadowfacts on 8/29/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct ActivityIndicatorView: UIViewRepresentable { + typealias UIViewType = UIActivityIndicatorView + + func makeUIView(context: Context) -> UIActivityIndicatorView { + let view = UIActivityIndicatorView() + view.startAnimating() + return view + } + + func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) { + } + +} diff --git a/Tusker/Views/Attachment Cells/AddAttachmentTableViewCell.swift b/Tusker/Views/Attachment Cells/AddAttachmentTableViewCell.swift deleted file mode 100644 index 0c60b61031..0000000000 --- a/Tusker/Views/Attachment Cells/AddAttachmentTableViewCell.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// AddAttachmentTableViewCell.swift -// Tusker -// -// Created by Shadowfacts on 3/13/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import UIKit - -class AddAttachmentTableViewCell: UITableViewCell { - - @IBOutlet weak var iconImageView: UIImageView! - @IBOutlet weak var label: UILabel! - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - let imageName: String - if traitCollection.userInterfaceStyle == .dark { - imageName = "photo.fill" - } else { - imageName = "photo" - } - iconImageView.image = UIImage(systemName: imageName) - } - - func setEnabled(_ enabled: Bool) { - let color = enabled ? UIColor.systemBlue : .systemGray - iconImageView.tintColor = color - label.textColor = color - } - -} diff --git a/Tusker/Views/Attachment Cells/AddAttachmentTableViewCell.xib b/Tusker/Views/Attachment Cells/AddAttachmentTableViewCell.xib deleted file mode 100644 index aa0328b08c..0000000000 --- a/Tusker/Views/Attachment Cells/AddAttachmentTableViewCell.xib +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Tusker/Views/Attachment Cells/ComposeAttachmentTableViewCell.swift b/Tusker/Views/Attachment Cells/ComposeAttachmentTableViewCell.swift deleted file mode 100644 index 49b237c2c7..0000000000 --- a/Tusker/Views/Attachment Cells/ComposeAttachmentTableViewCell.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// ComposeAttachmentTableViewCell.swift -// Tusker -// -// Created by Shadowfacts on 3/13/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import UIKit -import Photos -import AVFoundation -import Vision - -protocol ComposeAttachmentTableViewCellDelegate: class { - func composeAttachment(_ cell: ComposeAttachmentTableViewCell, present viewController: UIViewController, animated: Bool) - func removeAttachment(_ cell: ComposeAttachmentTableViewCell) - func attachmentDescriptionChanged(_ cell: ComposeAttachmentTableViewCell) - func composeAttachmentDescriptionHeightChanged(_ cell: ComposeAttachmentTableViewCell) -} - -class ComposeAttachmentTableViewCell: UITableViewCell { - - weak var delegate: ComposeAttachmentTableViewCellDelegate? - - @IBOutlet weak var assetImageView: UIImageView! - @IBOutlet weak var descriptionTextView: UITextView! - @IBOutlet weak var descriptionTextViewHeightConstraint: NSLayoutConstraint! - @IBOutlet weak var descriptionPlaceholderLabel: UILabel! - @IBOutlet weak var removeButton: UIButton! - @IBOutlet weak var activityIndicator: UIActivityIndicatorView! - - var attachment: CompositionAttachment! - - var state: State = .allowEntry { - didSet { - switch state { - case .allowEntry: - descriptionTextView.isEditable = true - updateDescriptionPlaceholderLabel() - activityIndicator.stopAnimating() - case .recognizingText: - descriptionTextView.isEditable = false - descriptionPlaceholderLabel.isHidden = true - activityIndicator.startAnimating() - } - } - } - - private var textRecognitionRequest: VNRecognizeTextRequest? - - override func awakeFromNib() { - super.awakeFromNib() - - assetImageView.layer.masksToBounds = true - assetImageView.layer.cornerRadius = 8 - - descriptionTextView.delegate = self - } - - func updateUI(for attachment: CompositionAttachment) { - self.attachment = attachment - - descriptionTextView.text = attachment.attachmentDescription - updateDescriptionPlaceholderLabel() - - assetImageView.contentMode = .scaleAspectFill - assetImageView.backgroundColor = .secondarySystemBackground - switch attachment.data { - case let .image(image): - assetImageView.image = image - case let .asset(asset): - let size = CGSize(width: 80, height: 80) - PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in - guard self.attachment == attachment else { return } - self.assetImageView.image = image - } - case let .video(url): - let asset = AVURLAsset(url: url) - let imageGenerator = AVAssetImageGenerator(asset: asset) - if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) { - assetImageView.image = UIImage(cgImage: cgImage) - } - case let .drawing(drawing): - assetImageView.image = drawing.imageInLightMode(from: drawing.bounds) - assetImageView.contentMode = .scaleAspectFit - assetImageView.backgroundColor = .white - } - } - - func updateDescriptionPlaceholderLabel() { - descriptionPlaceholderLabel.isHidden = !descriptionTextView.text.isEmpty - } - - func setEnabled(_ enabled: Bool) { - descriptionTextView.isEditable = enabled - removeButton.isEnabled = enabled - } - - func recognizeTextFromImage() { - precondition(attachment.data.type == .image) - state = .recognizingText - - DispatchQueue.global(qos: .userInitiated).async { - self.attachment.data.getData { (data, mimeType) in - let handler = VNImageRequestHandler(data: data, options: [:]) - let request = VNRecognizeTextRequest { (request, error) in - DispatchQueue.main.async { - self.state = .allowEntry - - if let results = request.results as? [VNRecognizedTextObservation] { - var text = "" - for observation in results { - let result = observation.topCandidates(1).first! - text.append(result.string) - text.append("\n") - } - self.descriptionTextView.text = text - self.textViewDidChange(self.descriptionTextView) - } - } - } - request.recognitionLevel = .accurate - request.usesLanguageCorrection = true - self.textRecognitionRequest = request - DispatchQueue.global(qos: .userInitiated).async { - do { - try handler.perform([request]) - } catch { - // The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for. - guard (error as NSError).code != 1 else { return } - DispatchQueue.main.async { - self.state = .allowEntry - let title = NSLocalizedString("Text Recognition Failed", comment: "text recognition error alert title") - let message = error.localizedDescription - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - self.delegate?.composeAttachment(self, present: alert, animated: true) - } - } - } - } - } - } - - override func prepareForReuse() { - super.prepareForReuse() - assetImageView.image = nil - descriptionTextViewHeightConstraint.constant = 80 - } - - @IBAction func removeButtonPressed(_ sender: Any) { - textRecognitionRequest?.cancel() - delegate?.removeAttachment(self) - } - -} - -extension ComposeAttachmentTableViewCell { - enum State { - case allowEntry, recognizingText - } -} - -extension ComposeAttachmentTableViewCell: UITextViewDelegate { - func textViewDidChange(_ textView: UITextView) { - attachment.attachmentDescription = textView.text - updateDescriptionPlaceholderLabel() - delegate?.attachmentDescriptionChanged(self) - let smallestSize = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude)) - let old = descriptionTextViewHeightConstraint.constant - descriptionTextViewHeightConstraint.constant = max(80, smallestSize.height) - if old != descriptionTextViewHeightConstraint.constant { - delegate?.composeAttachmentDescriptionHeightChanged(self) - } - - } -} diff --git a/Tusker/Views/Attachment Cells/ComposeAttachmentTableViewCell.xib b/Tusker/Views/Attachment Cells/ComposeAttachmentTableViewCell.xib deleted file mode 100644 index b84fe629c1..0000000000 --- a/Tusker/Views/Attachment Cells/ComposeAttachmentTableViewCell.xib +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Tusker/Views/Draft Cell/DraftTableViewCell.swift b/Tusker/Views/Draft Cell/DraftTableViewCell.swift index 5a368d47a9..8dd2d5b768 100644 --- a/Tusker/Views/Draft Cell/DraftTableViewCell.swift +++ b/Tusker/Views/Draft Cell/DraftTableViewCell.swift @@ -17,9 +17,9 @@ class DraftTableViewCell: UITableViewCell { @IBOutlet weak var attachmentsStackViewContainer: UIView! @IBOutlet weak var attachmentsStackView: UIStackView! - func updateUI(for draft: DraftsManager.Draft) { + func updateUI(for draft: Draft) { contentWarningLabel.text = draft.contentWarning - contentWarningLabel.isHidden = draft.contentWarning == nil + contentWarningLabel.isHidden = !draft.contentWarningEnabled contentLabel.text = draft.text lastModifiedLabel.text = draft.lastModified.timeAgoString() diff --git a/Tusker/Views/Status/BaseStatusTableViewCell.swift b/Tusker/Views/Status/BaseStatusTableViewCell.swift index 7e5293e536..c528bb8472 100644 --- a/Tusker/Views/Status/BaseStatusTableViewCell.swift +++ b/Tusker/Views/Status/BaseStatusTableViewCell.swift @@ -300,7 +300,7 @@ class BaseStatusTableViewCell: UITableViewCell { } @IBAction func replyPressed() { - delegate?.reply(to: statusID) + delegate?.compose(inReplyToID: statusID) } @IBAction func favoritePressed() { diff --git a/Tusker/Views/Status/TimelineStatusTableViewCell.swift b/Tusker/Views/Status/TimelineStatusTableViewCell.swift index abf03d78b4..5077624a4e 100644 --- a/Tusker/Views/Status/TimelineStatusTableViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusTableViewCell.swift @@ -153,9 +153,9 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell { if Preferences.shared.mentionReblogger, let rebloggerID = rebloggerID, let rebloggerAccount = mastodonController.persistentContainer.account(for: rebloggerID) { - delegate?.reply(to: statusID, mentioningAcct: rebloggerAccount.acct) + delegate?.compose(inReplyToID: statusID, mentioningAcct: rebloggerAccount.acct) } else { - delegate?.reply(to: statusID) + delegate?.compose(inReplyToID: statusID) } } diff --git a/Tusker/Views/WrappedProgressView.swift b/Tusker/Views/WrappedProgressView.swift new file mode 100644 index 0000000000..60847a69f5 --- /dev/null +++ b/Tusker/Views/WrappedProgressView.swift @@ -0,0 +1,26 @@ +// +// WrappedProgressView.swift +// Tusker +// +// Created by Shadowfacts on 8/30/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct WrappedProgressView: UIViewRepresentable { + typealias UIViewType = UIProgressView + + let value: Double + let total: Double + + func makeUIView(context: Context) -> UIProgressView { + return UIProgressView(progressViewStyle: .bar) + } + + func updateUIView(_ uiView: UIProgressView, context: Context) { + if total > 0 { + uiView.setProgress(Float(value / total), animated: true) + } + } +} diff --git a/Tusker/XCallbackURL/XCBActions.swift b/Tusker/XCallbackURL/XCBActions.swift index 8c0601dc8f..110b410a7a 100644 --- a/Tusker/XCallbackURL/XCBActions.swift +++ b/Tusker/XCallbackURL/XCBActions.swift @@ -158,8 +158,10 @@ struct XCBActions { } } } else { - let compose = ComposeViewController(mentioningAcct: mentioning, text: text, mastodonController: mastodonController) - compose.xcbSession = session + // todo: use text param + let draft = mastodonController.createDraft(mentioningAcct: mentioning) + let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController) +// compose.xcbSession = session let vc = UINavigationController(rootViewController: compose) present(vc) }