diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 45c2b068..497e5b2d 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -231,6 +231,7 @@ D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */; }; D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */; }; D6B053AE23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */; }; + D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */; }; D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */; }; D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */; }; D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; }; @@ -281,6 +282,7 @@ D6E426B9253382B300C02E1C /* SearchResultType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B8253382B300C02E1C /* SearchResultType.swift */; }; D6E6F26321603F8B006A8599 /* CharacterCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26221603F8B006A8599 /* CharacterCounter.swift */; }; D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26421604242006A8599 /* CharacterCounterTests.swift */; }; + D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */; }; D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; }; D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; }; D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; }; @@ -570,6 +572,7 @@ D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCollectionViewCell.swift; sourceTree = ""; }; D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AssetCollectionViewCell.xib; sourceTree = ""; }; D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerSheetContainerViewController.swift; sourceTree = ""; }; + D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OppositeCollapseKeywordsView.swift; sourceTree = ""; }; D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGrayscalifier.swift; sourceTree = ""; }; D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameLabel.swift; sourceTree = ""; }; D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = ""; }; @@ -626,6 +629,7 @@ D6E4885C24A2890C0011C13E /* Tusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tusker.entitlements; 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 = ""; }; + D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = ""; }; D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = ""; }; D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = ""; }; D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = ""; }; @@ -1049,6 +1053,7 @@ 04586B4022B2FFB10021BD04 /* PreferencesView.swift */, 04586B4222B301470021BD04 /* AppearancePrefsView.swift */, 0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */, + D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */, D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */, D68015412401A74600D6103B /* MediaPrefsView.swift */, D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */, @@ -1303,6 +1308,7 @@ D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */, D6E426802532814100C02E1C /* MaybeLazyStack.swift */, D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */, + D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */, D67C57A721E2649B00C3118B /* Account Detail */, D626494023C122C800612E6E /* Asset Picker */, D61959D0241E842400A37B8E /* Draft Cell */, @@ -1870,6 +1876,7 @@ D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */, D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */, D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */, + D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */, D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */, D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */, D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */, @@ -1878,6 +1885,7 @@ D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */, D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */, D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */, + D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */, D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */, D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */, D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */, diff --git a/Tusker/Extensions/StatusStateResolver.swift b/Tusker/Extensions/StatusStateResolver.swift index 60079c51..098abf41 100644 --- a/Tusker/Extensions/StatusStateResolver.swift +++ b/Tusker/Extensions/StatusStateResolver.swift @@ -23,8 +23,23 @@ extension StatusState { let contentWarningCollapsible = !status.spoilerText.isEmpty + let collapseDueToContentWarning: Bool? + if contentWarningCollapsible { + let lowercased = status.spoilerText.lowercased() + let opposite = Preferences.shared.oppositeCollapseKeywords.contains { lowercased.contains($0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) } + + if Preferences.shared.expandAllContentWarnings { + collapseDueToContentWarning = opposite + } else { + collapseDueToContentWarning = !opposite + } + } else { + collapseDueToContentWarning = nil + } + self.collapsible = contentWarningCollapsible || longEnoughToCollapse - self.collapsed = longEnoughToCollapse || (!Preferences.shared.expandAllContentWarnings && contentWarningCollapsible) + // use ?? instead of || because the content warnig pref takes priority over length + self.collapsed = collapseDueToContentWarning ?? longEnoughToCollapse } } diff --git a/Tusker/Preferences/Preferences.swift b/Tusker/Preferences/Preferences.swift index bf5c924c..82a07756 100644 --- a/Tusker/Preferences/Preferences.swift +++ b/Tusker/Preferences/Preferences.swift @@ -55,16 +55,13 @@ class Preferences: Codable, ObservableObject { self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps) self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari) self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode) - if container.contains(.expandAllContentWarnings) { - self.expandAllContentWarnings = try container.decode(Bool.self, forKey: .expandAllContentWarnings) - } - if container.contains(.collapseLongPosts) { - self.collapseLongPosts = try container.decode(Bool.self, forKey: .collapseLongPosts) - } + self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false + self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true + self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? [] self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts) self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType) - self.grayscaleImages = try container.decode(Bool.self, forKey: .grayscaleImages) + self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false self.silentActions = try container.decode([String: Permission].self, forKey: .silentActions) self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType) @@ -93,6 +90,7 @@ class Preferences: Codable, ObservableObject { try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode) try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings) try container.encode(collapseLongPosts, forKey: .collapseLongPosts) + try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords) try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts) try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType) @@ -126,6 +124,7 @@ class Preferences: Codable, ObservableObject { @Published var inAppSafariAutomaticReaderMode = false @Published var expandAllContentWarnings = false @Published var collapseLongPosts = true + @Published var oppositeCollapseKeywords: [String] = [] // MARK: Digital Wellness @Published var showFavoriteAndReblogCounts = true @@ -157,6 +156,7 @@ class Preferences: Codable, ObservableObject { case inAppSafariAutomaticReaderMode case expandAllContentWarnings case collapseLongPosts + case oppositeCollapseKeywords case showFavoriteAndReblogCounts case defaultNotificationsType diff --git a/Tusker/Screens/Preferences/BehaviorPrefsView.swift b/Tusker/Screens/Preferences/BehaviorPrefsView.swift index b3e05f9b..73f9ee95 100644 --- a/Tusker/Screens/Preferences/BehaviorPrefsView.swift +++ b/Tusker/Screens/Preferences/BehaviorPrefsView.swift @@ -36,12 +36,16 @@ struct BehaviorPrefsView: View { var contentWarningsSection: some View { Section(header: Text("Content Warnings")) { + Toggle(isOn: $preferences.collapseLongPosts) { + Text("Collapse Long Posts") + } + Toggle(isOn: $preferences.expandAllContentWarnings) { Text("Expand All Content Warnings") } - Toggle(isOn: $preferences.collapseLongPosts) { - Text("Collapse Long Posts") + NavigationLink(destination: OppositeCollapseKeywordsView()) { + Text(preferences.expandAllContentWarnings ? "Collapse Posts with Keywords in CWs" : "Expand Posts with Keywords in CWs") } } } diff --git a/Tusker/Screens/Preferences/OppositeCollapseKeywordsView.swift b/Tusker/Screens/Preferences/OppositeCollapseKeywordsView.swift new file mode 100644 index 00000000..1bff11b3 --- /dev/null +++ b/Tusker/Screens/Preferences/OppositeCollapseKeywordsView.swift @@ -0,0 +1,105 @@ +// +// OppositeCollapseKeywordsView.swift +// Tusker +// +// Created by Shadowfacts on 11/1/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct OppositeCollapseKeywordsView: View { + @ObservedObject private var preferences = Preferences.shared + // Can't use the raw [String] for keywords, because we need a fixed ID (within the lifetime of this view) for ForEach + @State private var keywords: [Keyword] = Preferences.shared.oppositeCollapseKeywords.map(Keyword.init) { + didSet { + preferences.oppositeCollapseKeywords = keywords.map(\.value) + } + } + @State private var valueToAdd = "" + @State private var makeAddFieldFirstResponder = false + + var body: some View { + ZStack { + // the background from the grouped ListStyle clips to the safe area, so when the keyboard is hiding/showing + // the color behind it can be seen, which looks odd + Color(UIColor.secondarySystemBackground) + .edgesIgnoringSafeArea(.bottom) + + List { + Section(footer: Text("A post matches if its content warning contains the text of a keyword, ignoring case.")) { + ForEach(keywords) { (keyword) in + Row(keyword: keyword) { + keywords.removeAll(where: { $0.id == keyword.id }) + } + } + .onDelete(perform: self.removeKeywords) + + FocusableTextField(placeholder: "Add Keyword", text: $valueToAdd, becomeFirstResponder: $makeAddFieldFirstResponder, onCommit: self.addKeyword) + } + } + .animation(.default) + .listStyle(GroupedListStyle()) + } + .onAppear(perform: updateAppearance) + .navigationBarTitle(preferences.expandAllContentWarnings ? "Collapse Post CW Keywords" : "Expand Post CW Keywords") + } + + private func updateAppearance() { + UIScrollView.appearance(whenContainedInInstancesOf: [PreferencesNavigationController.self]).keyboardDismissMode = .interactive + } + + private func commitExisting(at index: Int) -> () -> Void { + return { + if keywords[index].value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + keywords.remove(at: index) + } + + makeAddFieldFirstResponder = true + } + } + + private func removeKeywords(_ indices: IndexSet) { + keywords.remove(atOffsets: indices) + } + + private func addKeyword() { + guard !valueToAdd.isEmpty else { return } + keywords.append(Keyword(valueToAdd)) + valueToAdd = "" + } +} + +fileprivate extension OppositeCollapseKeywordsView { + // Class for wrapping keywords that provides a fixed id SwiftUI's ForEach can use + class Keyword: ObservableObject, Identifiable { + let id = UUID() + @Published var value: String + + init(_ value: String) { + self.value = value + } + } +} + +fileprivate extension OppositeCollapseKeywordsView { + // Use a separate View for the row so it can use @ObservableObject to get a binding for the keyword's value + struct Row: View { + @ObservedObject var keyword: Keyword + let removeKeyword: () -> Void + + var body: some View { + FocusableTextField(placeholder: "Keyword", text: $keyword.value) { + if keyword.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + removeKeyword() + } + } + } + } +} + +struct OppositeCollapseKeywordsView_Previews: PreviewProvider { + static var previews: some View { + OppositeCollapseKeywordsView() + } +} diff --git a/Tusker/Views/FocusableTextField.swift b/Tusker/Views/FocusableTextField.swift new file mode 100644 index 00000000..82dbfdbf --- /dev/null +++ b/Tusker/Views/FocusableTextField.swift @@ -0,0 +1,73 @@ +// +// FocusableTextField.swift +// Tusker +// +// Created by Shadowfacts on 11/2/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct FocusableTextField: UIViewRepresentable { + typealias UIViewType = UITextField + + let placeholder: String + let text: Binding + let becomeFirstResponder: Binding? + let onCommit: (() -> Void)? + + init(placeholder: String, text: Binding, becomeFirstResponder: Binding? = nil, onCommit: (() -> Void)? = nil) { + self.placeholder = placeholder + self.text = text + self.becomeFirstResponder = becomeFirstResponder + self.onCommit = onCommit + } + + func makeUIView(context: Context) -> UITextField { + let field = UITextField() + field.delegate = context.coordinator + field.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged) + field.addTarget(context.coordinator, action: #selector(Coordinator.didEnd(_:)), for: .primaryActionTriggered) + return field + } + + func updateUIView(_ uiView: UITextField, context: Context) { + uiView.placeholder = placeholder + uiView.text = text.wrappedValue + + context.coordinator.text = text + context.coordinator.onCommit = onCommit + + if becomeFirstResponder?.wrappedValue == true { + DispatchQueue.main.async { + uiView.becomeFirstResponder() + becomeFirstResponder?.wrappedValue = false + } + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(text: text) + } + + class Coordinator: NSObject, UITextFieldDelegate { + var text: Binding + var onCommit: (() -> Void)? + + init(text: Binding) { + self.text = text + } + + @objc func didChange(_ textField: UITextField) { + text.wrappedValue = textField.text ?? "" + } + + @objc func didEnd(_ textField: UITextField) { + onCommit?() + } + + func textFieldDidEndEditing(_ textField: UITextField) { + onCommit?() + } + } +}