Rewrite Compose screen in SwiftUI

This commit is contained in:
Shadowfacts 2020-08-31 19:28:50 -04:00
parent b55a96d649
commit 4c82b1a341
Signed by untrusted user: shadowfacts
GPG Key ID: 94A5AB95422746E5
45 changed files with 2115 additions and 1927 deletions

View File

@ -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)"
}
}
}
}

View File

@ -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 = "<group>"; };
04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; };
04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = "<group>"; };
D60309B42419D4F100A465FF /* ComposeAttachmentsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsViewController.swift; sourceTree = "<group>"; };
D60A4FFB238B726A008AC647 /* StatusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusState.swift; sourceTree = "<group>"; };
D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = "<group>"; };
D60E2F232442372B005F8713 /* StatusMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMO.swift; sourceTree = "<group>"; };
@ -385,10 +393,16 @@
D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = "<group>"; };
D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = "<group>"; };
D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = "<group>"; };
D620483123D2A6A3008A63EF /* CompositionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionState.swift; sourceTree = "<group>"; };
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
D622757324EDF1CD00B82A16 /* ComposeAttachmentsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentsList.swift; sourceTree = "<group>"; };
D622757724EE133700B82A16 /* ComposeAssetPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAssetPicker.swift; sourceTree = "<group>"; };
D622757924EE21D900B82A16 /* ComposeAttachmentRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentRow.swift; sourceTree = "<group>"; };
D622759F24F1677200B82A16 /* ComposeHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeHostingController.swift; sourceTree = "<group>"; };
D62275A524F1C81800B82A16 /* ComposeReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyView.swift; sourceTree = "<group>"; };
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = "<group>"; };
D62275A924F1E01C00B82A16 /* ComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTextView.swift; sourceTree = "<group>"; };
D626493123BD751600612E6E /* ShowCameraCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ShowCameraCollectionViewCell.xib; sourceTree = "<group>"; };
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentData.swift; sourceTree = "<group>"; };
D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllPhotosTableViewCell.swift; sourceTree = "<group>"; };
@ -422,10 +436,6 @@
D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; };
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = "<group>"; };
D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Tusker.xcdatamodel; sourceTree = "<group>"; };
D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AddAttachmentTableViewCell.xib; sourceTree = "<group>"; };
D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAttachmentTableViewCell.swift; sourceTree = "<group>"; };
D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentTableViewCell.swift; sourceTree = "<group>"; };
D63F9C6A241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeAttachmentTableViewCell.xib; sourceTree = "<group>"; };
D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachment.swift; sourceTree = "<group>"; };
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectImageButton.swift; sourceTree = "<group>"; };
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = DomainBlocks.plist; sourceTree = "<group>"; };
@ -465,7 +475,6 @@
D663626121360B1900C9CBA2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
D663626321360D2300C9CBA2 /* AvatarStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStyle.swift; sourceTree = "<group>"; };
D663626B21361C6700C9CBA2 /* Account+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Preferences.swift"; sourceTree = "<group>"; };
D66362702136338600C9CBA2 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = "<group>"; };
D66362742137068A00C9CBA2 /* Visibility+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Visibility+Helpers.swift"; sourceTree = "<group>"; };
D667383B23299340000A2373 /* InstanceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceType.swift; sourceTree = "<group>"; };
D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShortcutItems.swift; sourceTree = "<group>"; };
@ -477,6 +486,10 @@
D6757A7B2157E01900721E32 /* XCBManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBManager.swift; sourceTree = "<group>"; };
D6757A7D2157E02600721E32 /* XCBRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequestSpec.swift; sourceTree = "<group>"; };
D6757A812157E8FA00721E32 /* XCBSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBSession.swift; sourceTree = "<group>"; };
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = "<group>"; };
D677284D24ECC01D00C732D3 /* Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Draft.swift; sourceTree = "<group>"; };
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKDrawing+Render.swift"; sourceTree = "<group>"; };
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = "<group>"; };
D679C09E215850EF00DA27FE /* XCBActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActions.swift; sourceTree = "<group>"; };
@ -492,6 +505,7 @@
D681E4D6246E32290053414F /* StatusActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityItemSource.swift; sourceTree = "<group>"; };
D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountActivityItemSource.swift; sourceTree = "<group>"; };
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = "<group>"; };
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = "<group>"; };
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = "<group>"; };
@ -520,7 +534,6 @@
D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = "<group>"; };
D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = "<group>"; };
D6A5BB3023BBAD87003BF21D /* JSONResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONResponse.swift; sourceTree = "<group>"; };
D6A5FAF0217B7E05003DB2D9 /* ComposeViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ComposeViewController.xib; sourceTree = "<group>"; };
D6AC956623C4347E008C9946 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivity+Types.swift"; sourceTree = "<group>"; };
D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMesasgeActivity.swift; sourceTree = "<group>"; };
@ -549,8 +562,13 @@
D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentsContainerView.swift; sourceTree = "<group>"; };
D6C94D862139E62700CB5196 /* LargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageViewController.swift; sourceTree = "<group>"; };
D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorView.swift; sourceTree = "<group>"; };
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; };
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeContainerView.swift; sourceTree = "<group>"; };
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
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 = "<group>"; };
D6D4DDD6212518A200E1C4BB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -754,24 +772,14 @@
path = "Draft Cell";
sourceTree = "<group>";
};
D61959D1241E844900A37B8E /* Attachment Cells */ = {
isa = PBXGroup;
children = (
D63F9C67241C4F79004C03CF /* AddAttachmentTableViewCell.swift */,
D63F9C65241C4CC3004C03CF /* AddAttachmentTableViewCell.xib */,
D63F9C69241C50B9004C03CF /* ComposeAttachmentTableViewCell.swift */,
D63F9C6A241C50B9004C03CF /* ComposeAttachmentTableViewCell.xib */,
);
path = "Attachment Cells";
sourceTree = "<group>";
};
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 = "<group>";
@ -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 = "<group>";
@ -1081,6 +1099,7 @@
D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */,
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */,
D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */,
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -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 */,

View File

@ -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)
}
}

View File

@ -116,3 +116,6 @@ class MastodonController {
}
}
// ObservableObject so that SwiftUI views can receive it through @EnvironmentObject
extension MastodonController: ObservableObject {}

View File

@ -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
}
}
}

View File

@ -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<Modified: View>(_ condition: Bool, modifier: (Self) -> Modified) -> some View {
if condition {
modifier(self)
} else {
self
}
}
}

View File

@ -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

View File

@ -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
}
}

142
Tusker/Models/Draft.swift Normal file
View File

@ -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
}
}

View File

@ -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 }
}
}

View File

@ -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 {

View File

@ -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) {
}
}

View File

@ -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()
// }
//}

View File

@ -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()
// }
//}

View File

@ -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<Attachment, Error>?] = []
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
}
}

View File

@ -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")!)
}
}

View File

@ -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<CGFloat, Never>
@ObservedObject var uiState: ComposeUIState
init(
mastodonController: MastodonController,
vcWidthSubject: PassthroughSubject<CGFloat, Never>,
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()
// }
//}

View File

@ -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: )
// }
//}

View File

@ -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
}
}

View File

@ -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<ComposeContainerView> {
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<CGFloat, Never>()
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<Content: View, EnvironmentObject: ObservableObject>: 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)
}
}

View File

@ -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<ComposeView> {
}

View File

@ -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
}
}

View File

@ -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()
// }
//}

View File

@ -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<String>, 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<String>
var didChange: ((UITextView) -> Void)?
init(text: Binding<String>, 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()
// }
//}

View File

@ -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<UUID>()
var composeDrawingMode: ComposeDrawingMode?
init(draft: Draft) {
self.draft = draft
}
}
extension ComposeUIState {
enum ComposeDrawingMode {
case createNew
case edit(id: UUID)
}
}

View File

@ -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<CGFloat, Never>
@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<CGFloat, Never>
) {
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<Attachment, Error>?]()
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()
// }
//}

View File

@ -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..<StatusFormat.allCases.count).reversed() {
let spacer = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
spacer.width = 8
formatButtons.insert(spacer, at: i)
}
return formatButtons
}
@objc func adjustForKeyboard(notification: NSNotification) {
let userInfo = notification.userInfo!
let keyboardScreenEndFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue
let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame, from: view.window)
if notification.name == UIResponder.keyboardWillHideNotification {
scrollView.contentInset = .zero
} else {
// let accessoryFrame = view.convert(statusTextView.inputAccessoryView!.frame, from: view.window)
let offset = keyboardViewEndFrame.height// + accessoryFrame.height
// TODO: radar for incorrect keyboard end frame height (either converted or screen)
// the value returned is somewhere between the height of the keyboard and the height of the keyboard + accessory
// actually maybe not??
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: offset, right: 0)
}
scrollView.scrollIndicatorInsets = scrollView.contentInset
}
func updateCharactersRemaining() {
let count = CharacterCounter.count(text: statusTextView.text)
let cwCount = contentWarningEnabled ? (contentWarningTextField.text?.count ?? 0) : 0
let remaining = (mastodonController.instance?.maxStatusCharacters ?? 500) - count - cwCount
if remaining < 0 {
charactersRemainingLabel.textColor = .red
compositionState.formUnion(.tooManyCharacters)
} else {
charactersRemainingLabel.textColor = .darkGray
compositionState.subtract(.tooManyCharacters)
}
charactersRemainingLabel.text = String(remaining)
charactersRemainingLabel.accessibilityLabel = String(format: NSLocalizedString("%d characters remaining", comment: "compose characters remaining accessibility label"), remaining)
}
func updateHasChanges() {
if let currentDraft = currentDraft {
let cw = contentWarningEnabled ? contentWarningTextField.text : nil
hasChanges = statusTextView.text != currentDraft.text || cw != currentDraft.contentWarning
} else {
hasChanges = !statusTextView.text.isEmpty || (contentWarningEnabled && !(contentWarningTextField.text?.isEmpty ?? true))
}
}
func updatePlaceholder() {
placeholderLabel.isHidden = !statusTextView.text.isEmpty
}
func contentWarningStateChanged() {
contentWarningContainerView.isHidden = !contentWarningEnabled
if contentWarningEnabled {
contentWarningBarButtonItem.accessibilityLabel = NSLocalizedString("Remove Content Warning", comment: "remove CW accessibility label")
} else {
contentWarningBarButtonItem.accessibilityLabel = NSLocalizedString("Add Content Warning", comment: "add CW accessibility label")
}
}
func visibilityChanged() {
visibilityBarButtonItem.image = UIImage(systemName: visibility.imageName)
visibilityBarButtonItem.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), Preferences.shared.defaultPostVisibility.displayName)
}
func saveDraft() {
let attachments = composeAttachmentsViewController.attachments
let statusText = statusTextView.text.trimmingCharacters(in: .whitespacesAndNewlines)
let cw = contentWarningEnabled ? contentWarningTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) : nil
let account = mastodonController.accountInfo!
if attachments.count == 0, statusText.isEmpty, cw?.isEmpty ?? true {
if let currentDraft = self.currentDraft {
DraftsManager.shared.remove(currentDraft)
} else {
return
}
} else {
if let currentDraft = self.currentDraft {
currentDraft.update(accountID: account.id, text: statusText, contentWarning: cw, attachments: attachments)
} else {
self.currentDraft = DraftsManager.shared.create(accountID: account.id, text: statusText, contentWarning: cw, inReplyToID: inReplyToID, attachments: attachments)
}
}
DraftsManager.save()
}
@objc func close() {
dismiss(animated: true)
xcbSession?.complete(with: .cancel)
}
override func canPaste(_ itemProviders: [NSItemProvider]) -> 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..<end]
statusTextView.insertText(String(insertionResult.prefix + selectedText + insertionResult.suffix))
statusTextView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.prefix.count, length: currentSelectedRange.length)
}
}
@objc func draftsButtonPressed() {
let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!)
draftsVC.delegate = self
present(UINavigationController(rootViewController: draftsVC), animated: true)
}
@objc func postButtonPressed() {
guard let text = statusTextView.text,
!text.isEmpty else { return }
// save a draft before posting the status, so if a crash occurs during posting, the status won't be lost
saveDraft()
// disable post button while sending post request
compositionState.formUnion(.currentlyPosting)
let contentWarning: String?
if contentWarningEnabled, let cwText = contentWarningTextField.text, !cwText.isEmpty {
contentWarning = cwText
} else {
contentWarning = nil
}
let sensitive = contentWarning != nil
let visibility = self.visibility!
postProgressView.steps = 2 + (composeAttachmentsViewController.attachments.count * 2) // 2 steps (request data, then upload) for each attachment
postProgressView.currentStep = 1
composeAttachmentsViewController.uploadAll(stepProgress: postProgressView.step) { (success, uploadedAttachments) in
guard success else { return }
let request = Client.createStatus(text: text,
contentType: Preferences.shared.statusContentType,
inReplyTo: self.inReplyToID,
media: uploadedAttachments,
sensitive: sensitive,
spoilerText: contentWarning,
visibility: visibility,
language: nil)
self.mastodonController.run(request) { (response) in
guard case let .success(status, _) = response else { fatalError() }
self.postedStatus = status
// self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: true)
if let draft = self.currentDraft {
DraftsManager.shared.remove(draft)
}
DispatchQueue.main.async {
self.postProgressView.step()
self.dismiss(animated: true)
// todo: this doesn't work
// let conversationVC = ConversationTableViewController(for: status.id, mastodonController: self.mastodonController)
// self.show(conversationVC, sender: self)
self.xcbSession?.complete(with: .success, additionalData: [
"statusURL": status.url?.absoluteString,
"statusURI": status.uri
])
}
}
}
}
}
extension ComposeViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let replyView = replyView else { return }
var constant: CGFloat = 8
if scrollView.contentOffset.y < 0 {
constant -= scrollView.contentOffset.y
replyAvatarImageViewTopConstraint?.constant = 8 - scrollView.contentOffset.y
} else if scrollView.contentOffset.y > 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)
}
}

View File

@ -1,178 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="ComposeViewController" customModule="Tusker" customModuleProvider="target">
<connections>
<outlet property="charactersRemainingLabel" destination="PMB-Wa-Ht0" id="PN9-wr-Pzu"/>
<outlet property="composeAttachmentsContainerView" destination="YFf-I2-7eX" id="u0n-Xe-v09"/>
<outlet property="contentView" destination="pcX-rB-RxJ" id="o95-Qa-6N7"/>
<outlet property="contentWarningContainerView" destination="kU2-7l-MSy" id="Gnq-Jb-kCA"/>
<outlet property="contentWarningTextField" destination="T05-p6-vTz" id="Ivu-Ll-ByO"/>
<outlet property="inReplyToContainer" destination="2Dv-Q7-UEA" id="hfG-5j-G5R"/>
<outlet property="inReplyToLabel" destination="Y25-eP-tDE" id="9Ei-3s-dAx"/>
<outlet property="placeholderLabel" destination="EW3-YK-vPC" id="Rsw-Nv-TNz"/>
<outlet property="postProgressView" destination="Tq7-6P-hMT" id="amT-F1-JI0"/>
<outlet property="scrollView" destination="6Z0-Vy-hMX" id="ya0-2T-QaV"/>
<outlet property="selfDetailView" destination="zZ3-Gv-4P5" id="jou-Vl-TQE"/>
<outlet property="stackView" destination="bOB-hF-O9w" id="lD7-b2-MWl"/>
<outlet property="statusTextView" destination="9pn-0T-IHb" id="u7j-KW-zCo"/>
<outlet property="view" destination="7XG-Dk-OGm" id="09I-sr-hnP"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="7XG-Dk-OGm">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" ambiguous="YES" alwaysBounceVertical="YES" keyboardDismissMode="interactive" translatesAutoresizingMaskIntoConstraints="NO" id="6Z0-Vy-hMX">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<subviews>
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="pcX-rB-RxJ">
<rect key="frame" x="0.0" y="0.0" width="375" height="371.5"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" ambiguous="YES" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="bOB-hF-O9w">
<rect key="frame" x="0.0" y="0.0" width="375" height="419.5"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="6V0-mH-Mhu">
<rect key="frame" x="0.0" y="0.0" width="375" height="66"/>
<subviews>
<view contentMode="scaleToFill" placeholderIntrinsicWidth="infinite" placeholderIntrinsicHeight="66" translatesAutoresizingMaskIntoConstraints="NO" id="zZ3-Gv-4P5" customClass="LargeAccountDetailView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="336" height="66"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" notEnabled="YES"/>
</accessibility>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" text="500" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="PMB-Wa-Ht0">
<rect key="frame" x="336" y="8" width="31" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="PMB-Wa-Ht0" secondAttribute="trailing" constant="8" id="5KJ-rz-Heh"/>
<constraint firstItem="zZ3-Gv-4P5" firstAttribute="leading" secondItem="6V0-mH-Mhu" secondAttribute="leading" id="f6Q-fK-zq1"/>
<constraint firstItem="zZ3-Gv-4P5" firstAttribute="top" secondItem="6V0-mH-Mhu" secondAttribute="top" id="fjf-mn-l9f"/>
<constraint firstItem="PMB-Wa-Ht0" firstAttribute="top" secondItem="6V0-mH-Mhu" secondAttribute="top" constant="8" id="q3V-aY-t9K"/>
<constraint firstAttribute="bottom" secondItem="zZ3-Gv-4P5" secondAttribute="bottom" id="rOO-0n-odM"/>
<constraint firstItem="PMB-Wa-Ht0" firstAttribute="leading" secondItem="zZ3-Gv-4P5" secondAttribute="trailing" id="sVv-tH-7eB"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="2Dv-Q7-UEA">
<rect key="frame" x="0.0" y="66" width="375" height="33.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="In reply to Display Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Y25-eP-tDE">
<rect key="frame" x="4" y="8" width="367" height="21.5"/>
<constraints>
<constraint firstAttribute="height" constant="21.5" id="man-Xn-eVt"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="bottom" secondItem="Y25-eP-tDE" secondAttribute="bottom" constant="4" id="1sZ-CX-GDU"/>
<constraint firstAttribute="trailing" secondItem="Y25-eP-tDE" secondAttribute="trailing" constant="4" id="I31-Rs-QwW"/>
<constraint firstItem="Y25-eP-tDE" firstAttribute="leading" secondItem="2Dv-Q7-UEA" secondAttribute="leading" constant="4" id="kdQ-zs-u7N"/>
<constraint firstItem="Y25-eP-tDE" firstAttribute="top" secondItem="2Dv-Q7-UEA" secondAttribute="top" constant="8" id="qdC-S5-CgV"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="kU2-7l-MSy">
<rect key="frame" x="0.0" y="99.5" width="375" height="42"/>
<subviews>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" borderStyle="roundedRect" placeholder="Write your warning here" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="T05-p6-vTz">
<rect key="frame" x="4" y="4" width="367" height="30"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor" red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="height" constant="30" id="yzY-MF-Ukx"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="20"/>
<textInputTraits key="textInputTraits"/>
</textField>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="T05-p6-vTz" secondAttribute="trailing" constant="4" id="8tG-eW-TG4"/>
<constraint firstAttribute="bottom" secondItem="T05-p6-vTz" secondAttribute="bottom" constant="8" id="SUL-Hk-uvM"/>
<constraint firstItem="T05-p6-vTz" firstAttribute="leading" secondItem="kU2-7l-MSy" secondAttribute="leading" constant="4" id="WGG-B2-lPC"/>
<constraint firstItem="T05-p6-vTz" firstAttribute="top" secondItem="kU2-7l-MSy" secondAttribute="top" constant="4" id="dN2-Pf-qFQ"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="lhQ-ae-pe9">
<rect key="frame" x="0.0" y="141.5" width="375" height="150"/>
<subviews>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="9pn-0T-IHb">
<rect key="frame" x="4" y="0.0" width="367" height="150"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor" red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="height" relation="greaterThanOrEqual" constant="150" id="ISI-jm-FxV"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="20"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="What's on your mind?" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="EW3-YK-vPC">
<rect key="frame" x="8" y="8" width="188" height="24"/>
<fontDescription key="fontDescription" type="system" pointSize="20"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="9pn-0T-IHb" secondAttribute="bottom" id="UAs-fL-Riv"/>
<constraint firstItem="9pn-0T-IHb" firstAttribute="leading" secondItem="lhQ-ae-pe9" secondAttribute="leading" constant="4" id="ezI-15-Yd4"/>
<constraint firstItem="9pn-0T-IHb" firstAttribute="top" secondItem="lhQ-ae-pe9" secondAttribute="top" id="n8v-pK-I9E"/>
<constraint firstItem="EW3-YK-vPC" firstAttribute="leading" secondItem="9pn-0T-IHb" secondAttribute="leading" constant="4" id="n9v-mJ-gz3"/>
<constraint firstItem="EW3-YK-vPC" firstAttribute="top" secondItem="9pn-0T-IHb" secondAttribute="top" constant="8" id="q5e-yM-bS4"/>
<constraint firstAttribute="trailing" secondItem="9pn-0T-IHb" secondAttribute="trailing" constant="4" id="x7Z-8w-xgm"/>
</constraints>
</view>
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="YFf-I2-7eX">
<rect key="frame" x="0.0" y="291.5" width="375" height="128"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
</view>
</subviews>
</stackView>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="bOB-hF-O9w" secondAttribute="trailing" id="GAR-qc-jte"/>
<constraint firstAttribute="height" secondItem="bOB-hF-O9w" secondAttribute="height" id="KO2-zF-s7P"/>
<constraint firstItem="bOB-hF-O9w" firstAttribute="top" secondItem="pcX-rB-RxJ" secondAttribute="top" id="aBm-Ub-TpI"/>
<constraint firstItem="bOB-hF-O9w" firstAttribute="leading" secondItem="pcX-rB-RxJ" secondAttribute="leading" id="yOt-hH-L57"/>
</constraints>
</view>
</subviews>
<constraints>
<constraint firstItem="pcX-rB-RxJ" firstAttribute="leading" secondItem="6Z0-Vy-hMX" secondAttribute="leading" id="X0f-ja-XBF"/>
<constraint firstAttribute="bottom" secondItem="pcX-rB-RxJ" secondAttribute="bottom" id="X8a-PA-r8F"/>
<constraint firstAttribute="trailing" secondItem="pcX-rB-RxJ" secondAttribute="trailing" id="lh7-xn-MGp"/>
<constraint firstItem="pcX-rB-RxJ" firstAttribute="top" secondItem="6Z0-Vy-hMX" secondAttribute="top" id="yMM-IS-8K1"/>
</constraints>
</scrollView>
<progressView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Tq7-6P-hMT" customClass="SteppedProgressView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="2"/>
<color key="trackTintColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</progressView>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstAttribute="trailing" secondItem="Tq7-6P-hMT" secondAttribute="trailing" id="GeN-8q-weq"/>
<constraint firstAttribute="bottom" secondItem="6Z0-Vy-hMX" secondAttribute="bottom" id="Hf3-Cc-mVX"/>
<constraint firstItem="Tq7-6P-hMT" firstAttribute="top" secondItem="Heg-g4-sYM" secondAttribute="top" id="LgA-xu-VGE"/>
<constraint firstItem="Tq7-6P-hMT" firstAttribute="leading" secondItem="7XG-Dk-OGm" secondAttribute="leading" id="agM-ZO-c3E"/>
<constraint firstItem="Heg-g4-sYM" firstAttribute="trailing" secondItem="6Z0-Vy-hMX" secondAttribute="trailing" id="hjY-W6-wTQ"/>
<constraint firstItem="bOB-hF-O9w" firstAttribute="width" secondItem="7XG-Dk-OGm" secondAttribute="width" id="i0p-NE-ca1"/>
<constraint firstItem="6Z0-Vy-hMX" firstAttribute="leading" secondItem="Heg-g4-sYM" secondAttribute="leading" id="lFF-yC-ql9"/>
<constraint firstItem="6Z0-Vy-hMX" firstAttribute="top" secondItem="Heg-g4-sYM" secondAttribute="top" id="osv-zq-seP"/>
</constraints>
<viewLayoutGuide key="safeArea" id="Heg-g4-sYM"/>
<point key="canvasLocation" x="140" y="154"/>
</view>
</objects>
</document>

View File

@ -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..<StatusFormat.allCases.count).reversed() {
let spacer = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
spacer.width = 8
formatButtons.insert(spacer, at: i)
}
return formatButtons
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
visibilityButton?.image = UIImage(systemName: visibility.imageName)
context.coordinator.text = $text
context.coordinator.didChange = textDidChange
context.coordinator.uiState = uiState
if becomeFirstResponder {
uiView.becomeFirstResponder()
DispatchQueue.main.async {
becomeFirstResponder = false
}
}
// 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, uiState: uiState, didChange: textDidChange)
}
class Coordinator: NSObject, UITextViewDelegate {
weak var textView: UITextView?
var text: Binding<String>
var didChange: (UITextView) -> Void
var uiState: ComposeUIState
init(text: Binding<String>, 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..<end]
textView.insertText(String(insertionResult.prefix + selectedText + insertionResult.suffix))
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.prefix.utf16.count, length: currentSelectedRange.length)
}
}
@objc func keyboardWillShow(_ notification: Foundation.Notification) {
uiState.delegate?.keyboardWillShow(accessoryView: textView!.inputAccessoryView!, notification: notification)
}
@objc func keyboardWillHide(_ notification: Foundation.Notification) {
uiState.delegate?.keyboardWillHide(accessoryView: textView!.inputAccessoryView!, notification: notification)
}
@objc func keyboardDidHide(_ notification: Foundation.Notification) {
uiState.delegate?.keyboardDidHide(accessoryView: textView!.inputAccessoryView!, notification: notification)
}
}
}

View File

@ -10,20 +10,22 @@ import UIKit
protocol DraftsTableViewControllerDelegate: class {
func draftSelectionCanceled()
func shouldSelectDraft(_ draft: DraftsManager.Draft, completion: @escaping (Bool) -> 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]
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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 }

View File

@ -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))
}

View File

@ -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(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
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)
let vc = UINavigationController(rootViewController: compose)
vc.presentationController?.delegate = compose
present(vc, animated: true)

View File

@ -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) {
}
}

View File

@ -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
}
}

View File

@ -1,54 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16092.1" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16082.1"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="80" id="4Gv-Ok-KDT" customClass="AddAttachmentTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="414" height="80"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="4Gv-Ok-KDT" id="wXX-bs-G7N">
<rect key="frame" x="0.0" y="0.0" width="414" height="80"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="gMT-px-c1s">
<rect key="frame" x="8" y="0.0" width="398" height="80"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="photo" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="fgi-4Y-VXH">
<rect key="frame" x="0.0" y="31" width="24" height="17.5"/>
<color key="tintColor" systemColor="systemBlueColor" red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" text="Add image or video" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="7Du-B3-9rN">
<rect key="frame" x="40" y="30" width="358" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="systemBlueColor" red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstAttribute="height" constant="80" id="3h8-I7-wtl"/>
</constraints>
</stackView>
</subviews>
<constraints>
<constraint firstItem="gMT-px-c1s" firstAttribute="leading" secondItem="wXX-bs-G7N" secondAttribute="leading" constant="8" id="1Cz-v3-Rzq"/>
<constraint firstAttribute="bottom" secondItem="gMT-px-c1s" secondAttribute="bottom" id="DFN-Nd-Baq"/>
<constraint firstAttribute="trailing" secondItem="gMT-px-c1s" secondAttribute="trailing" constant="8" id="Omi-6C-4u6"/>
<constraint firstItem="gMT-px-c1s" firstAttribute="top" secondItem="wXX-bs-G7N" secondAttribute="top" id="TbI-3U-6aP"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="iconImageView" destination="fgi-4Y-VXH" id="hXw-M3-5B0"/>
<outlet property="label" destination="7Du-B3-9rN" id="yX4-nX-DnY"/>
</connections>
<point key="canvasLocation" x="95.652173913043484" y="95.758928571428569"/>
</tableViewCell>
</objects>
<resources>
<image name="photo" catalog="system" width="128" height="93"/>
</resources>
</document>

View File

@ -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)
}
}
}

View File

@ -1,94 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="96" id="KGk-i7-Jjw" customClass="ComposeAttachmentTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="96"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="96"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Describe for the visually impared..." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="h6T-x4-yzl">
<rect key="frame" x="96" y="16" width="194" height="41"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="xRe-ec-Coh">
<rect key="frame" x="8" y="8" width="304" height="80"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="GLY-o8-47z">
<rect key="frame" x="0.0" y="0.0" width="80" height="80"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor" red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="width" constant="80" id="X6q-g9-dPN"/>
<constraint firstAttribute="height" constant="80" id="xgQ-E3-0QI"/>
</constraints>
</imageView>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="cwP-Eh-5dJ">
<rect key="frame" x="84" y="0.0" width="194" height="80"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" relation="greaterThanOrEqual" constant="80" id="6aZ-w8-j9n"/>
</constraints>
<color key="textColor" systemColor="labelColor" cocoaTouchSystemColor="darkTextColor"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Lvf-I9-aV3">
<rect key="frame" x="282" y="29" width="22" height="22"/>
<constraints>
<constraint firstAttribute="height" constant="22" id="aIh-Ym-ARv"/>
<constraint firstAttribute="width" constant="22" id="qG5-np-4Bs"/>
</constraints>
<state key="normal" image="xmark.circle.fill" catalog="system"/>
<connections>
<action selector="removeButtonPressed:" destination="KGk-i7-Jjw" eventType="touchUpInside" id="efv-Xx-t89"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="cwP-Eh-5dJ" firstAttribute="height" secondItem="xRe-ec-Coh" secondAttribute="height" id="JPp-3t-8ow"/>
<constraint firstAttribute="height" relation="greaterThanOrEqual" constant="80" id="jWo-An-3h6"/>
</constraints>
</stackView>
<activityIndicatorView hidden="YES" opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" style="medium" translatesAutoresizingMaskIntoConstraints="NO" id="Kzy-5r-UW8">
<rect key="frame" x="179" y="38" width="20" height="20"/>
</activityIndicatorView>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="xRe-ec-Coh" secondAttribute="bottom" constant="8" id="DOS-Wv-G3s"/>
<constraint firstItem="xRe-ec-Coh" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="8" id="E41-OU-J0c"/>
<constraint firstItem="h6T-x4-yzl" firstAttribute="trailing" secondItem="cwP-Eh-5dJ" secondAttribute="trailing" constant="4" id="KN2-Ve-3B2"/>
<constraint firstItem="h6T-x4-yzl" firstAttribute="top" secondItem="cwP-Eh-5dJ" secondAttribute="top" constant="8" id="P3B-Jo-XMs"/>
<constraint firstItem="h6T-x4-yzl" firstAttribute="leading" secondItem="cwP-Eh-5dJ" secondAttribute="leading" constant="4" id="UjP-Gs-ZjO"/>
<constraint firstItem="Kzy-5r-UW8" firstAttribute="centerX" secondItem="cwP-Eh-5dJ" secondAttribute="centerX" id="czP-Ia-Ddc"/>
<constraint firstItem="Kzy-5r-UW8" firstAttribute="centerY" secondItem="cwP-Eh-5dJ" secondAttribute="centerY" id="eel-xx-aFq"/>
<constraint firstItem="xRe-ec-Coh" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="8" id="gRN-PV-gm6"/>
<constraint firstAttribute="trailing" secondItem="xRe-ec-Coh" secondAttribute="trailing" constant="8" id="tyE-HK-4qb"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="activityIndicator" destination="Kzy-5r-UW8" id="lmy-NY-Owu"/>
<outlet property="assetImageView" destination="GLY-o8-47z" id="hZH-ur-m4z"/>
<outlet property="descriptionPlaceholderLabel" destination="h6T-x4-yzl" id="jBe-R0-Sfn"/>
<outlet property="descriptionTextView" destination="cwP-Eh-5dJ" id="pxJ-zF-GKC"/>
<outlet property="descriptionTextViewHeightConstraint" destination="6aZ-w8-j9n" id="ees-sT-Trc"/>
<outlet property="removeButton" destination="Lvf-I9-aV3" id="3qk-Zr-je1"/>
</connections>
<point key="canvasLocation" x="107" y="181"/>
</tableViewCell>
</objects>
<resources>
<image name="xmark.circle.fill" catalog="system" width="128" height="121"/>
</resources>
</document>

View File

@ -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()

View File

@ -300,7 +300,7 @@ class BaseStatusTableViewCell: UITableViewCell {
}
@IBAction func replyPressed() {
delegate?.reply(to: statusID)
delegate?.compose(inReplyToID: statusID)
}
@IBAction func favoritePressed() {

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}