diff --git a/README.md b/README.md index b9f4114d..fad1b1c1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,15 @@ # Tusker Tusker is a WIP iOS app for Mastodon and Pleroma. + +## Installing for Development + +Xcode 11 is required, macOS Mojave or later should work (only macOS Catalina is regularly tested). + +1. Clone the project: `git clone https://git.shadowfacts.net/shadowfacts/Tusker.git` +2. Change directory into the project: `cd Tusker` +3. Clone the submodules: `git submodule init && git submodule update` +4. Open `Tusker.xcworkspace` in Xcode. +5. Change the code signing identity to your own. +6. Change the bundle identifier to something unique. +7. Select a target in the Tusker scheme and build & run. diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 7fe41973..615568e9 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */; }; 0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0427033922B31269000D31B6 /* AdvancedPrefsView.swift */; }; 0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0427037B22B316B9000D31B6 /* SilentActionPrefs.swift */; }; - 04496BD721625361001F1B23 /* ContentLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04496BD621625361001F1B23 /* ContentLabel.swift */; }; 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450531E22B0097E00100BA2 /* Timline+UI.swift */; }; 0454DDAF22B462EF00B8BB8E /* GalleryExpandAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0454DDAE22B462EF00B8BB8E /* GalleryExpandAnimationController.swift */; }; 0454DDB122B467AA00B8BB8E /* GalleryShrinkAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0454DDB022B467AA00B8BB8E /* GalleryShrinkAnimationController.swift */; }; @@ -73,6 +72,10 @@ 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 */; }; D626493323BD751600612E6E /* ShowCameraCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D626493123BD751600612E6E /* ShowCameraCollectionViewCell.xib */; }; D626493523BD94CE00612E6E /* CompositionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493423BD94CE00612E6E /* CompositionAttachment.swift */; }; D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */; }; @@ -200,7 +203,6 @@ D6BED170212663DA00F02DA0 /* SwiftSoup.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; }; D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; }; - D6C693F92162E4DB007D6A6D /* StatusContentLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693F82162E4DB007D6A6D /* StatusContentLabel.swift */; }; D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; }; D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; }; D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */; }; @@ -211,7 +213,6 @@ D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */; }; D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */; }; D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; }; - D6D58DF922074B74009C8DD9 /* LinkLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D58DF822074B74009C8DD9 /* LinkLabel.swift */; }; D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; }; D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; }; D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.swift */; }; @@ -285,7 +286,6 @@ 0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BehaviorPrefsView.swift; sourceTree = ""; }; 0427033922B31269000D31B6 /* AdvancedPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedPrefsView.swift; sourceTree = ""; }; 0427037B22B316B9000D31B6 /* SilentActionPrefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SilentActionPrefs.swift; sourceTree = ""; }; - 04496BD621625361001F1B23 /* ContentLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLabel.swift; sourceTree = ""; }; 0450531E22B0097E00100BA2 /* Timline+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timline+UI.swift"; sourceTree = ""; }; 0454DDAE22B462EF00B8BB8E /* GalleryExpandAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryExpandAnimationController.swift; sourceTree = ""; }; 0454DDB022B467AA00B8BB8E /* GalleryShrinkAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryShrinkAnimationController.swift; sourceTree = ""; }; @@ -348,6 +348,10 @@ D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = ""; }; D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = ""; }; D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = ""; }; + D620483123D2A6A3008A63EF /* CompositionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionState.swift; sourceTree = ""; }; + D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = ""; }; + D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = ""; }; + D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = ""; }; D626493123BD751600612E6E /* ShowCameraCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ShowCameraCollectionViewCell.xib; sourceTree = ""; }; D626493423BD94CE00612E6E /* CompositionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachment.swift; sourceTree = ""; }; D626493623C0FD0000612E6E /* AllPhotosTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllPhotosTableViewCell.swift; sourceTree = ""; }; @@ -473,7 +477,6 @@ D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SwiftSoup.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = ""; }; D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = ""; }; - D6C693F82162E4DB007D6A6D /* StatusContentLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentLabel.swift; sourceTree = ""; }; D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = ""; }; D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = ""; }; D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentsContainerView.swift; sourceTree = ""; }; @@ -490,9 +493,8 @@ D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TuskerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerUITests.swift; sourceTree = ""; }; D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - D6D58DF822074B74009C8DD9 /* LinkLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkLabel.swift; sourceTree = ""; }; - D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ContentWarningCopyMode.swift; path = ../../../../../../../System/Volumes/Data/Users/shadowfacts/Dev/iOS/Tusker/Tusker/Preferences/ContentWarningCopyMode.swift; sourceTree = ""; }; - D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Preferences+Notification.swift"; path = "../../../../../../../System/Volumes/Data/Users/shadowfacts/Dev/iOS/Tusker/Tusker/Preferences/Preferences+Notification.swift"; sourceTree = ""; }; + D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = ""; }; + D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = ""; }; D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = ""; }; D6E6F26221603F8B006A8599 /* CharacterCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounter.swift; sourceTree = ""; }; D6E6F26421604242006A8599 /* CharacterCounterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounterTests.swift; sourceTree = ""; }; @@ -862,6 +864,7 @@ D66362702136338600C9CBA2 /* ComposeViewController.swift */, D626493423BD94CE00612E6E /* CompositionAttachment.swift */, D6285B5221EA708700FE4B39 /* StatusFormat.swift */, + D620483123D2A6A3008A63EF /* CompositionState.swift */, ); path = Compose; sourceTree = ""; @@ -1108,9 +1111,9 @@ D6BED1722126661300F02DA0 /* Views */ = { isa = PBXGroup; children = ( - D6D58DF822074B74009C8DD9 /* LinkLabel.swift */, - 04496BD621625361001F1B23 /* ContentLabel.swift */, - D6C693F82162E4DB007D6A6D /* StatusContentLabel.swift */, + D620483323D3801D008A63EF /* LinkTextView.swift */, + D620483523D38075008A63EF /* ContentTextView.swift */, + D620483723D38190008A63EF /* StatusContentTextView.swift */, D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */, 04ED00B021481ED800567C53 /* SteppedProgressView.swift */, D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */, @@ -1630,9 +1633,7 @@ 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */, D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */, D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */, - D6C693F92162E4DB007D6A6D /* StatusContentLabel.swift in Sources */, D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */, - D6D58DF922074B74009C8DD9 /* LinkLabel.swift in Sources */, 0454DDAF22B462EF00B8BB8E /* GalleryExpandAnimationController.swift in Sources */, D64BC18F23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift in Sources */, D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */, @@ -1642,6 +1643,7 @@ D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */, D6945C3623AC6C09005C403C /* SavedInstancesManager.swift in Sources */, D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */, + D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */, D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */, 0411610022B442870030A9B7 /* AttachmentViewController.swift in Sources */, D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */, @@ -1652,6 +1654,7 @@ D66362712136338600C9CBA2 /* ComposeViewController.swift in Sources */, D6AC956723C4347E008C9946 /* SceneDelegate.swift in Sources */, D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */, + D620483623D38075008A63EF /* ContentTextView.swift in Sources */, D6028B9B2150811100F223B9 /* MastodonCache.swift in Sources */, D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */, D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */, @@ -1707,9 +1710,9 @@ D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */, D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */, D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */, + D620483423D3801D008A63EF /* LinkTextView.swift in Sources */, D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */, D627943523A5525100D38C68 /* StatusActivity.swift in Sources */, - 04496BD721625361001F1B23 /* ContentLabel.swift in Sources */, D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */, D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */, D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */, @@ -1729,6 +1732,7 @@ D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */, D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */, 04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */, + D620483223D2A6A3008A63EF /* CompositionState.swift in Sources */, D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */, D667E5EB21349EF80057A976 /* ProfileHeaderTableViewCell.swift in Sources */, 04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */, diff --git a/Tusker/Info.plist b/Tusker/Info.plist index 48f051d6..60a0a65f 100644 --- a/Tusker/Info.plist +++ b/Tusker/Info.plist @@ -88,6 +88,8 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationLandscapeLeft UISupportedInterfaceOrientations~ipad diff --git a/Tusker/Preferences/Preferences.swift b/Tusker/Preferences/Preferences.swift index a0e50b46..75fdaa97 100644 --- a/Tusker/Preferences/Preferences.swift +++ b/Tusker/Preferences/Preferences.swift @@ -45,6 +45,7 @@ class Preferences: Codable, ObservableObject { self.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility) self.automaticallySaveDrafts = try container.decode(Bool.self, forKey: .automaticallySaveDrafts) self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode) + self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions) self.blurAllMedia = try container.decode(Bool.self, forKey: .blurAllMedia) self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps) self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari) @@ -68,6 +69,7 @@ class Preferences: Codable, ObservableObject { try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility) try container.encode(automaticallySaveDrafts, forKey: .automaticallySaveDrafts) try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode) + try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions) try container.encode(blurAllMedia, forKey: .blurAllMedia) try container.encode(openLinksInApps, forKey: .openLinksInApps) try container.encode(useInAppSafari, forKey: .useInAppSafari) @@ -90,6 +92,7 @@ class Preferences: Codable, ObservableObject { @Published var defaultPostVisibility = Status.Visibility.public @Published var automaticallySaveDrafts = true @Published var contentWarningCopyMode = ContentWarningCopyMode.asIs + @Published var requireAttachmentDescriptions = false @Published var blurAllMedia = false @Published var openLinksInApps = true @Published var useInAppSafari = true @@ -112,6 +115,7 @@ class Preferences: Codable, ObservableObject { case defaultPostVisibility case automaticallySaveDrafts case contentWarningCopyMode + case requireAttachmentDescriptions case blurAllMedia case openLinksInApps case useInAppSafari diff --git a/Tusker/Screens/Compose/ComposeViewController.swift b/Tusker/Screens/Compose/ComposeViewController.swift index a6fc85d0..e36e5ff3 100644 --- a/Tusker/Screens/Compose/ComposeViewController.swift +++ b/Tusker/Screens/Compose/ComposeViewController.swift @@ -39,6 +39,12 @@ class ComposeViewController: UIViewController { 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! @@ -135,6 +141,7 @@ class ComposeViewController: UIViewController { // we have to set the font here, because the monospaced digit font is not available in IB charactersRemainingLabel.font = .monospacedDigitSystemFont(ofSize: 17, weight: .regular) updateCharactersRemaining() + updateAttachmentDescriptionsRequired() updatePlaceholder() NotificationCenter.default.addObserver(self, selector: #selector(contentWarningTextFieldDidChange), name: UITextField.textDidChangeNotification, object: contentWarningTextField) @@ -270,17 +277,29 @@ class ComposeViewController: UIViewController { scrollView.scrollIndicatorInsets = scrollView.contentInset } + func updateAttachmentDescriptionsRequired() { + if Preferences.shared.requireAttachmentDescriptions { + for case let mediaView as ComposeMediaView in attachmentsStackView.arrangedSubviews { + if mediaView.descriptionTextView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + compositionState.formUnion(.requiresAttachmentDescriptions) + return + } + } + + } + compositionState.subtract(.requiresAttachmentDescriptions) + } + func updateCharactersRemaining() { - // TODO: include CW char count 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 - postBarButtonItem.isEnabled = false + compositionState.formUnion(.tooManyCharacters) } else { charactersRemainingLabel.textColor = .darkGray - postBarButtonItem.isEnabled = true + compositionState.subtract(.tooManyCharacters) } charactersRemainingLabel.text = String(remaining) charactersRemainingLabel.accessibilityLabel = String(format: NSLocalizedString("%d characters remaining", comment: "compose characters remaining accessibility label"), remaining) @@ -458,7 +477,7 @@ class ComposeViewController: UIViewController { saveDraft() // disable post button while sending post request - postBarButtonItem.isEnabled = false + compositionState.formUnion(.currentlyPosting) let contentWarning: String? if contentWarningEnabled, let cwText = contentWarningTextField.text, !cwText.isEmpty { @@ -579,6 +598,7 @@ extension ComposeViewController: AssetPickerViewControllerDelegate { } func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachment]) { selectedAttachments.append(contentsOf: attachments) + updateAttachmentDescriptionsRequired() } } @@ -587,6 +607,11 @@ extension ComposeViewController: ComposeMediaViewDelegate { let index = attachmentsStackView.arrangedSubviews.firstIndex(of: mediaView)! selectedAttachments.remove(at: index) updateAddAttachmentButton() + updateAttachmentDescriptionsRequired() + } + + func descriptionTextViewDidChange(_ mediaView: ComposeMediaView) { + updateAttachmentDescriptionsRequired() } } @@ -624,7 +649,7 @@ extension ComposeViewController: DraftsTableViewControllerDelegate { updatePlaceholder() updateCharactersRemaining() - + selectedAttachments = draft.attachments.map { $0.attachment } updateAttachmentViews() @@ -635,6 +660,8 @@ extension ComposeViewController: DraftsTableViewControllerDelegate { // call the delegate method manually, since setting the text property doesn't call it mediaView.textViewDidChange(mediaView.descriptionTextView) } + + updateAttachmentDescriptionsRequired() } func draftSelectionCompleted() { diff --git a/Tusker/Screens/Compose/CompositionState.swift b/Tusker/Screens/Compose/CompositionState.swift new file mode 100644 index 00000000..950ff821 --- /dev/null +++ b/Tusker/Screens/Compose/CompositionState.swift @@ -0,0 +1,23 @@ +// +// CompositionState.swift +// Tusker +// +// Created by Shadowfacts on 1/17/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import Foundation + +struct CompositionState: OptionSet { + let rawValue: Int + + static let currentlyPosting = CompositionState(rawValue: 1 << 0) + static let tooManyCharacters = CompositionState(rawValue: 1 << 1) + static let requiresAttachmentDescriptions = CompositionState(rawValue: 1 << 2) + + static let valid: CompositionState = [] + + var isValid: Bool { + isEmpty + } +} diff --git a/Tusker/Screens/Gallery/GalleryViewController.swift b/Tusker/Screens/Gallery/GalleryViewController.swift index d582d7dd..81d76482 100644 --- a/Tusker/Screens/Gallery/GalleryViewController.swift +++ b/Tusker/Screens/Gallery/GalleryViewController.swift @@ -35,6 +35,14 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc return viewControllers?.first } + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if UIDevice.current.userInterfaceIdiom == .phone { + return .allButUpsideDown + } else { + return .all + } + } + init(attachments: [Attachment], sourcesInfo: [LargeImageViewController.SourceInfo?], startIndex: Int) { self.attachments = attachments self.sourcesInfo = sourcesInfo diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index d0d01655..dd49bcae 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -11,6 +11,14 @@ import UIKit class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { let mastodonController: MastodonController + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if UIDevice.current.userInterfaceIdiom == .phone { + return .portrait + } else { + return .all + } + } init(mastodonController: MastodonController) { self.mastodonController = mastodonController @@ -20,7 +28,7 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") - } + } override func viewDidLoad() { super.viewDidLoad() diff --git a/Tusker/Screens/Notifications/NotificationsTableViewController.swift b/Tusker/Screens/Notifications/NotificationsTableViewController.swift index ce87c5a5..1ff65686 100644 --- a/Tusker/Screens/Notifications/NotificationsTableViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsTableViewController.swift @@ -211,8 +211,10 @@ class NotificationsTableViewController: EnhancedTableViewController { self.mastodonController.cache.addAll(statuses: newNotifications.compactMap { $0.status }) self.mastodonController.cache.addAll(accounts: newNotifications.map { $0.account }) - self.newer = pagination?.newer - + if let newer = pagination?.newer { + self.newer = newer + } + DispatchQueue.main.async { self.refreshControl?.endRefreshing() diff --git a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift index 8cad403c..0be76809 100644 --- a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift +++ b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift @@ -29,6 +29,14 @@ class InstanceSelectorTableViewController: UITableViewController { var urlHandler: AnyCancellable? var currentQuery: String? + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if UIDevice.current.userInterfaceIdiom == .phone { + return .portrait + } else { + return .all + } + } + init() { super.init(style: .grouped) @@ -159,7 +167,9 @@ class InstanceSelectorTableViewController: UITableViewController { } switch item { case let .selected(instance): - delegate.didSelectInstance(url: URL(string: instance.uri)!) + // we can't just turn the URI string from the API into a URL instance, because Mastodon only includes the domain in the "URI" + let components = parseURLComponents(input: instance.uri) + delegate.didSelectInstance(url: components.url!) case let .recommended(instance): var components = URLComponents() components.scheme = "https" diff --git a/Tusker/Screens/Preferences/BehaviorPrefsView.swift b/Tusker/Screens/Preferences/BehaviorPrefsView.swift index f7074cd8..bb07a322 100644 --- a/Tusker/Screens/Preferences/BehaviorPrefsView.swift +++ b/Tusker/Screens/Preferences/BehaviorPrefsView.swift @@ -40,6 +40,9 @@ struct BehaviorPrefsView: View { Text("Prepend 're: '").tag(ContentWarningCopyMode.prependRe) Text("Don't copy").tag(ContentWarningCopyMode.doNotCopy) } + Toggle(isOn: $preferences.requireAttachmentDescriptions) { + Text("Require Attachment Descriptions") + } } } diff --git a/Tusker/Screens/Profile/ProfileTableViewController.swift b/Tusker/Screens/Profile/ProfileTableViewController.swift index 2e58f49e..8ddb9739 100644 --- a/Tusker/Screens/Profile/ProfileTableViewController.swift +++ b/Tusker/Screens/Profile/ProfileTableViewController.swift @@ -222,12 +222,32 @@ class ProfileTableViewController: EnhancedTableViewController { self.mastodonController.cache.addAll(statuses: newStatuses) self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0) - self.newer = pagination?.newer + if let newer = pagination?.newer { + self.newer = newer + } DispatchQueue.main.async { self.refreshControl?.endRefreshing() } } + + getStatuses(onlyPinned: true) { (response) in + guard case let .success(newPinnedStatuses, _) = response else { fatalError() } + self.mastodonController.cache.addAll(statuses: newPinnedStatuses) + + let oldPinnedStatuses = self.pinnedStatuses + var pinnedStatuses = [(id: String, state: StatusState)]() + for status in newPinnedStatuses { + let state: StatusState + if let (_, oldState) = oldPinnedStatuses.first(where: { $0.id == status.id }) { + state = oldState + } else { + state = .unknown + } + pinnedStatuses.append((status.id, state)) + } + self.pinnedStatuses = pinnedStatuses + } } @objc func composePressed(_ sender: Any) { @@ -247,7 +267,7 @@ extension ProfileTableViewController: StatusTableViewCellDelegate { } extension ProfileTableViewController: ProfileHeaderTableViewCellDelegate { - func showMoreOptions() { + func showMoreOptions(cell: ProfileHeaderTableViewCell) { let account = mastodonController.cache.account(for: accountID)! mastodonController.cache.relationship(for: account.id) { [weak self] (relationship) in @@ -262,6 +282,7 @@ extension ProfileTableViewController: ProfileHeaderTableViewCellDelegate { DispatchQueue.main.async { let activityController = UIActivityViewController(activityItems: [account.url, account], applicationActivities: customActivities) activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(viewController: self, url: account.url) + activityController.popoverPresentationController?.sourceView = cell.moreButtonVisualEffectView self.present(activityController, animated: true) } } diff --git a/Tusker/Screens/Timeline/TimelineTableViewController.swift b/Tusker/Screens/Timeline/TimelineTableViewController.swift index c94b45d2..12022b00 100644 --- a/Tusker/Screens/Timeline/TimelineTableViewController.swift +++ b/Tusker/Screens/Timeline/TimelineTableViewController.swift @@ -131,6 +131,11 @@ class TimelineTableViewController: EnhancedTableViewController { self.newer = pagination?.newer self.mastodonController.cache.addAll(statuses: newStatuses) self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0) + + if let newer = pagination?.newer { + self.newer = newer + } + DispatchQueue.main.async { self.refreshControl?.endRefreshing() diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index 346d78a3..411fddae 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -24,7 +24,7 @@ extension MenuPreviewProvider { private var mastodonController: MastodonController? { navigationDelegate?.apiController } - func actionsForProfile(accountID: String) -> [UIAction] { + func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIAction] { guard let mastodonController = mastodonController, let account = mastodonController.cache.account(for: accountID) else { return [] } return [ @@ -35,27 +35,27 @@ extension MenuPreviewProvider { self.navigationDelegate?.compose(mentioning: account.acct) }), createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { (_) in - self.navigationDelegate?.showMoreOptions(forAccount: accountID) + self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView) }) ] } - func actionsForURL(_ url: URL) -> [UIAction] { + func actionsForURL(_ url: URL, sourceView: UIView?) -> [UIAction] { return [ createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in self.navigationDelegate?.selected(url: url) }), createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { (_) in - self.navigationDelegate?.showMoreOptions(forURL: url) + self.navigationDelegate?.showMoreOptions(forURL: url, sourceView: sourceView) }) ] } - func actionsForHashtag(_ hashtag: Hashtag) -> [UIAction] { - return actionsForURL(hashtag.url) + func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIAction] { + return actionsForURL(hashtag.url, sourceView: sourceView) } - func actionsForStatus(statusID: String) -> [UIAction] { + func actionsForStatus(statusID: String, sourceView: UIView?) -> [UIAction] { guard let mastodonController = mastodonController, let status = mastodonController.cache.status(for: statusID) else { return [] } return [ @@ -66,7 +66,7 @@ extension MenuPreviewProvider { self.navigationDelegate?.selected(url: status.url!) }), createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { (_) in - self.navigationDelegate?.showMoreOptions(forStatus: statusID) + self.navigationDelegate?.showMoreOptions(forStatus: statusID, sourceView: sourceView) }) ] } diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index f7b04f20..8915fe16 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -46,11 +46,11 @@ protocol TuskerNavigationDelegate { func showGallery(attachments: [Attachment], sourceViews: [UIImageView?], startIndex: Int) - func showMoreOptions(forStatus statusID: String) + func showMoreOptions(forStatus statusID: String, sourceView: UIView?) - func showMoreOptions(forAccount accountID: String) + func showMoreOptions(forAccount accountID: String, sourceView: UIView?) - func showMoreOptions(forURL url: URL) + func showMoreOptions(forURL url: URL, sourceView: UIView?) func showFollowedByList(accountIDs: [String]) @@ -60,7 +60,11 @@ protocol TuskerNavigationDelegate { extension TuskerNavigationDelegate where Self: UIViewController { func show(_ vc: UIViewController) { - show(vc, sender: self) + if vc is LargeImageViewController || vc is GalleryViewController || vc is SFSafariViewController { + present(vc, animated: true) + } else { + show(vc, sender: self) + } } func selected(account accountID: String) { @@ -184,7 +188,7 @@ extension TuskerNavigationDelegate where Self: UIViewController { present(gallery(attachments: attachments, sourceViews: sourceViews, startIndex: startIndex), animated: true) } - private func moreOptions(forURL url: URL) -> UIViewController { + private func moreOptions(forURL url: URL) -> UIActivityViewController { let customActivites: [UIActivity] = [ OpenInSafariActivity() ] @@ -193,7 +197,7 @@ extension TuskerNavigationDelegate where Self: UIViewController { return activityController } - private func moreOptions(forStatus statusID: String) -> UIViewController { + private func moreOptions(forStatus statusID: String) -> UIActivityViewController { guard let status = apiController.cache.status(for: statusID) else { fatalError("Missing cached status \(statusID)") } guard let url = status.url else { fatalError("Missing url for status \(statusID)") } var customActivites: [UIActivity] = [OpenInSafariActivity()] @@ -212,21 +216,27 @@ extension TuskerNavigationDelegate where Self: UIViewController { return activityController } - private func moreOptions(forAccount accountID: String) -> UIViewController { + private func moreOptions(forAccount accountID: String) -> UIActivityViewController { guard let account = apiController.cache.account(for: accountID) else { fatalError("Missing cached account \(accountID)") } return moreOptions(forURL: account.url) } - func showMoreOptions(forStatus statusID: String) { - present(moreOptions(forStatus: statusID), animated: true) + func showMoreOptions(forStatus statusID: String, sourceView: UIView?) { + let vc = moreOptions(forStatus: statusID) + vc.popoverPresentationController?.sourceView = sourceView + present(vc, animated: true) } - func showMoreOptions(forURL url: URL) { - present(moreOptions(forURL: url), animated: true) + func showMoreOptions(forURL url: URL, sourceView: UIView?) { + let vc = moreOptions(forURL: url) + vc.popoverPresentationController?.sourceView = sourceView + present(vc, animated: true) } - func showMoreOptions(forAccount accountID: String) { - present(moreOptions(forAccount: accountID), animated: true) + func showMoreOptions(forAccount accountID: String, sourceView: UIView?) { + let vc = moreOptions(forAccount: accountID) + vc.popoverPresentationController?.sourceView = sourceView + present(vc, animated: true) } func showFollowedByList(accountIDs: [String]) { diff --git a/Tusker/Views/Account Cell/AccountTableViewCell.swift b/Tusker/Views/Account Cell/AccountTableViewCell.swift index 00d92346..cb93b6bd 100644 --- a/Tusker/Views/Account Cell/AccountTableViewCell.swift +++ b/Tusker/Views/Account Cell/AccountTableViewCell.swift @@ -70,10 +70,9 @@ extension AccountTableViewCell: MenuPreviewProvider { func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { guard let mastodonController = mastodonController else { return nil } - return (content: { - ProfileTableViewController(accountID: self.accountID, mastodonController: mastodonController) - }, actions: { - self.actionsForProfile(accountID: self.accountID) - }) + return ( + content: { ProfileTableViewController(accountID: self.accountID, mastodonController: mastodonController) }, + actions: { self.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView) } + ) } } diff --git a/Tusker/Views/Compose Media/ComposeMediaView.swift b/Tusker/Views/Compose Media/ComposeMediaView.swift index d11a418b..54c1efd7 100644 --- a/Tusker/Views/Compose Media/ComposeMediaView.swift +++ b/Tusker/Views/Compose Media/ComposeMediaView.swift @@ -12,6 +12,7 @@ import AVFoundation protocol ComposeMediaViewDelegate { func didRemoveMedia(_ mediaView: ComposeMediaView) + func descriptionTextViewDidChange(_ mediaView: ComposeMediaView) } class ComposeMediaView: UIView { @@ -69,5 +70,6 @@ class ComposeMediaView: UIView { extension ComposeMediaView: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { placeholderLabel.isHidden = !descriptionTextView.text.isEmpty + delegate?.descriptionTextViewDidChange(self) } } diff --git a/Tusker/Views/Compose Status Reply/ComposeStatusReplyView.swift b/Tusker/Views/Compose Status Reply/ComposeStatusReplyView.swift index ccbcca3d..1242ae4b 100644 --- a/Tusker/Views/Compose Status Reply/ComposeStatusReplyView.swift +++ b/Tusker/Views/Compose Status Reply/ComposeStatusReplyView.swift @@ -14,7 +14,7 @@ class ComposeStatusReplyView: UIView { @IBOutlet weak var avatarImageView: UIImageView! @IBOutlet weak var displayNameLabel: UILabel! @IBOutlet weak var usernameLabel: UILabel! - @IBOutlet weak var contentLabel: StatusContentLabel! + @IBOutlet weak var statusContentTextView: StatusContentTextView! static func create() -> ComposeStatusReplyView { return UINib(nibName: "ComposeStatusReplyView", bundle: nil).instantiate(withOwner: nil, options: nil).first as! ComposeStatusReplyView @@ -34,7 +34,7 @@ class ComposeStatusReplyView: UIView { func updateUI(for status: Status) { displayNameLabel.text = status.account.realDisplayName usernameLabel.text = "@\(status.account.acct)" - contentLabel.statusID = status.id + statusContentTextView.statusID = status.id ImageCache.avatars.get(status.account.avatar) { (data) in guard let data = data else { return } diff --git a/Tusker/Views/Compose Status Reply/ComposeStatusReplyView.xib b/Tusker/Views/Compose Status Reply/ComposeStatusReplyView.xib index 43fe156c..dbebbf91 100644 --- a/Tusker/Views/Compose Status Reply/ComposeStatusReplyView.xib +++ b/Tusker/Views/Compose Status Reply/ComposeStatusReplyView.xib @@ -1,8 +1,8 @@ - + - + @@ -39,23 +39,24 @@ - + + + - - - + - + + @@ -72,8 +73,8 @@ - + diff --git a/Tusker/Views/ContentLabel.swift b/Tusker/Views/ContentLabel.swift deleted file mode 100644 index b53e2d02..00000000 --- a/Tusker/Views/ContentLabel.swift +++ /dev/null @@ -1,231 +0,0 @@ -// -// ContentLabel.swift -// Tusker -// -// Created by Shadowfacts on 10/1/18. -// Copyright © 2018 Shadowfacts. All rights reserved. -// - -import UIKit -import SafariServices -import Pachyderm -import SwiftSoup - -class ContentLabel: LinkLabel { - - private static let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) - - var navigationDelegate: TuskerNavigationDelegate? - - // MARK: - Emojis - func setEmojis(_ emojis: [Emoji]) { - guard !emojis.isEmpty else { return } - - let group = DispatchGroup() - - let mutAttrString = NSMutableAttributedString(attributedString: self.attributedText!) - let string = mutAttrString.string - let matches = ContentLabel.emojiRegex.matches(in: string, options: [], range: NSRange(location: 0, length: mutAttrString.length)) - for match in matches.reversed() { - let shortcode = (string as NSString).substring(with: match.range(at: 1)) - guard let emoji = emojis.first(where: { $0.shortcode == shortcode }) else { - continue - } - - group.enter() - ImageCache.emojis.get(emoji.url) { (data) in - guard let data = data, let image = UIImage(data: data) else { - group.leave() - return - } - DispatchQueue.main.async { - let attachment = self.createEmojiTextAttachment(image: image, index: match.range.location) - mutAttrString.replaceCharacters(in: match.range, with: NSAttributedString(attachment: attachment)) - - group.leave() - } - } - } - - group.notify(queue: .main) { - self.attributedText = mutAttrString - self.setNeedsLayout() - self.setNeedsDisplay() - } - } - - // Based on https://github.com/ReticentJohn/Amaroq/blob/7c5b7088eb9fd1611dcb0f47d43bf8df093e142c/DireFloof/InlineImageHelpers.m - func createEmojiTextAttachment(image: UIImage, index: Int) -> NSTextAttachment { - let font = self.font! - - let adjustedCapHeight = font.capHeight - 1 - var imageSizeMatchingFontSize = CGSize(width: image.size.width * (adjustedCapHeight / image.size.height), height: adjustedCapHeight) - - let defaultScale: CGFloat = 1.4 - imageSizeMatchingFontSize = CGSize(width: imageSizeMatchingFontSize.width * defaultScale, height: imageSizeMatchingFontSize.height * defaultScale) - let textColor = self.textColor! - - UIGraphicsBeginImageContextWithOptions(imageSizeMatchingFontSize, false, 0.0) - textColor.set() - image.draw(in: CGRect(origin: .zero, size: imageSizeMatchingFontSize)) - let attachmentImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - - let attachment = NSTextAttachment() - attachment.image = attachmentImage - return attachment - } - - // MARK: - HTML Parsing - func setTextFromHtml(_ html: String) { - let doc = try! SwiftSoup.parse(html) - let body = doc.body()! - - let (attributedText, links) = attributedTextForHTMLNode(body) - let mutAttrString = NSMutableAttributedString(attributedString: attributedText) - - // only trailing whitespace can be trimmed here - // when posting an attachment without any text, pleromafe includes U+200B ZERO WIDTH SPACE at the beginning - // this would get trimmed and cause range out of bounds crashes - mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines) - - self.links = [] - let linkAttributes: [NSAttributedString.Key: Any] = [ - .foregroundColor: UIColor.systemBlue, - ] - for (range, url) in links { - mutAttrString.addAttributes(linkAttributes, range: range) - self.links.append(Link(range: range, url: url)) - } - - self.attributedText = mutAttrString - } - - private func attributedTextForHTMLNode(_ node: Node) -> (NSAttributedString, [NSRange: URL]) { - switch node { - case let node as TextNode: - return (NSAttributedString(string: node.text()), [:]) - case let node as Element: - var links = [NSRange: URL]() - let attributed = NSMutableAttributedString() - for child in node.getChildNodes() { - let (text, childLinks) = attributedTextForHTMLNode(child) - for (range, url) in childLinks { - let newRange = NSRange(location: range.location + attributed.length, length: range.length) - links[newRange] = url - } - attributed.append(text) - } - - switch node.tagName() { - case "br": - attributed.append(NSAttributedString(string: "\n")) - case "a": - if let link = try? node.attr("href"), - let url = URL(string: link) { - links[attributed.fullRange] = url - } - case "p": - attributed.append(NSAttributedString(string: "\n\n")) - case "em", "i": - let currentFont: UIFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? self.font - attributed.addAttribute(.font, value: currentFont.addingTraits(.traitItalic)!, range: attributed.fullRange) - case "strong", "b": - let currentFont: UIFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? self.font - attributed.addAttribute(.font, value: currentFont.addingTraits(.traitBold)!, range: attributed.fullRange) - case "del": - attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange) - case "code": - attributed.addAttribute(.font, value: UIFont(name: "Menlo", size: font!.pointSize)!, range: attributed.fullRange) - case "pre": - attributed.addAttribute(.font, value: UIFont(name: "Menlo", size: font!.pointSize)!, range: attributed.fullRange) - attributed.append(NSAttributedString(string: "\n\n")) - case "ol", "ul": - attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines) - attributed.append(NSAttributedString(string: "\n")) - break - case "li": - let parentEl = node.parent()! - let parentTag = parentEl.tagName() - let bullet: NSAttributedString - if parentTag == "ol" { - let index = (try? node.elementSiblingIndex()) ?? 0 - // we use the monospace digit font so that the periods of all the list items line up - bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: font!.pointSize, weight: .regular)]) - } else if parentTag == "ul" { - bullet = NSAttributedString(string: "\u{2022}\t") - } else { - bullet = NSAttributedString(string: "") - } - // inserting bullets at the beginning of the string shifts all the links down, so we adjust the link ranges - for (range, url) in links { - let newRange = NSRange(location: range.location + bullet.length - 1, length: range.length) - links[newRange] = url - links.removeValue(forKey: range) - } - attributed.insert(bullet, at: 0) - attributed.append(NSAttributedString(string: "\n")) - default: - break - } - - return (attributed, links) - default: - fatalError("Unexpected node type: \(type(of: node))") - } - } - - func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController { - let text = (self.text! as NSString).substring(with: range) - - if let navigationDelegate = navigationDelegate { - if let mention = getMention(for: url, text: text) { - return ProfileTableViewController(accountID: mention.id, mastodonController: navigationDelegate.apiController) - } else if let tag = getHashtag(for: url, text: text) { - return HashtagTimelineViewController(for: tag, mastodonController: navigationDelegate.apiController) - } - - } - return SFSafariViewController(url: url) - } - - func getViewController(forLinkAt point: CGPoint) -> UIViewController? { - guard let link = getLink(atPoint: point) else { - return nil - } - return getViewController(forLink: link.url, inRange: link.range) - } - - // MARK: - Interaction - - override func linkTapped(_ link: LinkLabel.Link) { - let text = (self.text! as NSString).substring(with: link.range) - - if let mention = getMention(for: link.url, text: text) { - navigationDelegate?.selected(mention: mention) - } else if let tag = getHashtag(for: link.url, text: text) { - navigationDelegate?.selected(tag: tag) - } else { - navigationDelegate?.selected(url: link.url) - } - } - - override func linkLongPressed(_ link: LinkLabel.Link) { - navigationDelegate?.showMoreOptions(forURL: link.url) - } - - // MARK: - Navigation - func getMention(for url: URL, text: String) -> Mention? { - return nil - } - - func getHashtag(for url: URL, text: String) -> Hashtag? { - if text.starts(with: "#") { - let tag = String(text.dropFirst()) - return Hashtag(name: tag, url: url) - } else { - return nil - } - } - -} diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift new file mode 100644 index 00000000..b0f6cb80 --- /dev/null +++ b/Tusker/Views/ContentTextView.swift @@ -0,0 +1,294 @@ +// +// ContentTextView.swift +// Tusker +// +// Created by Shadowfacts on 1/18/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit +import SwiftSoup +import Pachyderm +import SafariServices + +private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) + +class ContentTextView: LinkTextView { + + // todo: should be weak + var navigationDelegate: TuskerNavigationDelegate? + var mastodonController: MastodonController? { navigationDelegate?.apiController } + + var defaultFont: UIFont = .systemFont(ofSize: 17) + var defaultColor: UIColor = .label + + override func awakeFromNib() { + super.awakeFromNib() + + delegate = self + + addInteraction(UIContextMenuInteraction(delegate: self)) + + textDragInteraction?.isEnabled = false + + textContainerInset = .zero + textContainer.lineFragmentPadding = 0 + } + + // MARK: - Emojis + func setEmojis(_ emojis: [Emoji]) { + guard !emojis.isEmpty else { return } + + let emojiImages = CachedDictionary(name: "ContentTextView Emoji Images") + + let group = DispatchGroup() + + for emoji in emojis { + group.enter() + ImageCache.emojis.get(emoji.url) { (data) in + defer { group.leave() } + guard let data = data, let image = UIImage(data: data) else { + return + } + emojiImages[emoji.shortcode] = image + } + } + + group.notify(queue: .main) { + let mutAttrString = NSMutableAttributedString(attributedString: self.attributedText!) + let string = mutAttrString.string + let matches = emojiRegex.matches(in: string, options: [], range: mutAttrString.fullRange) + // replaces the emojis started from the end of the string as to not alter the indexes of the other emojis + for match in matches.reversed() { + let shortcode = (string as NSString).substring(with: match.range(at: 1)) + guard let emojiImage = emojiImages[shortcode] else { + continue + } + + let attachment = self.createEmojiTextAttachment(image: emojiImage, index: match.range.location) + let attachmentStr = NSAttributedString(attachment: attachment) + mutAttrString.replaceCharacters(in: match.range, with: attachmentStr) + } + + self.attributedText = mutAttrString + self.setNeedsLayout() + self.setNeedsDisplay() + } + } + + // Based on https://github.com/ReticentJohn/Amaroq/blob/7c5b7088eb9fd1611dcb0f47d43bf8df093e142c/DireFloof/InlineImageHelpers.m + private func createEmojiTextAttachment(image: UIImage, index: Int) -> NSTextAttachment { + let font = self.font! + + let adjustedCapHeight = font.capHeight - 1 + var imageSizeMatchingFontSize = CGSize(width: image.size.width * (adjustedCapHeight / image.size.height), height: adjustedCapHeight) + + let defaultScale: CGFloat = 1.4 + imageSizeMatchingFontSize = CGSize(width: imageSizeMatchingFontSize.width * defaultScale, height: imageSizeMatchingFontSize.height * defaultScale) + let textColor = self.textColor ?? UIColor.label + + UIGraphicsBeginImageContextWithOptions(imageSizeMatchingFontSize, false, 0.0) + textColor.set() + image.draw(in: CGRect(origin: .zero, size: imageSizeMatchingFontSize)) + let attachmentImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + let attachment = NSTextAttachment() + attachment.image = attachmentImage + return attachment + } + + // MARK: - HTML Parsing + func setTextFromHtml(_ html: String) { + let doc = try! SwiftSoup.parse(html) + let body = doc.body()! + + let attributedText = attributedTextForHTMLNode(body) + let mutAttrString = NSMutableAttributedString(attributedString: attributedText) + mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines) + + self.attributedText = mutAttrString + } + + func attributedTextForHTMLNode(_ node: Node, usePreformattedText: Bool = false) -> NSAttributedString { + switch node { + case let node as TextNode: + let text: String + if usePreformattedText { + text = node.getWholeText() + } else { + text = node.text() + } + return NSAttributedString(string: text, attributes: [.font: defaultFont, .foregroundColor: defaultColor]) + case let node as Element: + let attributed = NSMutableAttributedString(string: "", attributes: [.font: defaultFont, .foregroundColor: defaultColor]) + for child in node.getChildNodes() { + attributed.append(attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre")) + } + + switch node.tagName() { + case "br": + attributed.append(NSAttributedString(string: "\n")) + case "a": + if let link = try? node.attr("href"), + let url = URL(string: link) { + attributed.addAttribute(.link, value: url, range: attributed.fullRange) + } + case "p": + attributed.append(NSAttributedString(string: "\n\n")) + case "em", "i": + let currentFont: UIFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? self.font! + attributed.addAttribute(.font, value: currentFont.addingTraits(.traitItalic)!, range: attributed.fullRange) + case "strong", "b": + let currentFont: UIFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? self.font! + attributed.addAttribute(.font, value: currentFont.addingTraits(.traitBold)!, range: attributed.fullRange) + case "del": + attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange) + case "code": + attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: self.font!.pointSize, weight: .regular), range: attributed.fullRange) + case "pre": + attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: self.font!.pointSize, weight: .regular), range: attributed.fullRange) + attributed.append(NSAttributedString(string: "\n\n")) + case "ol", "ul": + attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines) + attributed.append(NSAttributedString(string: "\n\n")) + case "li": + let parentEl = node.parent()! + let parentTag = parentEl.tagName() + let bullet: NSAttributedString + if parentTag == "ol" { + let index = (try? node.elementSiblingIndex()) ?? 0 + // we use the monospace digit font so that the periods of all the list items line up + bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: self.font!.pointSize, weight: .regular)]) + } else if parentTag == "ul" { + bullet = NSAttributedString(string: "\u{2022}\t") + } else { + bullet = NSAttributedString() + } + attributed.insert(bullet, at: 0) + attributed.append(NSAttributedString(string: "\n")) + default: + break + } + + return attributed + default: + fatalError("Unexpected node type \(type(of: node))") + } + } + + // MARK: - Interaction + + // only accept touches that are over a link + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if getLinkAtPoint(point) != nil || isSelectable { + return super.hitTest(point, with: event) + } else { + return nil + } + } + + func getLinkAtPoint(_ point: CGPoint) -> (URL, NSRange)? { + let locationInTextContainer = CGPoint(x: point.x - textContainerInset.left, y: point.y - textContainerInset.top) + var partialFraction: CGFloat = 0 + let characterIndex = layoutManager.characterIndex(for: locationInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: &partialFraction) + if characterIndex < textStorage.length { + var range = NSRange() + if let link = textStorage.attribute(.link, at: characterIndex, longestEffectiveRange: &range, in: textStorage.fullRange) as? URL { + return (link, range) + } + } + return nil + } + + // MARK: - Navigation + + func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController { + let text = (self.text as NSString).substring(with: range) + + if let mention = getMention(for: url, text: text) { + return ProfileTableViewController(accountID: mention.id, mastodonController: mastodonController!) + } else if let tag = getHashtag(for: url, text: text) { + return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!) + } else { + return SFSafariViewController(url: url) + } + } + + open func getMention(for url: URL, text: String) -> Mention? { + return nil + } + + open func getHashtag(for url: URL, text: String) -> Hashtag? { + if text.starts(with: "#") { + let tag = String(text.dropFirst()) + return Hashtag(name: tag, url: url) + } else { + return nil + } + } + +} + +extension ContentTextView: UITextViewDelegate { + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + let text = (self.text as NSString).substring(with: characterRange) + switch interaction { + case .invokeDefaultAction: + if let mention = getMention(for: URL, text: text) { + navigationDelegate?.selected(mention: mention) + } else if let tag = getHashtag(for: URL, text: text) { + navigationDelegate?.selected(tag: tag) + } else { + navigationDelegate?.selected(url: URL) + } + case .presentActions: + print("present actions") + case .preview: + print("preview") + @unknown default: + break + } + + return false + } +} + +extension ContentTextView: MenuPreviewProvider { + func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { + fatalError("unimplemented") + } +} + +extension ContentTextView: UIContextMenuInteractionDelegate { + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + if let (link, range) = getLinkAtPoint(location) { + let preview: UIContextMenuContentPreviewProvider = { + self.getViewController(forLink: link, inRange: range) + } + let actions: UIContextMenuActionProvider = { (_) in + let text = (self.text as NSString).substring(with: range) + let actions: [UIAction] + if let mention = self.getMention(for: link, text: text) { + actions = self.actionsForProfile(accountID: mention.id, sourceView: self) + } else if let tag = self.getHashtag(for: link, text: text) { + actions = self.actionsForHashtag(tag, sourceView: self) + } else { + actions = self.actionsForURL(link, sourceView: self) + } + return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions) + } + return UIContextMenuConfiguration(identifier: nil, previewProvider: preview, actionProvider: actions) + } else { + return nil + } + } + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + if let viewController = animator.previewViewController { + animator.preferredCommitStyle = .pop + animator.addCompletion { + self.navigationDelegate?.show(viewController) + } + } + } +} diff --git a/Tusker/Views/Instance Cell/InstanceTableViewCell.swift b/Tusker/Views/Instance Cell/InstanceTableViewCell.swift index 624caf15..920e068f 100644 --- a/Tusker/Views/Instance Cell/InstanceTableViewCell.swift +++ b/Tusker/Views/Instance Cell/InstanceTableViewCell.swift @@ -14,7 +14,7 @@ class InstanceTableViewCell: UITableViewCell { @IBOutlet weak var thumbnailImageView: UIImageView! @IBOutlet weak var domainLabel: UILabel! @IBOutlet weak var adultLabel: UILabel! - @IBOutlet weak var descriptionLabel: ContentLabel! + @IBOutlet weak var descriptionTextView: ContentTextView! var instance: Instance? var selectorInstance: InstanceSelector.Instance? @@ -37,7 +37,7 @@ class InstanceTableViewCell: UITableViewCell { domainLabel.text = instance.domain adultLabel.isHidden = instance.category != .adult - descriptionLabel.setTextFromHtml(instance.description) + descriptionTextView.setTextFromHtml(instance.description) updateThumbnail(url: instance.proxiedThumbnailURL) } @@ -47,7 +47,7 @@ class InstanceTableViewCell: UITableViewCell { domainLabel.text = URLComponents(string: instance.uri)?.host ?? instance.uri adultLabel.isHidden = true - descriptionLabel.setTextFromHtml(instance.description) + descriptionTextView.setTextFromHtml(instance.description) if let thumbnail = instance.thumbnail { updateThumbnail(url: thumbnail) diff --git a/Tusker/Views/Instance Cell/InstanceTableViewCell.xib b/Tusker/Views/Instance Cell/InstanceTableViewCell.xib index ed7702f0..e8a0881c 100644 --- a/Tusker/Views/Instance Cell/InstanceTableViewCell.xib +++ b/Tusker/Views/Instance Cell/InstanceTableViewCell.xib @@ -1,8 +1,8 @@ - + - + @@ -27,7 +27,7 @@ - + @@ -51,15 +51,12 @@ - + + @@ -75,7 +72,7 @@ - + diff --git a/Tusker/Views/LinkLabel.swift b/Tusker/Views/LinkLabel.swift deleted file mode 100644 index aff52783..00000000 --- a/Tusker/Views/LinkLabel.swift +++ /dev/null @@ -1,185 +0,0 @@ -// -// LinkLabel.swift -// Tusker -// -// Created by Shadowfacts on 2/3/19. -// Copyright © 2019 Shadowfacts. All rights reserved. -// - -import UIKit - -class LinkLabel: UILabel { - - typealias Link = (range: NSRange, url: URL) - - let layoutManager = NSLayoutManager() - let textContainer = NSTextContainer(size: .zero) - var textStorage: NSTextStorage! - - var links = [Link]() - - var selectedLinkAttributes: [NSAttributedString.Key: Any] = [ -// .backgroundColor: UIColor(hue: 0, saturation: 0, brightness: 0.9, alpha: 1) - .backgroundColor: UIColor.secondarySystemBackground - ] - - var selectedLinkRange: NSRange? { - didSet { - if let oldValue = oldValue { - removeSelectedLinkAttributes(oldValue) - } - if let newValue = selectedLinkRange { - addSelectedLinkAttributes(newValue) - } - } - } - - override var attributedText: NSAttributedString? { - didSet { - guard let attributedText = attributedText else { return } - - textStorage?.removeLayoutManager(layoutManager) - - textStorage = NSTextStorage(attributedString: attributedText) - textStorage.addLayoutManager(layoutManager) - } - } - - override var text: String? { - willSet { - fatalError("LinkLabel does not support non-attributed text") - } - } - - override func awakeFromNib() { - super.awakeFromNib() - - isUserInteractionEnabled = true - let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(labelTapped(_:))) - tapRecognizer.delegate = self - addGestureRecognizer(tapRecognizer) - let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(labelLongPressed(_:))) - longPressRecognizer.delegate = self - addGestureRecognizer(longPressRecognizer) - - layoutManager.addTextContainer(textContainer) - textContainer.lineFragmentPadding = 0.0 - textContainer.lineBreakMode = lineBreakMode - textContainer.maximumNumberOfLines = numberOfLines - } - - override func layoutSubviews() { - super.layoutSubviews() - - textContainer.size = bounds.size - } - - func getLink(atPoint point: CGPoint) -> Link? { - let labelSize = bounds.size - let textBoundingBox = layoutManager.usedRect(for: textContainer) - let textContainerOffset = CGPoint(x: (labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x, - y: (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y) - let locationOfTouchInTextContainer = CGPoint(x: point.x - textContainerOffset.x, - y: point.y - textContainerOffset.y) - // let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) - let indexOfCharacter = layoutManager.glyphIndex(for: locationOfTouchInTextContainer, in: textContainer) - - if let link = links.first(where: { $0.range.contains(indexOfCharacter) }) { - return link - } else { - return nil - } - } - - func addSelectedLinkAttributes(_ range: NSRange) { - let mutAttrString = NSMutableAttributedString(attributedString: attributedText!) - mutAttrString.addAttributes(selectedLinkAttributes, range: range) - self.attributedText = mutAttrString - setNeedsDisplay() - } - - func removeSelectedLinkAttributes(_ range: NSRange) { - let mutAttrString = NSMutableAttributedString(attributedString: attributedText!) - selectedLinkAttributes.keys.forEach { mutAttrString.removeAttribute($0, range: range) } - self.attributedText = mutAttrString - setNeedsDisplay() - } - - // MARK: - Interaction - override func touchesBegan(_ touches: Set, with event: UIEvent?) { - if let touch = touches.first, onTouch(touch) { - return - } - super.touchesBegan(touches, with: event) - } - - override func touchesMoved(_ touches: Set, with event: UIEvent?) { - if let touch = touches.first, onTouch(touch) { - return - } - super.touchesMoved(touches, with: event) - } - - override func touchesEnded(_ touches: Set, with event: UIEvent?) { - if let touch = touches.first, onTouch(touch) { - return - } - super.touchesEnded(touches, with: event) - } - - override func touchesCancelled(_ touches: Set, with event: UIEvent?) { - if let touch = touches.first, onTouch(touch) { - return - } - super.touchesCancelled(touches, with: event) - } - - func onTouch(_ touch: UITouch) -> Bool { - let location = touch.location(in: self) - let link = getLink(atPoint: location) - - switch touch.phase { - case .began, .moved: - selectedLinkRange = link?.range - case .cancelled, .ended: - selectedLinkRange = nil - default: - break - } - - return link != nil - } - - @objc func labelTapped(_ recognizer: UITapGestureRecognizer) { - let location = recognizer.location(in: self) - guard let link = getLink(atPoint: location) else { - return - } - - linkTapped(link) - } - - @objc func labelLongPressed(_ recognizer: UILongPressGestureRecognizer) { - let location = recognizer.location(in: self) - guard let link = getLink(atPoint: location) else { - return - } - - linkLongPressed(link) - } - - func linkTapped(_ link: Link) { - } - - func linkLongPressed(_ link: Link) { - } - -} - -extension LinkLabel: UIGestureRecognizerDelegate { - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - let location = touch.location(in: self) - let link = getLink(atPoint: location) - return link != nil - } -} diff --git a/Tusker/Views/LinkTextView.swift b/Tusker/Views/LinkTextView.swift new file mode 100644 index 00000000..69defc8d --- /dev/null +++ b/Tusker/Views/LinkTextView.swift @@ -0,0 +1,22 @@ +// +// LinkTextView.swift +// Tusker +// +// Created by Shadowfacts on 1/18/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit + +class LinkTextView: UITextView { + + override func awakeFromNib() { + super.awakeFromNib() + + delaysContentTouches = false + isScrollEnabled = false + isEditable = false + isUserInteractionEnabled = true + } + +} diff --git a/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.xib b/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.xib index 77f25d9e..b9c06cdb 100644 --- a/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.xib +++ b/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.xib @@ -1,8 +1,8 @@ - + - + diff --git a/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.swift b/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.swift index 2b246c2f..a3df7918 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.swift +++ b/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.swift @@ -10,7 +10,7 @@ import UIKit import Pachyderm protocol ProfileHeaderTableViewCellDelegate: TuskerNavigationDelegate { - func showMoreOptions() + func showMoreOptions(cell: ProfileHeaderTableViewCell) } class ProfileHeaderTableViewCell: UITableViewCell { @@ -24,7 +24,7 @@ class ProfileHeaderTableViewCell: UITableViewCell { @IBOutlet weak var displayNameLabel: UILabel! @IBOutlet weak var usernameLabel: UILabel! @IBOutlet weak var followsYouLabel: UILabel! - @IBOutlet weak var noteLabel: StatusContentLabel! + @IBOutlet weak var noteTextView: StatusContentTextView! @IBOutlet weak var fieldsStackView: UIStackView! @IBOutlet weak var fieldNamesStackView: UIStackView! @IBOutlet weak var fieldValuesStack: UIStackView! @@ -79,9 +79,9 @@ class ProfileHeaderTableViewCell: UITableViewCell { } } - noteLabel.navigationDelegate = delegate - noteLabel.setTextFromHtml(account.note) - noteLabel.setEmojis(account.emojis) + noteTextView.navigationDelegate = delegate + noteTextView.setTextFromHtml(account.note) + noteTextView.setEmojis(account.emojis) if accountID != mastodonController.account.id { // don't show relationship label for the user's own account @@ -104,16 +104,18 @@ class ProfileHeaderTableViewCell: UITableViewCell { nameLabel.text = field.name nameLabel.font = .boldSystemFont(ofSize: 17) nameLabel.textAlignment = .right + nameLabel.numberOfLines = 0 fieldNamesStackView.addArrangedSubview(nameLabel) - let valueLabel = ContentLabel() - valueLabel.setTextFromHtml(field.value) - valueLabel.setEmojis(account.emojis) - valueLabel.font = .systemFont(ofSize: 17) - valueLabel.textAlignment = .left - valueLabel.awakeFromNib() // TODO: this shouldn't be necessary - valueLabel.navigationDelegate = delegate - fieldValuesStack.addArrangedSubview(valueLabel) + let valueTextView = ContentTextView() + valueTextView.isSelectable = false + valueTextView.font = .systemFont(ofSize: 17) + valueTextView.setTextFromHtml(field.value) + valueTextView.setEmojis(account.emojis) + valueTextView.textAlignment = .left + valueTextView.awakeFromNib() + valueTextView.navigationDelegate = delegate + fieldValuesStack.addArrangedSubview(valueTextView) } } else { fieldsStackView.isHidden = true @@ -138,7 +140,7 @@ class ProfileHeaderTableViewCell: UITableViewCell { } @objc func morePressed() { - delegate?.showMoreOptions() + delegate?.showMoreOptions(cell: self) } @objc func avatarPressed() { @@ -151,27 +153,27 @@ class ProfileHeaderTableViewCell: UITableViewCell { } -extension ProfileHeaderTableViewCell: MenuPreviewProvider { - var navigationDelegate: TuskerNavigationDelegate? { return delegate } - func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { - let noteLabelPoint = noteLabel.convert(location, from: self) - if noteLabel.bounds.contains(noteLabelPoint), - let link = noteLabel.getLink(atPoint: noteLabelPoint) { - return ( - content: { self.noteLabel.getViewController(forLink: link.url, inRange: link.range) }, - actions: { - let text = (self.noteLabel.text! as NSString).substring(with: link.range) - if let mention = self.noteLabel.getMention(for: link.url, text: text) { - return self.actionsForProfile(accountID: mention.id) - } else if let hashtag = self.noteLabel.getHashtag(for: link.url, text: text) { - return self.actionsForHashtag(hashtag) - } else { - return self.actionsForURL(link.url) - } - } - ) - } else { - return nil - } - } -} +//extension ProfileHeaderTableViewCell: MenuPreviewProvider { +// var navigationDelegate: TuskerNavigationDelegate? { return delegate } +// func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { +// let noteLabelPoint = noteLabel.convert(location, from: self) +// if noteLabel.bounds.contains(noteLabelPoint), +// let link = noteLabel.getLink(atPoint: noteLabelPoint) { +// return ( +// content: { self.noteLabel.getViewController(forLink: link.url, inRange: link.range) }, +// actions: { +// let text = (self.noteLabel.text! as NSString).substring(with: link.range) +// if let mention = self.noteLabel.getMention(for: link.url, text: text) { +// return self.actionsForProfile(accountID: mention.id, sourceView: self) +// } else if let hashtag = self.noteLabel.getHashtag(for: link.url, text: text) { +// return self.actionsForHashtag(hashtag, sourceView: self) +// } else { +// return self.actionsForURL(link.url, sourceView: self) +// } +// } +// ) +// } else { +// return nil +// } +// } +//} diff --git a/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.xib b/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.xib index a34c2b7a..2c4e2202 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.xib +++ b/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.xib @@ -1,8 +1,8 @@ - + - + @@ -66,12 +66,13 @@ - + + @@ -165,7 +166,7 @@ - + diff --git a/Tusker/Views/Status/BaseStatusTableViewCell.swift b/Tusker/Views/Status/BaseStatusTableViewCell.swift index be586211..0b7f778e 100644 --- a/Tusker/Views/Status/BaseStatusTableViewCell.swift +++ b/Tusker/Views/Status/BaseStatusTableViewCell.swift @@ -18,7 +18,7 @@ class BaseStatusTableViewCell: UITableViewCell { var delegate: StatusTableViewCellDelegate? { didSet { - contentLabel.navigationDelegate = delegate + contentTextView.navigationDelegate = delegate } } var overrideMastodonController: MastodonController? @@ -29,7 +29,7 @@ class BaseStatusTableViewCell: UITableViewCell { @IBOutlet weak var usernameLabel: UILabel! @IBOutlet weak var contentWarningLabel: UILabel! @IBOutlet weak var collapseButton: UIButton! - @IBOutlet weak var contentLabel: StatusContentLabel! + @IBOutlet weak var contentTextView: StatusContentTextView! @IBOutlet weak var attachmentsView: AttachmentsContainerView! @IBOutlet weak var replyButton: UIButton! @IBOutlet weak var favoriteButton: UIButton! @@ -91,7 +91,7 @@ class BaseStatusTableViewCell: UITableViewCell { collapseButton.layer.masksToBounds = true collapseButton.layer.cornerRadius = 5 - accessibilityElements = [displayNameLabel!, contentWarningLabel!, collapseButton!, contentLabel!, attachmentsView!] + accessibilityElements = [displayNameLabel!, contentWarningLabel!, collapseButton!, contentTextView!, attachmentsView!] attachmentsView.isAccessibilityElement = true NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) @@ -134,7 +134,7 @@ class BaseStatusTableViewCell: UITableViewCell { updateStatusState(status: status) - contentLabel.statusID = statusID + contentTextView.statusID = statusID contentWarningLabel.text = status.spoilerText contentWarningLabel.isHidden = status.spoilerText.isEmpty @@ -143,7 +143,7 @@ class BaseStatusTableViewCell: UITableViewCell { collapsible = !status.spoilerText.isEmpty var shouldCollapse = collapsible if !shouldCollapse, - let text = contentLabel.text, + let text = contentTextView.text, text.count > 500 { collapsible = true shouldCollapse = true @@ -215,7 +215,7 @@ class BaseStatusTableViewCell: UITableViewCell { func setCollapsed(_ collapsed: Bool, animated: Bool) { self.collapsed = collapsed - contentLabel.isHidden = collapsed + contentTextView.isHidden = collapsed attachmentsView.isHidden = attachmentsView.attachments.count == 0 || collapsed let buttonImage = UIImage(systemName: collapsed ? "chevron.down" : "chevron.up")! @@ -301,7 +301,7 @@ class BaseStatusTableViewCell: UITableViewCell { } @IBAction func morePressed() { - delegate?.showMoreOptions(forStatus: statusID) + delegate?.showMoreOptions(forStatus: statusID, sourceView: moreButton) } @objc func accountPressed() { @@ -327,11 +327,10 @@ extension BaseStatusTableViewCell: MenuPreviewProvider { func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { guard let mastodonController = mastodonController else { return nil } if avatarImageView.frame.contains(location) { - return (content: { - ProfileTableViewController(accountID: self.accountID, mastodonController: mastodonController) - }, actions: { - self.actionsForProfile(accountID: self.accountID) - }) + return ( + content: { ProfileTableViewController(accountID: self.accountID, mastodonController: mastodonController) }, + actions: { self.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView) } + ) } else if attachmentsView.frame.contains(location) { let attachmentsViewLocation = attachmentsView.convert(location, from: self) if let attachmentView = attachmentsView.attachmentViews.allObjects.first(where: { $0.frame.contains(attachmentsViewLocation) }), @@ -339,22 +338,22 @@ extension BaseStatusTableViewCell: MenuPreviewProvider { let description = attachmentView.attachment.description return (content: { self.delegate?.largeImage(image, description: description, sourceView: attachmentView) }, actions: { [] }) } - } else if contentLabel.frame.contains(location), + }/* else if contentLabel.frame.contains(location), let link = contentLabel.getLink(atPoint: contentLabel.convert(location, from: self)) { return ( content: { self.contentLabel.getViewController(forLink: link.url, inRange: link.range) }, actions: { let text = (self.contentLabel.text! as NSString).substring(with: link.range) if let mention = self.contentLabel.getMention(for: link.url, text: text) { - return self.actionsForProfile(accountID: mention.id) + return self.actionsForProfile(accountID: mention.id, sourceView: self) } else if let hashtag = self.contentLabel.getHashtag(for: link.url, text: text) { - return self.actionsForHashtag(hashtag) + return self.actionsForHashtag(hashtag, sourceView: self) } else { - return self.actionsForURL(link.url) + return self.actionsForURL(link.url, sourceView: self) } } ) - } + }*/ return self.getStatusCellPreviewProviders(for: location, sourceViewController: sourceViewController) } } diff --git a/Tusker/Views/Status/ConversationMainStatusTableViewCell.swift b/Tusker/Views/Status/ConversationMainStatusTableViewCell.swift index 6052ee95..0621a423 100644 --- a/Tusker/Views/Status/ConversationMainStatusTableViewCell.swift +++ b/Tusker/Views/Status/ConversationMainStatusTableViewCell.swift @@ -33,7 +33,9 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell { profileAccessibilityElement = UIAccessibilityElement(accessibilityContainer: self) profileAccessibilityElement.accessibilityFrameInContainerSpace = profileDetailContainerView.convert(profileDetailContainerView.frame, to: self) - accessibilityElements = [profileAccessibilityElement!, contentWarningLabel!, collapseButton!, contentLabel!, totalFavoritesButton!, totalReblogsButton!, timestampAndClientLabel!, replyButton!, favoriteButton!, reblogButton!, moreButton!] + accessibilityElements = [profileAccessibilityElement!, contentWarningLabel!, collapseButton!, contentTextView!, totalFavoritesButton!, totalReblogsButton!, timestampAndClientLabel!, replyButton!, favoriteButton!, reblogButton!, moreButton!] + + contentTextView.defaultFont = .systemFont(ofSize: 20) } override func updateUI(statusID: String, state: StatusState) { diff --git a/Tusker/Views/Status/ConversationMainStatusTableViewCell.xib b/Tusker/Views/Status/ConversationMainStatusTableViewCell.xib index 93112ca2..d15d5795 100644 --- a/Tusker/Views/Status/ConversationMainStatusTableViewCell.xib +++ b/Tusker/Views/Status/ConversationMainStatusTableViewCell.xib @@ -1,8 +1,8 @@ - + - + @@ -75,12 +75,13 @@ - + + - +